Skip to content

Commit 7affa4a

Browse files
fix: handle player loop and render exit (#617)
## Problem Two newly reported runtime issues break common local workflows: - Fixes #615: `<hyperframes-player loop>` reaches the final frame, receives a paused runtime state, and stays paused instead of wrapping. - Fixes #616: `hyperframes render` can finish writing the output and print `Render complete`, but still remain alive when a non-essential handle keeps Node's event loop open. The catalog block also used old VPN branding and slug/file names that should now be neutral. Renaming registry items also exposed a catalog-preview CI bug where deleted registry paths were treated as still-renderable changed items. ## What this fixes - detects player completion from the previous playing state before mutating the parent `_paused` cache from the runtime's final state - wraps looping players back to `0` and immediately resumes playback even when the runtime posts `isPlaying: false` at the end frame - keeps non-looping players dispatching the existing `ended` flow - lets the CLI command path schedule a short unref'd `process.exit(0)` after a successful local or Docker render - keeps `renderLocal()` importable for tests and internal callers without forcing process exit unless the CLI command explicitly opts in - adds regression coverage for the player loop end-state and successful render exit scheduling - renames the VPN catalog block to `vpn-youtube-spot` across registry, docs route, install command, composition filename, asset filename, composition id, and timeline key - keeps visible block/app copy friendly and named `VPN` - updates catalog-preview CI to ignore deleted registry paths when computing changed preview items ## Root cause The player message handler updated `_paused = !data.isPlaying` before checking for end-of-composition loop behavior. The runtime's legitimate final-frame state has `isPlaying: false`, so the existing `currentTime >= duration && !paused` loop branch was skipped. For render completion, the CLI returned after `printRenderComplete()`, leaving process lifetime entirely to Node's active handles. Most local renders in this checkout drain cleanly, but the reported npm flow shows a sleeping parent process after output is already complete. The CLI now schedules a short unref'd successful exit only from the command path after user-visible render work has completed. The catalog block issue was content/metadata drift: registry/docs/code identifiers still used the old slug, so the catalog route, install command, composition id, file names, and source prompt did not match the requested neutral VPN naming. The preview workflow used plain `git diff --name-only`, which includes deleted paths during renames; it now filters to added/copied/modified/renamed live paths. ## Verification ### Local checks - `bun run build:hyperframes-runtime` - `bun run --filter @hyperframes/player test -- src/hyperframes-player.test.ts` - `bun run --filter @hyperframes/cli test -- src/commands/render.test.ts` - `bun run --filter @hyperframes/player typecheck` - `bun run --filter @hyperframes/cli typecheck` - `bunx oxfmt --check packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts` - `bunx oxlint packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts` - `bun run --filter @hyperframes/player build` - `bun run --filter @hyperframes/studio build` - `bun run --filter @hyperframes/cli build` - `bunx oxfmt --check registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json` - `bunx oxlint registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json` - `bunx oxfmt --check .github/workflows/catalog-previews.yml` - `BASE_SHA=26b8e2a9853eb1a8f77c05fb0c8f0903cdb2cf18; git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ ...` returns only `vpn-youtube-spot` - `npx tsx scripts/sync-schemas.ts --check` - `npx mint validate` from `docs/` - `npx mint broken-links` from `docs/` - `git diff --check` - Lefthook pre-commit: format pass - Lefthook commit-msg: commitlint pass ### Browser verification - Built the player bundle and served a real local reproduction using the built player, the built HyperFrames runtime, and GSAP. - Used `agent-browser` to open the page, click `Seek near end`, and wait through the end-frame transition. - Verified the browser state after playback: `stuck=false`, `looped=true`, and playback continued after wrapping from ~4s back to the start. - Served `registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html` locally, used `agent-browser` to seek the timeline, and verified `window.__timelines` contains `vpn-youtube-spot`, not `goonvpn-youtube-spot`. - Served the docs locally with Mintlify, opened `/catalog/blocks/vpn-youtube-spot`, and verified the install command is `npx hyperframes add vpn-youtube-spot` with no old slug visible. ### Composition verification - `bun run --filter @hyperframes/cli dev lint /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if` returned 0 errors and 1 existing large-composition warning. - `bun run --filter @hyperframes/cli dev validate /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --timeout 5000` returned 0 console errors; it reported existing non-fatal contrast audit warnings from the block styling. - `bun run --filter @hyperframes/cli dev render /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --output /tmp/hf-vpn-renamed-proof.mp4 --fps 30 --quality draft --workers 1 --no-browser-gpu` completed successfully. - `ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 /tmp/hf-vpn-renamed-proof.mp4` reported `duration=7.000000`. ### Render verification - Ran a real 1920x1080, 5-second render with `--gpu --workers 6 --quality draft --fps 24`. - Verified the command printed `Render complete` and the parent process exited with code `0` in the wrapper: `RENDER_EXIT_PROOF code=0 signal=null sawComplete=true`. ## Notes - I could not reproduce the exact indefinite #616 render hang on this checkout; both tiny and GPU/6-worker local renders exited cleanly before and after the patch. The CLI guard still addresses the reported leaked-handle failure mode because it fires only after successful render completion. - Browser proof artifacts were local-only: `/tmp/hf-player-loop-proof-final.png`, `/tmp/hf-player-loop-proof-final.webm`, `/tmp/hf-vpn-code-rename-proof.png`, `/tmp/hf-vpn-code-rename-proof.webm`, `/tmp/hf-vpn-doc-route-rename-proof.png`, and `/tmp/hf-vpn-doc-route-rename-proof.webm`. - The renamed composition render artifact was local-only: `/tmp/hf-vpn-renamed-proof.mp4`. - The CLI exit guard is only enabled by the `render` command's top-level local/Docker calls. Direct test/internal calls to `renderLocal()` do not force process exit unless they pass `exitAfterComplete: true`.
1 parent f1d408e commit 7affa4a

14 files changed

Lines changed: 265 additions & 123 deletions

File tree

.github/workflows/catalog-previews.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444
run: |
4545
# Find which blocks/components changed in this PR
4646
BASE_SHA=${{ github.event.pull_request.base.sha }}
47-
CHANGED_ITEMS=$(git diff --name-only "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ \
47+
CHANGED_ITEMS=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ \
4848
| grep -E '^registry/(blocks|components)/' \
4949
| sed 's|^registry/[^/]*/\([^/]*\)/.*|\1|' \
5050
| sort -u)

docs/catalog/blocks/goonvpn-youtube-spot.mdx

Lines changed: 0 additions & 53 deletions
This file was deleted.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
title: "VPN YouTube Spot"
3+
description: "Snappy Apple-style YouTube insert showing a phone finding and installing a friendly VPN app with sound effects."
4+
---
5+
6+
# VPN YouTube Spot
7+
8+
Snappy Apple-style YouTube insert showing a phone finding and installing a friendly VPN app with sound effects.
9+
10+
`app` `showcase` `youtube` `sfx`
11+
12+
Created by [Stronkter](https://x.com/Stronkter).
13+
14+
## Source Prompt
15+
16+
```text
17+
HyperFrames by HeyGen make me a 7s video with Apple-style bold font and styling: a phone scrolling in an app store, clicking on a friendly VPN app called VPN, installing it, then snapping down and fading to a white background. Make it snappy and polished for a YouTube insert, with sound effects, 60fps, and 1920x1080.
18+
```
19+
20+
<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vpn-youtube-spot.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vpn-youtube-spot.png" autoPlay muted loop playsInline />
21+
22+
## Install
23+
24+
<CodeGroup>
25+
26+
```bash Terminal
27+
npx hyperframes add vpn-youtube-spot
28+
```
29+
30+
</CodeGroup>
31+
32+
## Details
33+
34+
| Property | Value |
35+
| --- | --- |
36+
| Type | Block |
37+
| Dimensions | 1920×1080 |
38+
| Duration | 7s |
39+
40+
## Files
41+
42+
| File | Target | Type |
43+
| --- | --- | --- |
44+
| `vpn-youtube-spot.html` | `compositions/vpn-youtube-spot.html` | hyperframes:composition |
45+
| `assets/vpn-sfx.wav` | `assets/vpn-sfx.wav` | hyperframes:asset |
46+
47+
## Usage
48+
49+
After installing, add the block to your host composition:
50+
51+
```html
52+
<div data-composition-id="vpn-youtube-spot" data-composition-src="compositions/vpn-youtube-spot.html" data-start="0" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
53+
```

docs/docs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@
144144
"pages": [
145145
"catalog/blocks/app-showcase",
146146
"catalog/blocks/apple-money-count",
147-
"catalog/blocks/goonvpn-youtube-spot",
147+
"catalog/blocks/vpn-youtube-spot",
148148
"catalog/blocks/north-korea-locked-down",
149149
"catalog/blocks/nyc-paris-flight",
150150
"catalog/blocks/ui-3d-reveal"

docs/public/catalog-index.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,18 +126,18 @@
126126
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/glitch.png"
127127
},
128128
{
129-
"name": "goonvpn-youtube-spot",
129+
"name": "vpn-youtube-spot",
130130
"type": "block",
131-
"title": "GoonVPN YouTube Spot",
132-
"description": "Snappy Apple-style YouTube insert showing a phone finding and installing a fictional VPN app with sound effects.",
131+
"title": "VPN YouTube Spot",
132+
"description": "Snappy Apple-style YouTube insert showing a phone finding and installing a friendly VPN app with sound effects.",
133133
"tags": [
134134
"app",
135135
"showcase",
136136
"youtube",
137137
"sfx"
138138
],
139-
"href": "/catalog/blocks/goonvpn-youtube-spot",
140-
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/goonvpn-youtube-spot.png"
139+
"href": "/catalog/blocks/vpn-youtube-spot",
140+
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vpn-youtube-spot.png"
141141
},
142142
{
143143
"name": "grain-overlay",

packages/cli/src/commands/render.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ describe("renderLocal browser GPU config", () => {
4747
}
4848
}
4949
vi.clearAllMocks();
50+
vi.useRealTimers();
51+
vi.restoreAllMocks();
5052
});
5153

5254
it("passes an explicit software override for --no-browser-gpu even when env requests hardware", async () => {
@@ -130,6 +132,31 @@ describe("renderLocal browser GPU config", () => {
130132

131133
expect(producerState.createdJobs[0]?.variables).toBeUndefined();
132134
});
135+
136+
it("can force the CLI process to exit after a successful local render", async () => {
137+
vi.useFakeTimers();
138+
const exit = vi
139+
.spyOn(process, "exit")
140+
.mockImplementation((code?: string | number | null): never => {
141+
throw new Error(`process.exit:${code ?? ""}`);
142+
});
143+
const { renderLocal } = await import("./render.js");
144+
145+
await renderLocal("/tmp/project", "/tmp/out.mp4", {
146+
fps: 30,
147+
quality: "standard",
148+
format: "mp4",
149+
gpu: false,
150+
browserGpu: true,
151+
hdrMode: "auto",
152+
quiet: true,
153+
exitAfterComplete: true,
154+
});
155+
156+
expect(exit).not.toHaveBeenCalled();
157+
expect(() => vi.advanceTimersByTime(100)).toThrow("process.exit:0");
158+
expect(exit).toHaveBeenCalledWith(0);
159+
});
133160
});
134161

135162
describe("parseVariablesArg", () => {

packages/cli/src/commands/render.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ export default defineCommand({
403403
videoBitrate,
404404
quiet,
405405
variables,
406+
exitAfterComplete: true,
406407
});
407408
} else {
408409
await renderLocal(project.dir, outputPath, {
@@ -418,6 +419,7 @@ export default defineCommand({
418419
quiet,
419420
browserPath,
420421
variables,
422+
exitAfterComplete: true,
421423
});
422424
}
423425
},
@@ -436,6 +438,7 @@ interface RenderOptions {
436438
quiet: boolean;
437439
browserPath?: string;
438440
variables?: Record<string, unknown>;
441+
exitAfterComplete?: boolean;
439442
}
440443

441444
export type VariablesParseError =
@@ -748,6 +751,7 @@ async function renderDocker(
748751
});
749752

750753
printRenderComplete(outputPath, elapsed, options.quiet);
754+
if (options.exitAfterComplete) scheduleRenderProcessExit();
751755
}
752756

753757
export async function renderLocal(
@@ -796,6 +800,27 @@ export async function renderLocal(
796800
const elapsed = Date.now() - startTime;
797801
trackRenderMetrics(job, elapsed, options, false);
798802
printRenderComplete(outputPath, elapsed, options.quiet);
803+
if (options.exitAfterComplete) scheduleRenderProcessExit();
804+
}
805+
806+
type UnrefableTimer = {
807+
unref: () => void;
808+
};
809+
810+
function isUnrefableTimer(
811+
timer: ReturnType<typeof setTimeout>,
812+
): timer is ReturnType<typeof setTimeout> & UnrefableTimer {
813+
return (
814+
typeof timer === "object" &&
815+
timer !== null &&
816+
"unref" in timer &&
817+
typeof timer.unref === "function"
818+
);
819+
}
820+
821+
function scheduleRenderProcessExit(): void {
822+
const timer = setTimeout(() => process.exit(0), 100);
823+
if (isUnrefableTimer(timer)) timer.unref();
799824
}
800825

801826
function getMemorySnapshot() {

packages/player/src/hyperframes-player.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,89 @@ describe("HyperframesPlayer seek() sync path", () => {
798798
});
799799
});
800800

801+
describe("HyperframesPlayer loop end-state handling", () => {
802+
type PlayerInternal = HTMLElement & {
803+
iframe: HTMLIFrameElement;
804+
play: () => void;
805+
seek: (timeInSeconds: number) => void;
806+
loop: boolean;
807+
_duration: number;
808+
_paused: boolean;
809+
_onMessage: (event: MessageEvent) => void;
810+
};
811+
812+
let player: PlayerInternal;
813+
let frameWindow: Window;
814+
815+
beforeEach(async () => {
816+
await import("./hyperframes-player.js");
817+
player = document.createElement("hyperframes-player") as PlayerInternal;
818+
frameWindow = window;
819+
vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined);
820+
Object.defineProperty(player.iframe, "contentWindow", {
821+
configurable: true,
822+
get: () => frameWindow,
823+
});
824+
document.body.appendChild(player);
825+
});
826+
827+
afterEach(() => {
828+
player.remove();
829+
vi.restoreAllMocks();
830+
});
831+
832+
it("wraps and keeps playing when a looping composition posts its final paused state", () => {
833+
const seek = vi.spyOn(player, "seek");
834+
const play = vi.spyOn(player, "play");
835+
player.loop = true;
836+
player._duration = 4;
837+
player._paused = false;
838+
839+
player._onMessage(
840+
new MessageEvent("message", {
841+
source: frameWindow,
842+
data: {
843+
source: "hf-preview",
844+
type: "state",
845+
frame: 120,
846+
isPlaying: false,
847+
},
848+
}),
849+
);
850+
851+
expect(seek).toHaveBeenCalledWith(0);
852+
expect(play).toHaveBeenCalled();
853+
expect(player._paused).toBe(false);
854+
});
855+
856+
it("fires ended and stays paused when a non-looping composition posts its final paused state", () => {
857+
const seek = vi.spyOn(player, "seek");
858+
const play = vi.spyOn(player, "play");
859+
const ended = vi.fn();
860+
player.addEventListener("ended", ended);
861+
player.loop = false;
862+
player._duration = 4;
863+
player._paused = false;
864+
865+
player._onMessage(
866+
new MessageEvent("message", {
867+
source: frameWindow,
868+
data: {
869+
source: "hf-preview",
870+
type: "state",
871+
frame: 120,
872+
isPlaying: false,
873+
},
874+
}),
875+
);
876+
877+
expect(seek).not.toHaveBeenCalled();
878+
expect(play).not.toHaveBeenCalled();
879+
expect(ended).toHaveBeenCalledTimes(1);
880+
expect(player._paused).toBe(true);
881+
});
882+
});
883+
801884
describe("HyperframesPlayer srcdoc attribute", () => {
802885
type PlayerInternal = HTMLElement & {
803886
iframe: HTMLIFrameElement;

packages/player/src/hyperframes-player.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,19 @@ class HyperframesPlayer extends HTMLElement {
429429
if (data.type === "state") {
430430
this._currentTime = (data.frame ?? 0) / DEFAULT_FPS;
431431
const wasPlaying = !this._paused;
432-
this._paused = !data.isPlaying;
432+
const nextPaused = !data.isPlaying;
433+
const completedPlayback =
434+
this._duration > 0 && this._currentTime >= this._duration && (wasPlaying || data.isPlaying);
435+
436+
if (completedPlayback && this.loop) {
437+
if (this._audioOwner === "parent") this._pauseParentMedia();
438+
this._paused = nextPaused;
439+
this.seek(0);
440+
this.play();
441+
return;
442+
}
443+
444+
this._paused = nextPaused;
433445

434446
// Under parent ownership the proxies are the audible output, so they
435447
// mirror the iframe's play/pause transitions (externally-driven pause
@@ -456,16 +468,11 @@ class HyperframesPlayer extends HTMLElement {
456468
);
457469
}
458470

459-
if (this._currentTime >= this._duration && !this._paused) {
471+
if (completedPlayback) {
460472
if (this._audioOwner === "parent") this._pauseParentMedia();
461-
if (this.loop) {
462-
this.seek(0);
463-
this.play();
464-
} else {
465-
this._paused = true;
466-
this.controlsApi?.updatePlaying(false);
467-
this.dispatchEvent(new Event("ended"));
468-
}
473+
this._paused = true;
474+
this.controlsApi?.updatePlaying(false);
475+
this.dispatchEvent(new Event("ended"));
469476
}
470477
}
471478

0 commit comments

Comments
 (0)