mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(studio,runtime,engine,compiler): 8 bug fixes — audio, render, timeline, Lottie, thumbnails, video render (#133)
## Summary
**Original 5 bugs fixed:**
- **Bug 1 — Audio silent after seek**: Added `Accept-Ranges` / `Content-Length` + `206 Partial Content` to the static asset server for byte-range seeking.
- **Bug 2 — Download 404 after restart**: Render list endpoint now registers on-disk renders into the in-memory job map.
- **Bug 3 — Timeline stops at GSAP end**: `resolveRootTimelineFromDocument` pads the GSAP timeline to match `data-duration` when the composition declares longer.
- **Bug 4 — Render stuck at 0%**: Store `jobState` reference (not spread copy) so async progress mutations reach the SSE stream.
- **Bug 5 — Lottie missing in preview/render**: Two fixes — (a) moved Lottie adapter before GSAP so `onUpdate` wins; (b) fixed bundler silently dropping external CDN `\<script src>` tags from sub-compositions (root cause: `$content(s).html()` returns `""` for external scripts).
**3 additional bugs fixed:**
- **Bug 6 — Blank thumbnails outside monorepo**: Implemented `generateThumbnail` in the CLI adapter using Puppeteer.
- **Bug 7 — Video empty in rendered sub-compositions**: Fixed `parseVideoElements` selector from `video[id][src]` to `video[src][data-start]` + auto-assign IDs.
- **Render errors**: Failed renders now show their error message in the renders panel.
## Commits
| Commit | Description |
| --- | --- |
| `3951c6f` | fix(studio): store render job reference instead of snapshot copy |
| `f331c30` | fix(studio): make previously-completed renders downloadable after restart |
| `a5e2d04` | fix(studio): add range request support for audio/video seeking in preview |
| `f24317a` | fix(runtime): pad GSAP timeline to data-duration when composition declares longer duration |
| `7cf38ca` | fix(runtime): fix Lottie adapter conflicting with GSAP-driven animations |
| `bc99209` | fix(studio): surface render error messages in the renders panel |
| `8fc9e8b` | fix(cli): implement generateThumbnail in studio adapter |
| `90277ea` | fix(engine): render videos inside sub-compositions that lack an explicit id |
| `f5bb579` | fix(compiler): preserve external CDN scripts from sub-compositions in bundle |
## Test plan
- [x] `golden-lyric-video`: seek → audio plays from seeked position
- [x] Any project: render → progress advances past 0%, reaches 100%
- [x] Any project: complete render, restart `hyperframes dev`, Download → works
- [x] `intro-vid`: play → runs full 5s (not stopping at 3s)
- [x] `hyperframe-build-up-demo`: play → rocket Lottie visible during 0-2s ✅ verified
- [x] Outside monorepo: Compositions sidebar shows thumbnail images (not blank)
- [x] `bug.zip` project: render → video in polaroid sub-composition appears in output
- [x] Trigger a failed render → error message shown
This commit is contained in:
@@ -44,6 +44,50 @@ function resolveRuntimePath(): string {
|
||||
return builtPath;
|
||||
}
|
||||
|
||||
// ── Shared thumbnail browser (singleton per process) ────────────────────────
|
||||
// One browser instance is reused across all composition thumbnail requests.
|
||||
// Spawning a new Puppeteer process per request adds 2-5s overhead and causes
|
||||
// contention when the sidebar requests multiple thumbnails simultaneously.
|
||||
|
||||
let _thumbnailBrowser: import("puppeteer-core").Browser | null = null;
|
||||
let _thumbnailBrowserInitializing: Promise<import("puppeteer-core").Browser | null> | null = null;
|
||||
|
||||
async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser | null> {
|
||||
if (_thumbnailBrowser?.connected) return _thumbnailBrowser;
|
||||
if (_thumbnailBrowserInitializing) return _thumbnailBrowserInitializing;
|
||||
|
||||
_thumbnailBrowserInitializing = (async () => {
|
||||
try {
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
const { acquireBrowser, buildChromeArgs } = await import("@hyperframes/engine");
|
||||
|
||||
try {
|
||||
const b = await ensureBrowser();
|
||||
if (b.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
||||
process.env.PRODUCER_HEADLESS_SHELL_PATH = b.executablePath;
|
||||
}
|
||||
} catch {
|
||||
/* continue — acquireBrowser will try its own resolution */
|
||||
}
|
||||
|
||||
const acquired = await acquireBrowser(buildChromeArgs({ width: 1920, height: 1080 }), {
|
||||
enableBrowserPool: false,
|
||||
});
|
||||
_thumbnailBrowser = acquired.browser;
|
||||
_thumbnailBrowser.on("disconnected", () => {
|
||||
_thumbnailBrowser = null;
|
||||
_thumbnailBrowserInitializing = null;
|
||||
});
|
||||
return _thumbnailBrowser;
|
||||
} catch {
|
||||
_thumbnailBrowserInitializing = null;
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return _thumbnailBrowserInitializing;
|
||||
}
|
||||
|
||||
// ── Server factory ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface StudioServerOptions {
|
||||
@@ -152,6 +196,44 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
|
||||
return state;
|
||||
},
|
||||
|
||||
async generateThumbnail(opts): Promise<Buffer | null> {
|
||||
// Reuse a single browser across all thumbnail requests for this server
|
||||
// instance — avoids paying the ~2s Puppeteer startup cost per composition.
|
||||
// The browser is created lazily and kept alive until the process exits.
|
||||
const browser = await getThumbnailBrowser();
|
||||
if (!browser) return null;
|
||||
let page: import("puppeteer-core").Page | null = null;
|
||||
try {
|
||||
page = await browser.newPage();
|
||||
await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 });
|
||||
// domcontentloaded instead of networkidle2 — CDN scripts (GSAP, Lottie,
|
||||
// fonts) never reach "idle" and cause a 15s timeout per thumbnail.
|
||||
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
// Wait for the runtime to register timelines (up to 5s, non-fatal).
|
||||
await page
|
||||
.waitForFunction(() => !!(window as any).__timelines || !!(window as any).__playerReady, {
|
||||
timeout: 5000,
|
||||
})
|
||||
.catch(() => {});
|
||||
await page.evaluate((t: number) => {
|
||||
const win = window as any;
|
||||
if (win.__player?.seek) win.__player.seek(t);
|
||||
else if (win.__timeline?.seek) {
|
||||
win.__timeline.pause();
|
||||
win.__timeline.seek(t);
|
||||
}
|
||||
}, opts.seekTime);
|
||||
// Let the seek render settle.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const screenshot = (await page.screenshot({ type: "jpeg", quality: 80 })) as Buffer;
|
||||
return screenshot;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await page?.close().catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── Build the Hono app ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user