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
+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;