mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-08 07:46:59 +00:00
`bundleToSingleHtml` compiles `data-duration` into `data-end` (in `compileTimingAttrs`), then calls `validateHyperframeHtmlContract` against the compiled HTML. The linter's `deprecated_data_end` rule fired on the compiler's own consistent output — `<audio data-start="0" data-duration="18" data-end="18">` — because `diagnoseDerivedEnd` unconditionally emitted a `deprecated-end` diagnostic whenever both attributes were present, ignoring whether the derived value matched. Reporters routed this as "raw-source lint passes with 0 errors and 0 warnings, but `check --strict` still logs StaticGuard noise about data-end without data-duration." Field cluster: cli-feedback crons 61-68, n=25+ across darwin/arm64, darwin/x64, linux/x64, win32/x64, and versions 0.7.56 through 0.7.64. L3 reporter cite (ts=1784519869): "bundleToSingleHtml compiles data-duration into data-end, then validates the compiled HTML and reports its own generated data-end as deprecated." Fix: `diagnoseDerivedEnd` now stays silent when the paired `data-end` matches `data-start + data-duration` (within a 1ns epsilon to absorb IEEE-754 residuals like `0.1 + 0.2 = 0.30000000000000004`). Truly-legacy authoring shapes — `data-end` alone with no `data-duration`, or a `data-end` that disagrees with `data-duration` — still fire `deprecated_data_end`, with a refined message that names the drift on the conflicting variant. Facets covered: (a) validator treats compiler-derived `data-end` as legal when paired with `data-duration`, and (e) recognizes the compile-time rewrite site (`bundleToSingleHtml` → `compileHtml` → `compileTimingAttrs`). Facets (b) stderr gating, (c) terminal JSON verdict, and (d) audio-not- dropped are unblocked transitively — StaticGuard's `console.warn` is already gated on `!isValid` and never drops audio; the check command already emits JSON on every terminal path; so once the false-positive diagnostic stops firing on compiler output, the noisy stderr line and the misleading "check appears to fail" reporter framing both go away without further wiring. Co-Authored-By: Via <noreply@heygen.com>
477 lines
17 KiB
TypeScript
477 lines
17 KiB
TypeScript
/**
|
|
* Browser-safe authored composition contract.
|
|
*
|
|
* Source HTML is authored with data-start + data-duration + data-track-index.
|
|
* data-end is compiler-derived and data-layer is legacy input only. Readers
|
|
* accept legacy documents; writers always emit the canonical representation.
|
|
*/
|
|
|
|
export const COMPOSITION_CONTRACT_VERSION = 1 as const;
|
|
|
|
export const COMPOSITION_ATTRIBUTES = Object.freeze({
|
|
start: "data-start",
|
|
duration: "data-duration",
|
|
trackIndex: "data-track-index",
|
|
derivedEnd: "data-end",
|
|
legacyTrack: "data-layer",
|
|
} as const);
|
|
|
|
export const CANONICAL_AUTHORED_TIMING_ATTRIBUTES = Object.freeze([
|
|
COMPOSITION_ATTRIBUTES.start,
|
|
COMPOSITION_ATTRIBUTES.duration,
|
|
COMPOSITION_ATTRIBUTES.trackIndex,
|
|
] as const);
|
|
|
|
export const DERIVED_TIMING_ATTRIBUTES = Object.freeze([
|
|
COMPOSITION_ATTRIBUTES.derivedEnd,
|
|
] as const);
|
|
|
|
export const LEGACY_TIMING_ATTRIBUTES = Object.freeze([
|
|
COMPOSITION_ATTRIBUTES.derivedEnd,
|
|
COMPOSITION_ATTRIBUTES.legacyTrack,
|
|
] as const);
|
|
|
|
export type ReferenceExpression =
|
|
| { kind: "absolute"; value: number }
|
|
| { kind: "reference"; refId: string; offset: number };
|
|
|
|
export type ClipTimingDiagnosticCode =
|
|
| "invalid-start"
|
|
| "unresolved-start-reference"
|
|
| "invalid-duration"
|
|
| "invalid-end"
|
|
| "end-before-start"
|
|
| "deprecated-end"
|
|
| "conflicting-end"
|
|
| "invalid-track-index"
|
|
| "deprecated-layer"
|
|
| "conflicting-layer";
|
|
|
|
export interface ClipTimingDiagnostic {
|
|
code: ClipTimingDiagnosticCode;
|
|
attribute: string;
|
|
value: string | null;
|
|
}
|
|
|
|
export interface ClipAttributeReader {
|
|
getAttribute(name: string): string | null;
|
|
}
|
|
|
|
export interface ClipAttributeWriter extends ClipAttributeReader {
|
|
setAttribute(name: string, value: string): void;
|
|
removeAttribute(name: string): void;
|
|
}
|
|
|
|
export interface ReadClipTimingOptions {
|
|
/** Resolve a reference to the referenced clip's absolute end time. */
|
|
resolveReferenceEnd?: (refId: string) => number | null | undefined;
|
|
/** Start used when data-start is absent. Defaults to zero. */
|
|
defaultStart?: number | null;
|
|
}
|
|
|
|
export interface ClipTiming {
|
|
startExpression: ReferenceExpression | null;
|
|
start: number | null;
|
|
duration: number | null;
|
|
end: number | null;
|
|
trackIndex: number;
|
|
durationSource: "duration" | "legacy-end" | "missing" | "invalid";
|
|
trackSource: "track-index" | "legacy-layer" | "default" | "invalid";
|
|
diagnostics: ClipTimingDiagnostic[];
|
|
}
|
|
|
|
export interface ClipTimingUpdate {
|
|
start?: number | string | ReferenceExpression;
|
|
duration?: number | null;
|
|
trackIndex?: number | null;
|
|
}
|
|
|
|
export class ClipTimingWriteError extends Error {
|
|
readonly code: "invalid-start" | "invalid-duration" | "invalid-track-index";
|
|
|
|
constructor(code: ClipTimingWriteError["code"], message: string) {
|
|
super(message);
|
|
this.name = "ClipTimingWriteError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
/** Parse a value to a finite number, or null if it is absent/invalid. */
|
|
export function parseNumeric(value: string | null | undefined): number | null {
|
|
if (value == null || value.trim() === "") return null;
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
const REFERENCE_ID_PATTERN = /^[A-Za-z0-9_.:-]+$/;
|
|
|
|
function isAsciiDigitAt(value: string, index: number): boolean {
|
|
const code = value.charCodeAt(index);
|
|
return code >= 48 && code <= 57;
|
|
}
|
|
|
|
function skipDigitsLeft(value: string, start: number): number {
|
|
let cursor = start;
|
|
while (cursor >= 0 && isAsciiDigitAt(value, cursor)) cursor--;
|
|
return cursor;
|
|
}
|
|
|
|
function skipWhitespaceLeft(value: string, start: number): number {
|
|
let cursor = start;
|
|
while (cursor >= 0 && (value[cursor] ?? "").trim() === "") cursor--;
|
|
return cursor;
|
|
}
|
|
|
|
function findMagnitudeStart(value: string): number | null {
|
|
const last = value.length - 1;
|
|
if (!isAsciiDigitAt(value, last)) return null;
|
|
let cursor = skipDigitsLeft(value, last);
|
|
if (value[cursor] === ".") cursor = skipDigitsLeft(value, cursor - 1);
|
|
return cursor + 1;
|
|
}
|
|
|
|
function parseReferenceOffset(
|
|
value: string,
|
|
): { refId: string; operator: "+" | "-"; magnitude: number } | null {
|
|
const magnitudeStart = findMagnitudeStart(value);
|
|
if (magnitudeStart == null) return null;
|
|
const operatorIndex = skipWhitespaceLeft(value, magnitudeStart - 1);
|
|
const operator = value[operatorIndex];
|
|
if (operator !== "+" && operator !== "-") return null;
|
|
|
|
const refId = value.slice(0, operatorIndex).trim();
|
|
if (!REFERENCE_ID_PATTERN.test(refId)) return null;
|
|
const magnitude = Number(value.slice(magnitudeStart));
|
|
if (!Number.isFinite(magnitude)) return null;
|
|
return { refId, operator, magnitude };
|
|
}
|
|
|
|
/**
|
|
* Parse the data-start grammar: absolute seconds, `clip-id`, or
|
|
* `clip-id +/- offset`, where references resolve to the referenced clip's end.
|
|
*/
|
|
export function parseStartExpression(raw: string | null | undefined): ReferenceExpression | null {
|
|
const normalized = (raw ?? "").trim();
|
|
if (!normalized) return null;
|
|
const absolute = parseNumeric(normalized);
|
|
if (absolute != null) return { kind: "absolute", value: absolute };
|
|
if (REFERENCE_ID_PATTERN.test(normalized)) {
|
|
return { kind: "reference", refId: normalized, offset: 0 };
|
|
}
|
|
const reference = parseReferenceOffset(normalized);
|
|
if (!reference) return null;
|
|
return {
|
|
kind: "reference",
|
|
refId: reference.refId,
|
|
offset: reference.operator === "-" ? -reference.magnitude : reference.magnitude,
|
|
};
|
|
}
|
|
|
|
function pushDiagnostic(
|
|
diagnostics: ClipTimingDiagnostic[],
|
|
code: ClipTimingDiagnosticCode,
|
|
attribute: string,
|
|
value: string | null,
|
|
): void {
|
|
diagnostics.push({ code, attribute, value });
|
|
}
|
|
|
|
function resolveStart(
|
|
expression: ReferenceExpression | null,
|
|
rawStart: string | null,
|
|
options: ReadClipTimingOptions,
|
|
diagnostics: ClipTimingDiagnostic[],
|
|
): number | null {
|
|
if (rawStart == null || rawStart.trim() === "") {
|
|
return options.defaultStart === undefined ? 0 : options.defaultStart;
|
|
}
|
|
if (!expression) {
|
|
pushDiagnostic(diagnostics, "invalid-start", COMPOSITION_ATTRIBUTES.start, rawStart);
|
|
return null;
|
|
}
|
|
if (expression.kind === "absolute") return Math.max(0, expression.value);
|
|
const referencedEnd = options.resolveReferenceEnd?.(expression.refId);
|
|
if (referencedEnd == null || !Number.isFinite(referencedEnd)) {
|
|
pushDiagnostic(
|
|
diagnostics,
|
|
"unresolved-start-reference",
|
|
COMPOSITION_ATTRIBUTES.start,
|
|
rawStart,
|
|
);
|
|
return null;
|
|
}
|
|
return Math.max(0, referencedEnd + expression.offset);
|
|
}
|
|
|
|
type DurationRead = Pick<ClipTiming, "duration" | "end" | "durationSource">;
|
|
type TrackRead = Pick<ClipTiming, "trackIndex" | "trackSource">;
|
|
|
|
// Float pairs like `data-start="0.1" + data-duration="0.2"` add to
|
|
// `0.30000000000000004`, so the compiler-emitted `data-end` string can be a few
|
|
// ulps off the sum a reader computes from the canonical inputs. Any drift below
|
|
// this ceiling is treated as equal for the derived-end reconciliation check —
|
|
// well below one 60fps frame (~16.67 ms) and orders of magnitude above the
|
|
// worst realistic IEEE-754 residual (~2e-16 s).
|
|
const DERIVED_END_EQUALITY_EPSILON_SECONDS = 1e-9;
|
|
|
|
function derivedEndsAreConsistent(parsedEnd: number, canonicalEnd: number): boolean {
|
|
return Math.abs(parsedEnd - canonicalEnd) <= DERIVED_END_EQUALITY_EPSILON_SECONDS;
|
|
}
|
|
|
|
// Reconcile a `data-end` attribute paired with canonical `data-duration`.
|
|
//
|
|
// The compiler (`compileTimingAttrs` in @hyperframes/core) writes
|
|
// `data-end = data-start + data-duration` into the bundled HTML so the runtime
|
|
// can key off a single attribute. That derived attribute is legitimate — it is
|
|
// not a legacy authoring shape. Treating it as `deprecated-end` here made the
|
|
// linter fire `deprecated_data_end` on the compiler's own output whenever
|
|
// StaticGuard re-validated a bundled composition, producing a false-positive
|
|
// cluster reported in the field (n=25+ across cli-feedback crons 61-68).
|
|
//
|
|
// We keep `deprecated-end` for the truly legacy shape (see `readLegacyEnd` —
|
|
// `data-end` present without `data-duration`) and for the *conflicting* case
|
|
// where an author left a stale `data-end` behind a canonical duration edit; a
|
|
// stale end must not be silently overridden without a diagnostic.
|
|
function diagnoseDerivedEnd(
|
|
rawEnd: string,
|
|
canonicalEnd: number | null,
|
|
diagnostics: ClipTimingDiagnostic[],
|
|
): void {
|
|
const parsedEnd = parseNumeric(rawEnd);
|
|
if (parsedEnd == null) {
|
|
pushDiagnostic(diagnostics, "deprecated-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd);
|
|
pushDiagnostic(diagnostics, "invalid-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd);
|
|
return;
|
|
}
|
|
if (canonicalEnd == null) {
|
|
// Canonical duration exists but resolved end is unknown (e.g. unresolved
|
|
// reference start). Preserve prior behavior — flag as deprecated so authors
|
|
// remove the stale companion attribute.
|
|
pushDiagnostic(diagnostics, "deprecated-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd);
|
|
return;
|
|
}
|
|
if (derivedEndsAreConsistent(parsedEnd, canonicalEnd)) {
|
|
// Compiler-derived (or manually consistent) `data-end` alongside
|
|
// `data-duration` — silent. No diagnostic.
|
|
return;
|
|
}
|
|
pushDiagnostic(diagnostics, "deprecated-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd);
|
|
pushDiagnostic(diagnostics, "conflicting-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd);
|
|
}
|
|
|
|
function readCanonicalDuration(
|
|
rawDuration: string,
|
|
start: number | null,
|
|
diagnostics: ClipTimingDiagnostic[],
|
|
): DurationRead {
|
|
const duration = parseNumeric(rawDuration);
|
|
if (duration == null || duration < 0) {
|
|
pushDiagnostic(diagnostics, "invalid-duration", COMPOSITION_ATTRIBUTES.duration, rawDuration);
|
|
return { duration: null, end: null, durationSource: "invalid" };
|
|
}
|
|
return {
|
|
duration,
|
|
end: start == null ? null : start + duration,
|
|
durationSource: "duration",
|
|
};
|
|
}
|
|
|
|
function readLegacyEnd(
|
|
rawEnd: string,
|
|
start: number | null,
|
|
diagnostics: ClipTimingDiagnostic[],
|
|
): DurationRead {
|
|
pushDiagnostic(diagnostics, "deprecated-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd);
|
|
const end = parseNumeric(rawEnd);
|
|
if (end == null) {
|
|
pushDiagnostic(diagnostics, "invalid-end", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd);
|
|
return { duration: null, end: null, durationSource: "invalid" };
|
|
}
|
|
if (start == null) return { duration: null, end, durationSource: "legacy-end" };
|
|
if (end < start) {
|
|
pushDiagnostic(diagnostics, "end-before-start", COMPOSITION_ATTRIBUTES.derivedEnd, rawEnd);
|
|
return { duration: null, end: null, durationSource: "invalid" };
|
|
}
|
|
return { duration: end - start, end, durationSource: "legacy-end" };
|
|
}
|
|
|
|
function readDuration(
|
|
attributes: ClipAttributeReader,
|
|
start: number | null,
|
|
diagnostics: ClipTimingDiagnostic[],
|
|
): DurationRead {
|
|
const rawDuration = attributes.getAttribute(COMPOSITION_ATTRIBUTES.duration);
|
|
const rawEnd = attributes.getAttribute(COMPOSITION_ATTRIBUTES.derivedEnd);
|
|
if (rawDuration == null) {
|
|
return rawEnd == null
|
|
? { duration: null, end: null, durationSource: "missing" }
|
|
: readLegacyEnd(rawEnd, start, diagnostics);
|
|
}
|
|
const canonical = readCanonicalDuration(rawDuration, start, diagnostics);
|
|
if (rawEnd != null) diagnoseDerivedEnd(rawEnd, canonical.end, diagnostics);
|
|
return canonical;
|
|
}
|
|
|
|
function readTrackValue(
|
|
rawValue: string,
|
|
attribute: string,
|
|
source: "track-index" | "legacy-layer",
|
|
diagnostics: ClipTimingDiagnostic[],
|
|
): TrackRead {
|
|
const trackIndex = parseNumeric(rawValue);
|
|
if (trackIndex == null || !Number.isInteger(trackIndex)) {
|
|
pushDiagnostic(diagnostics, "invalid-track-index", attribute, rawValue);
|
|
return { trackIndex: 0, trackSource: "invalid" };
|
|
}
|
|
return { trackIndex, trackSource: source };
|
|
}
|
|
|
|
function readTrack(
|
|
attributes: ClipAttributeReader,
|
|
diagnostics: ClipTimingDiagnostic[],
|
|
): TrackRead {
|
|
const rawTrack = attributes.getAttribute(COMPOSITION_ATTRIBUTES.trackIndex);
|
|
const rawLayer = attributes.getAttribute(COMPOSITION_ATTRIBUTES.legacyTrack);
|
|
if (rawTrack == null && rawLayer == null) return { trackIndex: 0, trackSource: "default" };
|
|
if (rawTrack == null && rawLayer != null) {
|
|
pushDiagnostic(diagnostics, "deprecated-layer", COMPOSITION_ATTRIBUTES.legacyTrack, rawLayer);
|
|
return readTrackValue(
|
|
rawLayer,
|
|
COMPOSITION_ATTRIBUTES.legacyTrack,
|
|
"legacy-layer",
|
|
diagnostics,
|
|
);
|
|
}
|
|
const canonical = readTrackValue(
|
|
rawTrack ?? "",
|
|
COMPOSITION_ATTRIBUTES.trackIndex,
|
|
"track-index",
|
|
diagnostics,
|
|
);
|
|
if (rawLayer == null) return canonical;
|
|
pushDiagnostic(diagnostics, "deprecated-layer", COMPOSITION_ATTRIBUTES.legacyTrack, rawLayer);
|
|
const parsedLayer = parseNumeric(rawLayer);
|
|
if (parsedLayer != null && parsedLayer !== canonical.trackIndex) {
|
|
pushDiagnostic(diagnostics, "conflicting-layer", COMPOSITION_ATTRIBUTES.legacyTrack, rawLayer);
|
|
}
|
|
return canonical;
|
|
}
|
|
|
|
/** Read canonical timing, accepting legacy attributes without preferring them. */
|
|
export function readClipTiming(
|
|
attributes: ClipAttributeReader,
|
|
options: ReadClipTimingOptions = {},
|
|
): ClipTiming {
|
|
const diagnostics: ClipTimingDiagnostic[] = [];
|
|
const rawStart = attributes.getAttribute(COMPOSITION_ATTRIBUTES.start);
|
|
const startExpression = parseStartExpression(rawStart);
|
|
const start = resolveStart(startExpression, rawStart, options, diagnostics);
|
|
const duration = readDuration(attributes, start, diagnostics);
|
|
const track = readTrack(attributes, diagnostics);
|
|
return { startExpression, start, ...duration, ...track, diagnostics };
|
|
}
|
|
|
|
function serializeStartNumber(start: number): string {
|
|
if (!Number.isFinite(start)) {
|
|
throw new ClipTimingWriteError("invalid-start", "start must be finite");
|
|
}
|
|
return String(start);
|
|
}
|
|
|
|
function serializeStartReference(start: ReferenceExpression): string {
|
|
if (start.kind === "absolute") return serializeStartNumber(start.value);
|
|
if (!start.refId || !Number.isFinite(start.offset)) {
|
|
throw new ClipTimingWriteError(
|
|
"invalid-start",
|
|
"reference start must have an id and finite offset",
|
|
);
|
|
}
|
|
if (start.offset === 0) return start.refId;
|
|
return `${start.refId} ${start.offset < 0 ? "-" : "+"} ${Math.abs(start.offset)}`;
|
|
}
|
|
|
|
function serializeStart(start: ClipTimingUpdate["start"]): string {
|
|
if (typeof start === "number") return serializeStartNumber(start);
|
|
if (typeof start === "object" && start != null) return serializeStartReference(start);
|
|
if (typeof start === "string" && parseStartExpression(start)) return start.trim();
|
|
throw new ClipTimingWriteError("invalid-start", `invalid start expression: ${start ?? ""}`);
|
|
}
|
|
|
|
function writeDuration(
|
|
attributes: ClipAttributeWriter,
|
|
duration: number | null | undefined,
|
|
current: ClipTiming,
|
|
): void {
|
|
if (duration === null) {
|
|
attributes.removeAttribute(COMPOSITION_ATTRIBUTES.duration);
|
|
return;
|
|
}
|
|
if (duration === undefined) {
|
|
if (
|
|
attributes.getAttribute(COMPOSITION_ATTRIBUTES.duration) == null &&
|
|
current.duration != null
|
|
) {
|
|
attributes.setAttribute(COMPOSITION_ATTRIBUTES.duration, String(current.duration));
|
|
}
|
|
return;
|
|
}
|
|
if (!Number.isFinite(duration) || duration < 0) {
|
|
throw new ClipTimingWriteError(
|
|
"invalid-duration",
|
|
"duration must be a finite, non-negative number",
|
|
);
|
|
}
|
|
attributes.setAttribute(COMPOSITION_ATTRIBUTES.duration, String(duration));
|
|
}
|
|
|
|
function writeTrack(
|
|
attributes: ClipAttributeWriter,
|
|
trackIndex: number | null | undefined,
|
|
current: ClipTiming,
|
|
): void {
|
|
if (trackIndex === null) {
|
|
attributes.removeAttribute(COMPOSITION_ATTRIBUTES.trackIndex);
|
|
return;
|
|
}
|
|
if (trackIndex === undefined) {
|
|
if (
|
|
attributes.getAttribute(COMPOSITION_ATTRIBUTES.trackIndex) == null &&
|
|
current.trackSource === "legacy-layer"
|
|
) {
|
|
attributes.setAttribute(COMPOSITION_ATTRIBUTES.trackIndex, String(current.trackIndex));
|
|
}
|
|
return;
|
|
}
|
|
if (!Number.isFinite(trackIndex) || !Number.isInteger(trackIndex)) {
|
|
throw new ClipTimingWriteError("invalid-track-index", "trackIndex must be a finite integer");
|
|
}
|
|
attributes.setAttribute(COMPOSITION_ATTRIBUTES.trackIndex, String(trackIndex));
|
|
}
|
|
|
|
/** Write only canonical authored timing and remove derived/legacy source attributes. */
|
|
export function writeClipTiming(
|
|
attributes: ClipAttributeWriter,
|
|
update: ClipTimingUpdate,
|
|
): ClipTiming {
|
|
const current = readClipTiming(attributes);
|
|
if (update.start !== undefined) {
|
|
attributes.setAttribute(COMPOSITION_ATTRIBUTES.start, serializeStart(update.start));
|
|
}
|
|
writeDuration(attributes, update.duration, current);
|
|
writeTrack(attributes, update.trackIndex, current);
|
|
// A legacy end paired with an unresolved reference start cannot be converted
|
|
// to data-duration without a reference resolver. On a duration-omitting edit
|
|
// (for example, moving only the track), preserve that sole duration source
|
|
// instead of canonicalizing it into data loss.
|
|
const preserveUnresolvedLegacyEnd =
|
|
update.duration === undefined &&
|
|
attributes.getAttribute(COMPOSITION_ATTRIBUTES.duration) == null &&
|
|
attributes.getAttribute(COMPOSITION_ATTRIBUTES.derivedEnd) != null &&
|
|
current.duration == null;
|
|
if (!preserveUnresolvedLegacyEnd) {
|
|
attributes.removeAttribute(COMPOSITION_ATTRIBUTES.derivedEnd);
|
|
}
|
|
attributes.removeAttribute(COMPOSITION_ATTRIBUTES.legacyTrack);
|
|
return readClipTiming(attributes);
|
|
}
|