fix(engine): resolve relative data-start references in video-frame extraction

* fix(engine): resolve relative data-start references in video-frame extraction

<video data-start="intro"> (a relative reference to another clip's end) is
resolved by the browser runtime but parseVideoElements/parseImageElements did a
raw parseFloat, yielding NaN start/end. The FrameLookupTable active-window
checks (start <= t <= end) are then always false, so the clip is never injected
and composites BLANK in the final render — while lint/validate/inspect/snapshot
and the live preview all look fine. The docs' Relative Timing section teaches
exactly this pattern on <video>.

Share the pure reference-syntax parser (parseStartExpression) out of the runtime
resolver into @hyperframes/core, and resolve references in the extractor against
the linkedom document it already holds: a reference resolves to the target
clip's resolved start + its duration (data-duration or data-end) + offset,
mirroring the runtime. Cycle-guarded; an unknown target or unknown duration
falls back to the target's start / 0 (never NaN), matching runtime semantics.
Natural-media-duration-only targets aren't known at parse time (same limit as
the runtime's fallback). parseImageElements gets the same fix.

Runtime resolver behavior is unchanged (its 25-case suite still passes).

* chore: re-trigger CI to refresh a stuck CodeQL aggregate check
This commit is contained in:
Miguel Ángel
2026-07-07 18:11:21 -04:00
committed by GitHub
parent 42a209545b
commit 4a36655b2b
5 changed files with 246 additions and 39 deletions
+5
View File
@@ -232,6 +232,11 @@ export type { FitTextOptions, FitTextResult } from "./text/index.js";
// Runtime helpers (composition-side)
export { getVariables } from "./runtime/getVariables.js";
export {
parseStartExpression,
parseNumeric,
type ReferenceExpression,
} from "./runtime/startExpression.js";
// Variable validation (CLI / tooling-side)
export {
@@ -0,0 +1,47 @@
/**
* Pure parser for the `data-start` timing expression grammar, shared by the
* browser runtime resolver (`createRuntimeStartTimeResolver`) and the Node-side
* video-frame extractor (`parseVideoElements`) so both agree on exactly what a
* relative reference means. No DOM/browser dependencies — safe to import in
* Node.
*
* Grammar (matches the docs' "Relative Timing" section):
* - `"12.5"` -> absolute seconds
* - `"intro"` -> start when clip `intro` ends
* - `"intro + 2"` -> 2s after `intro` ends
* - `"intro - 0.5"` -> 0.5s before `intro` ends (overlap)
*/
export type ReferenceExpression =
| { kind: "absolute"; value: number }
| { kind: "reference"; refId: string; offset: number };
/** Parse a value to a finite number, or `null` if it isn't one. */
export function parseNumeric(value: string | null | undefined): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Parse a raw `data-start` value into an absolute time or a clip reference.
* Returns `null` when the value is empty or not a recognized expression.
*/
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 };
}
const referenceMatch = normalized.match(/^([A-Za-z0-9_.:-]+)(?:\s*([+-])\s*([0-9]*\.?[0-9]+))?$/);
if (!referenceMatch) return null;
const refId = (referenceMatch[1] ?? "").trim();
if (!refId) return null;
const sign = referenceMatch[2] ?? "+";
const offsetRaw = referenceMatch[3] ?? "0";
const parsedOffset = Number.parseFloat(offsetRaw);
const offsetMagnitude = Number.isFinite(parsedOffset) ? Math.max(0, parsedOffset) : 0;
const offset = sign === "-" ? -offsetMagnitude : offsetMagnitude;
return { kind: "reference", refId, offset };
}
+1 -36
View File
@@ -1,27 +1,11 @@
import type { RuntimeTimelineLike } from "./types";
import { swallow } from "./diagnostics";
import { readElementPlaybackRate } from "./media";
import { parseNumeric, parseStartExpression } from "./startExpression";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
type ReferenceExpression =
| {
kind: "absolute";
value: number;
}
| {
kind: "reference";
refId: string;
offset: number;
};
function parseNumeric(value: string | null | undefined): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseDurationAttr(element: Element): number | null {
return parseNumeric(element.getAttribute("data-duration"));
}
@@ -38,25 +22,6 @@ function parseAuthoredEndAttr(element: Element): number | null {
return parseNumeric(element.getAttribute(AUTHORED_END_ATTR));
}
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 };
}
const referenceMatch = normalized.match(/^([A-Za-z0-9_.:-]+)(?:\s*([+-])\s*([0-9]*\.?[0-9]+))?$/);
if (!referenceMatch) return null;
const refId = (referenceMatch[1] ?? "").trim();
if (!refId) return null;
const sign = referenceMatch[2] ?? "+";
const offsetRaw = referenceMatch[3] ?? "0";
const parsedOffset = Number.parseFloat(offsetRaw);
const offsetMagnitude = Number.isFinite(parsedOffset) ? Math.max(0, parsedOffset) : 0;
const offset = sign === "-" ? -offsetMagnitude : offsetMagnitude;
return { kind: "reference", refId, offset };
}
export function createRuntimeStartTimeResolver(params: {
timelineRegistry?: Record<string, RuntimeTimelineLike | undefined>;
includeAuthoredTimingAttrs?: boolean;