mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core,engine,producer): handle id-less media in sub-composition renders (#96)
## What Move the id-less media fix into the shared timing compiler so producer can resolve durations for sub-composition videos before inlining, then carry the merged result through engine parsing, regression coverage, and the regression Docker image used in CI. This PR now does five concrete things: - assigns stable ids to id-less media in core `compileTimingAttrs()` so unresolved duration injection can target them - keeps the engine-side `parseVideoElements()` support for `video[src]` plus the newer `data-duration` / natural-duration fallback from `main` - makes producer prefer sub-composition media metadata over the later inlined-document parse when the same media id appears in both places - makes `sub-composition-video` a runnable regression test by fixing its metadata and checking in the missing `output/compiled.html` snapshot - removes the stale `pnpm-workspace.yaml` copy step from `Dockerfile.test`, so regression CI builds the Bun-based test image from the current workspace layout ## Why - media without an explicit `id` could not participate in unresolved-duration resolution early enough - producer could lose the resolved sub-composition timing by overwriting it with the later inlined parse - the regression fixture intended to cover this case was not actually running in CI because its `meta.json` was incomplete and the required compiled snapshot was missing - the regression image definition still expected a deleted `pnpm-workspace.yaml`, so GitHub Actions failed before the test shard could start Putting the id-generation step in core makes the behavior reusable instead of relying on producer-only HTML patching. ## How ### Shared compiler - core `compileTimingAttrs()` now auto-assigns stable ids to id-less `video` / `audio` tags - those generated ids are returned in `unresolved`, so `injectDurations()` can add `data-duration` and `data-end` to the same media element later in the pipeline - added core tests that cover auto-id assignment and duration injection for generated ids ### Producer - when producer combines `subVideos` / `subAudios` with the media re-parsed from the final inlined HTML, it now lets the sub-composition metadata win - this preserves the resolved/clamped timing already computed for nested media instead of overwriting it with the later parse - `sub-composition-video` now has valid regression metadata and a checked-in `output/compiled.html` snapshot so CI actually executes it ### Engine - resolved the merge conflict in `videoFrameExtractor` by keeping the broader `video[src]` parsing from this branch and the `data-duration` / natural-duration fallback that landed on `main` - added a focused engine unit test for videos without ids ### CI image - `Dockerfile.test` now copies only `package.json` and `bun.lock` at the workspace root before `bun install --frozen-lockfile` - this matches the current monorepo layout and removes the obsolete pnpm-era dependency on `pnpm-workspace.yaml` ## Test plan - [x] `bun run --filter @hyperframes/core test` - [x] `bun run --filter @hyperframes/engine test` - [x] `bun run --filter @hyperframes/producer test --update --sequential sub-composition-video` - [x] `bun run --filter @hyperframes/producer test --sequential sub-composition-video` - [x] Browser check with `agent-browser` against the compiled fixture page (`http://127.0.0.1:8123/compiled.html`) - [x] Clean tracked-only Docker build of `Dockerfile.test` with the PR version of the file applied ## Notes - Latest regression workflow is green on `main`, but before this PR the `sub-composition-video` fixture was being skipped by the harness rather than exercised end to end. - The CI Docker fix was validated from a tracked-only export to avoid local untracked worktree artifacts affecting the result.
This commit is contained in:
@@ -35,6 +35,18 @@ describe("compileTimingAttrs", () => {
|
||||
expect(unresolved[0].start).toBe(1);
|
||||
});
|
||||
|
||||
it("auto-assigns ids to id-less videos so unresolved duration resolution can target them", () => {
|
||||
const html = '<video src="a.mp4" data-start="1">';
|
||||
const { html: compiled, unresolved } = compileTimingAttrs(html);
|
||||
|
||||
expect(compiled).toContain('id="hf-video-0"');
|
||||
expect(compiled).toContain('data-has-audio="true"');
|
||||
expect(unresolved).toHaveLength(1);
|
||||
expect(unresolved[0].id).toBe("hf-video-0");
|
||||
expect(unresolved[0].tagName).toBe("video");
|
||||
expect(unresolved[0].start).toBe(1);
|
||||
});
|
||||
|
||||
it("compiles audio tags the same as video (minus data-has-audio)", () => {
|
||||
const html = '<audio id="a1" src="music.mp3" data-start="0" data-duration="10">';
|
||||
const { html: compiled } = compileTimingAttrs(html);
|
||||
@@ -70,6 +82,15 @@ describe("injectDurations", () => {
|
||||
expect(result).toContain('data-end="6"');
|
||||
});
|
||||
|
||||
it("injects durations for auto-assigned media ids", () => {
|
||||
const { html, unresolved } = compileTimingAttrs('<video src="a.mp4" data-start="1">');
|
||||
const result = injectDurations(html, [{ id: unresolved[0]!.id, duration: 4 }]);
|
||||
|
||||
expect(result).toContain('id="hf-video-0"');
|
||||
expect(result).toContain('data-duration="4"');
|
||||
expect(result).toContain('data-end="5"');
|
||||
});
|
||||
|
||||
it("does not overwrite existing data-duration", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="3">';
|
||||
const result = injectDurations(html, [{ id: "v1", duration: 10 }]);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Works in both Node.js and browser (no dependencies, regex-based).
|
||||
*
|
||||
* Guarantees every timed element gets:
|
||||
* - id on media elements when missing
|
||||
* - data-end (computed from data-start + data-duration when possible)
|
||||
* - data-has-audio="true" on <video> elements
|
||||
*
|
||||
@@ -66,11 +67,16 @@ function injectAttr(tag: string, attr: string, value: string): string {
|
||||
function compileTag(
|
||||
tag: string,
|
||||
isVideo: boolean,
|
||||
generateId: () => number,
|
||||
): { tag: string; unresolved: UnresolvedElement | null } {
|
||||
let result = tag;
|
||||
let unresolved: UnresolvedElement | null = null;
|
||||
|
||||
const id = getAttr(result, "id");
|
||||
let id = getAttr(result, "id");
|
||||
if (!id) {
|
||||
id = `${isVideo ? "hf-video" : "hf-audio"}-${generateId()}`;
|
||||
result = injectAttr(result, "id", id);
|
||||
}
|
||||
const startStr = getAttr(result, "data-start");
|
||||
const start = startStr !== null ? parseFloat(startStr) : 0;
|
||||
const mediaStartStr = getAttr(result, "data-media-start");
|
||||
@@ -114,17 +120,19 @@ function compileTag(
|
||||
*/
|
||||
export function compileTimingAttrs(html: string): CompilationResult {
|
||||
const unresolved: UnresolvedElement[] = [];
|
||||
let nextVideoId = 0;
|
||||
let nextAudioId = 0;
|
||||
|
||||
// Process <video ...> tags
|
||||
html = html.replace(/<video[^>]*>/gi, (match) => {
|
||||
const { tag, unresolved: u } = compileTag(match, true);
|
||||
const { tag, unresolved: u } = compileTag(match, true, () => nextVideoId++);
|
||||
if (u) unresolved.push(u);
|
||||
return tag;
|
||||
});
|
||||
|
||||
// Process <audio ...> tags
|
||||
html = html.replace(/<audio[^>]*>/gi, (match) => {
|
||||
const { tag, unresolved: u } = compileTag(match, false);
|
||||
const { tag, unresolved: u } = compileTag(match, false, () => nextAudioId++);
|
||||
if (u) unresolved.push(u);
|
||||
return tag;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user