mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +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:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"timeout": 180,
|
||||
"statusMessage": "Running build + lint + typecheck before commit…",
|
||||
"command": "node -e \"\nconst chunks = [];\nprocess.stdin.on('data', d => chunks.push(d));\nprocess.stdin.on('end', () => {\n const input = JSON.parse(Buffer.concat(chunks).toString());\n const cmd = input.tool_input?.command || '';\n if (!/git\\\\s+commit\\\\b/.test(cmd)) process.exit(0);\n const { execSync } = require('child_process');\n const cwd = process.env.PWD || process.cwd();\n const steps = [\n ['pnpm build', 'Build'],\n ['pnpm run -w lint', 'Lint'],\n ['bun run --filter \\'*\\' typecheck 2>&1 | grep -v \\'vitest\\\\|test\\\\.ts\\' || true', 'Typecheck'],\n ];\n const failures = [];\n for (const [script, label] of steps) {\n try { execSync(script, { cwd, stdio: 'pipe' }); }\n catch (e) {\n failures.push(label + ':\\\\n' + (e.stdout?.toString() || e.message).slice(0, 400));\n }\n }\n if (failures.length > 0) {\n process.stdout.write(JSON.stringify({\n continue: false,\n stopReason: '\\u274c Pre-commit checks failed:\\\\n\\\\n' + failures.join('\\\\n\\\\n') + '\\\\n\\\\nFix the issues above before committing.',\n }));\n }\n});\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { bundleToSingleHtml } from "./htmlBundler";
|
||||
|
||||
function makeTempProject(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-"));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = join(dir, rel);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, content, "utf-8");
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("bundleToSingleHtml", () => {
|
||||
it("hoists external CDN scripts from sub-compositions into the bundle", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="rockets-host"
|
||||
data-composition-id="rockets"
|
||||
data-composition-src="compositions/rockets.html"
|
||||
data-start="0" data-duration="2"></div>
|
||||
</div>
|
||||
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
|
||||
</body></html>`,
|
||||
"compositions/rockets.html": `<template id="rockets-template">
|
||||
<div data-composition-id="rockets" data-width="1920" data-height="1080">
|
||||
<div id="rocket-container"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const anim = lottie.loadAnimation({ container: document.querySelector("#rocket-container"), path: "rocket.json" });
|
||||
window.__timelines["rockets"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
// Lottie CDN script from sub-composition must be present in the bundle
|
||||
expect(bundled).toContain(
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js",
|
||||
);
|
||||
|
||||
// Should only appear once (deduped)
|
||||
const occurrences = (bundled.match(/cdnjs\.cloudflare\.com\/ajax\/libs\/lottie-web/g) ?? [])
|
||||
.length;
|
||||
expect(occurrences).toBe(1);
|
||||
|
||||
// GSAP CDN from main doc should still be present
|
||||
expect(bundled).toContain("cdn.jsdelivr.net/npm/gsap");
|
||||
|
||||
// data-composition-src should be stripped (composition was inlined)
|
||||
expect(bundled).not.toContain("data-composition-src");
|
||||
});
|
||||
|
||||
it("does not duplicate CDN scripts already present in the main document", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="child-host"
|
||||
data-composition-id="child"
|
||||
data-composition-src="compositions/child.html"
|
||||
data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
|
||||
</body></html>`,
|
||||
"compositions/child.html": `<template id="child-template">
|
||||
<div data-composition-id="child" data-width="1920" data-height="1080">
|
||||
<div id="stage"></div>
|
||||
<!-- Same GSAP CDN as parent — should not be duplicated -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["child"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
// GSAP CDN should appear exactly once (deduped)
|
||||
const gsapOccurrences = (
|
||||
bundled.match(/cdn\.jsdelivr\.net\/npm\/gsap@3\.14\.2\/dist\/gsap\.min\.js/g) ?? []
|
||||
).length;
|
||||
expect(gsapOccurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -390,6 +390,7 @@ export async function bundleToSingleHtml(
|
||||
// Inline sub-compositions
|
||||
const compStyleChunks: string[] = [];
|
||||
const compScriptChunks: string[] = [];
|
||||
const compExternalScriptSrcs: string[] = [];
|
||||
$("[data-composition-src]").each((_, hostEl) => {
|
||||
const src = $(hostEl).attr("data-composition-src");
|
||||
if (!src || !isRelativeUrl(src)) return;
|
||||
@@ -416,9 +417,18 @@ export async function bundleToSingleHtml(
|
||||
$content(s).remove();
|
||||
});
|
||||
$content("script").each((_, s) => {
|
||||
compScriptChunks.push(
|
||||
`(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
);
|
||||
const externalSrc = ($content(s).attr("src") || "").trim();
|
||||
if (externalSrc) {
|
||||
// External CDN/remote script — collect for deduped injection into the document.
|
||||
// Do NOT try to inline the content (external scripts have no innerHTML).
|
||||
if (!compExternalScriptSrcs.includes(externalSrc)) {
|
||||
compExternalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
} else {
|
||||
compScriptChunks.push(
|
||||
`(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
);
|
||||
}
|
||||
$content(s).remove();
|
||||
});
|
||||
|
||||
@@ -439,6 +449,14 @@ export async function bundleToSingleHtml(
|
||||
$(hostEl).removeAttr("data-composition-src");
|
||||
});
|
||||
|
||||
// Inject external scripts from sub-compositions (e.g., Lottie CDN)
|
||||
// that aren't already present in the main document.
|
||||
for (const extSrc of compExternalScriptSrcs) {
|
||||
if (!$(`script[src="${extSrc}"]`).length) {
|
||||
$("body").append(`<script src="${extSrc}"></script>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (compStyleChunks.length) $("head").append(`<style>${compStyleChunks.join("\n\n")}</style>`);
|
||||
if (compScriptChunks.length)
|
||||
$("body").append(`<script>${compScriptChunks.join("\n;\n")}</script>`);
|
||||
|
||||
@@ -111,6 +111,42 @@ describe("lintHyperframeHtml", () => {
|
||||
expect(codes.length).toBe(uniqueCodes.length);
|
||||
});
|
||||
|
||||
it("reports info for composition with external CDN script dependency", () => {
|
||||
const html = `<template id="rockets-template">
|
||||
<div data-composition-id="rockets" data-width="1920" data-height="1080">
|
||||
<div id="rocket-container"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["rockets"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
|
||||
const finding = result.findings.find((f) => f.code === "external_script_dependency");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("info");
|
||||
expect(finding?.message).toContain("cdnjs.cloudflare.com");
|
||||
// info findings do not count as errors — ok should still be true
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not report external_script_dependency for inline scripts", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "external_script_dependency")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips <template> wrapper before linting composition files", () => {
|
||||
const html = `<template id="my-comp-template">
|
||||
<div data-composition-id="my-comp" data-width="1920" data-height="1080"
|
||||
|
||||
@@ -675,6 +675,32 @@ export function lintHyperframeHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// ── External CDN script dependency check ────────────────────────────────
|
||||
// Compositions that load CDN libraries via <script src="https://..."> work
|
||||
// correctly in bundled mode (bundleToSingleHtml auto-hoists them to the parent
|
||||
// document) and in runtime mode (loadExternalCompositions re-injects them).
|
||||
// But when a composition is used in a custom pipeline that bypasses both, the
|
||||
// scripts won't be available. Flag this as an info-level finding so developers
|
||||
// know the dependency exists.
|
||||
{
|
||||
const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
const seen = new Set<string>();
|
||||
while ((match = externalScriptRe.exec(source)) !== null) {
|
||||
const src = match[1] ?? "";
|
||||
if (seen.has(src)) continue;
|
||||
seen.add(src);
|
||||
pushFinding({
|
||||
code: "external_script_dependency",
|
||||
severity: "info",
|
||||
message: `This composition loads an external script from \`${src}\`. The HyperFrames bundler automatically hoists CDN scripts from sub-compositions into the parent document. In unbundled runtime mode, \`loadExternalCompositions\` re-injects them. If you're using a custom pipeline that bypasses both, you'll need to include this script manually.`,
|
||||
fixHint:
|
||||
"No action needed when using `hyperframes dev` or `hyperframes render`. If using a custom pipeline, add this script tag to your root composition or HTML page.",
|
||||
snippet: truncateSnippet(match[0] ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
||||
const warningCount = findings.length - errorCount;
|
||||
|
||||
|
||||
@@ -141,16 +141,6 @@ describe("lottie adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("play", () => {
|
||||
it("plays lottie-web animation", () => {
|
||||
const anim = createLottieWebAnim();
|
||||
lottieWindow.__hfLottie = [anim];
|
||||
const adapter = createLottieAdapter();
|
||||
adapter.play!();
|
||||
expect(anim.play).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("revert", () => {
|
||||
it("does not throw", () => {
|
||||
const adapter = createLottieAdapter();
|
||||
|
||||
@@ -129,23 +129,6 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter {
|
||||
}
|
||||
},
|
||||
|
||||
play: () => {
|
||||
const instances = (window as LottieWindow).__hfLottie;
|
||||
if (!instances || instances.length === 0) return;
|
||||
|
||||
for (const anim of instances) {
|
||||
try {
|
||||
if (isLottieWebAnimation(anim)) {
|
||||
anim.play();
|
||||
} else if (isDotLottiePlayer(anim)) {
|
||||
anim.play();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
revert: () => {
|
||||
// Don't clear __hfLottie — the animation objects are owned by the composition.
|
||||
// Just let them be garbage collected naturally.
|
||||
|
||||
@@ -32,6 +32,21 @@ export function initSandboxRuntimeModular(): void {
|
||||
// keep runtime resilient across reinits
|
||||
}
|
||||
}
|
||||
// Normalize html/body so browser defaults (8px margin, white background) never
|
||||
// bleed into renders as white bars. Runs in both preview and render contexts,
|
||||
// eliminating the preview/render parity gap that existed when only the React
|
||||
// component's normalizePreviewViewport call applied this normalization.
|
||||
if (document.documentElement) {
|
||||
document.documentElement.style.margin = "0";
|
||||
document.documentElement.style.padding = "0";
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
}
|
||||
if (document.body) {
|
||||
document.body.style.margin = "0";
|
||||
document.body.style.padding = "0";
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
const registerRuntimeCleanup = (callback: () => void) => {
|
||||
runtimeCleanupCallbacks.push(callback);
|
||||
@@ -707,6 +722,54 @@ export function initSandboxRuntimeModular(): void {
|
||||
};
|
||||
}
|
||||
}
|
||||
// If the root composition declares an explicit data-duration that meaningfully
|
||||
// exceeds the captured GSAP timeline, extend the timeline in-place by placing
|
||||
// a zero-duration no-op tween at the declared end position. This makes
|
||||
// timeline.duration() report the declared length without creating a composite
|
||||
// (which would double-count the original duration).
|
||||
const rootDeclaredDurAttr = rootCompositionNode?.getAttribute("data-duration");
|
||||
if (rootDeclaredDurAttr) {
|
||||
const rootDeclaredDur = parseFloat(rootDeclaredDurAttr);
|
||||
if (
|
||||
isUsableTimelineDuration(rootDeclaredDur) &&
|
||||
isUsableTimelineDuration(rootDurationSeconds) &&
|
||||
// Only pad when the gap is meaningful (>= 0.5s) to avoid floating-point
|
||||
// false positives on compositions whose GSAP duration is already close
|
||||
// to data-duration.
|
||||
rootDeclaredDur >= rootDurationSeconds + 0.5
|
||||
) {
|
||||
const tlWithTo = rootTimeline as RuntimeTimelineLike & {
|
||||
to?: (target: object, vars: { duration: number }, position: number) => unknown;
|
||||
};
|
||||
if (typeof tlWithTo.to === "function") {
|
||||
try {
|
||||
// Placing a zero-duration tween AT rootDeclaredDur extends
|
||||
// timeline.duration() to exactly rootDeclaredDur.
|
||||
tlWithTo.to({}, { duration: 0 }, rootDeclaredDur);
|
||||
} catch {
|
||||
// keep runtime resilient
|
||||
}
|
||||
}
|
||||
const newDur = getTimelineDurationSeconds(rootTimeline);
|
||||
if (isUsableTimelineDuration(newDur)) {
|
||||
return {
|
||||
timeline: rootTimeline,
|
||||
selectedTimelineIds: [rootCompositionId],
|
||||
selectedDurationSeconds: newDur,
|
||||
mediaDurationFloorSeconds,
|
||||
diagnostics: {
|
||||
code: "root_timeline_padded_to_declared_duration",
|
||||
details: {
|
||||
rootCompositionId,
|
||||
rootDurationSeconds,
|
||||
rootDeclaredDur,
|
||||
newDur,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
timeline: rootTimeline,
|
||||
selectedTimelineIds: [rootCompositionId],
|
||||
@@ -1357,9 +1420,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
|
||||
}),
|
||||
createWaapiAdapter(),
|
||||
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
|
||||
createThreeAdapter(),
|
||||
createLottieAdapter(),
|
||||
createThreeAdapter(),
|
||||
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
|
||||
] as RuntimeDeterministicAdapter[];
|
||||
installRuntimeErrorDiagnostics();
|
||||
runAdapters("discover");
|
||||
|
||||
@@ -66,7 +66,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
// Static asset serving
|
||||
// Static asset serving (with range request support for audio/video seeking)
|
||||
api.get("/projects/:id/preview/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
@@ -79,9 +79,38 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
}
|
||||
const contentType = getMimeType(subPath);
|
||||
const isText = /\.(html|css|js|json|svg|txt|md)$/i.test(subPath);
|
||||
const content = readFileSync(file, isText ? "utf-8" : undefined);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": contentType },
|
||||
const buffer: Buffer = isText
|
||||
? Buffer.from(readFileSync(file, "utf-8"), "utf-8")
|
||||
: readFileSync(file);
|
||||
const totalSize = buffer.length;
|
||||
|
||||
// Support byte-range requests so browsers can seek audio/video elements.
|
||||
const rangeHeader = c.req.header("Range");
|
||||
if (rangeHeader) {
|
||||
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
|
||||
if (match) {
|
||||
const start = parseInt(match[1]!, 10);
|
||||
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
|
||||
const safeEnd = Math.min(end, totalSize - 1);
|
||||
const chunkSize = safeEnd - start + 1;
|
||||
return new Response(new Uint8Array(buffer.slice(start, safeEnd + 1)), {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(chunkSize),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(totalSize),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,7 +73,8 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
quality,
|
||||
jobId,
|
||||
});
|
||||
renderJobs.set(jobId, { ...jobState, createdAt: Date.now() });
|
||||
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
|
||||
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
|
||||
|
||||
// Restart cleanup timer if needed
|
||||
if (!cleanupTimer && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
@@ -125,6 +126,27 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
});
|
||||
});
|
||||
|
||||
// Serve render inline (for in-browser playback — opens in a new tab)
|
||||
api.get("/render/:jobId/view", (c) => {
|
||||
const { jobId } = c.req.param();
|
||||
const job = renderJobs.get(jobId);
|
||||
if (!job?.outputPath || !existsSync(job.outputPath)) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
const isWebm = job.outputPath.endsWith(".webm");
|
||||
const contentType = isWebm ? "video/webm" : "video/mp4";
|
||||
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
|
||||
const content = readFileSync(job.outputPath);
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `inline; filename="${filename}"`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(content.length),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Download render
|
||||
api.get("/render/:jobId/download", (c) => {
|
||||
const { jobId } = c.req.param();
|
||||
@@ -195,6 +217,19 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
// Register on-disk renders that aren't in the current session's job map
|
||||
// so they remain downloadable after a server restart.
|
||||
for (const file of files) {
|
||||
if (!renderJobs.has(file.id)) {
|
||||
renderJobs.set(file.id, {
|
||||
id: file.id,
|
||||
status: file.status,
|
||||
progress: 100,
|
||||
outputPath: join(rendersDir, file.filename),
|
||||
createdAt: file.createdAt,
|
||||
} as RenderJobState & { createdAt: number });
|
||||
}
|
||||
}
|
||||
return c.json({ renders: files });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,17 @@ export function parseVideoElements(html: string): VideoElement[] {
|
||||
const videos: VideoElement[] = [];
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
const videoEls = document.querySelectorAll("video[id][src]");
|
||||
// Union: original "video[id][src]" (backward compat) + "video[src][data-start]"
|
||||
// (sub-composition videos that have timing but no explicit id).
|
||||
const videoEls = Array.from(
|
||||
new Set([
|
||||
...Array.from(document.querySelectorAll("video[id][src]")),
|
||||
...Array.from(document.querySelectorAll("video[src][data-start]")),
|
||||
]),
|
||||
);
|
||||
videoEls.forEach((el, i) => {
|
||||
if (!el.id) el.id = `hf-video-${i}`;
|
||||
});
|
||||
for (const el of videoEls) {
|
||||
const id = el.getAttribute("id");
|
||||
const src = el.getAttribute("src");
|
||||
@@ -60,14 +70,27 @@ export function parseVideoElements(html: string): VideoElement[] {
|
||||
|
||||
const startAttr = el.getAttribute("data-start");
|
||||
const endAttr = el.getAttribute("data-end");
|
||||
const durationAttr = el.getAttribute("data-duration");
|
||||
const mediaStartAttr = el.getAttribute("data-media-start");
|
||||
const hasAudioAttr = el.getAttribute("data-has-audio");
|
||||
|
||||
const start = startAttr ? parseFloat(startAttr) : 0;
|
||||
// Derive end from data-end → data-start+data-duration → Infinity (natural duration).
|
||||
// The caller (htmlCompiler) clamps Infinity to the composition's absoluteEnd.
|
||||
let end = 0;
|
||||
if (endAttr) {
|
||||
end = parseFloat(endAttr);
|
||||
} else if (durationAttr) {
|
||||
end = start + parseFloat(durationAttr);
|
||||
} else {
|
||||
end = Infinity; // no explicit bounds — play for the full natural video duration
|
||||
}
|
||||
|
||||
videos.push({
|
||||
id,
|
||||
src,
|
||||
start: startAttr ? parseFloat(startAttr) : 0,
|
||||
end: endAttr ? parseFloat(endAttr) : 0,
|
||||
start,
|
||||
end,
|
||||
mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
|
||||
hasAudio: hasAudioAttr === "true",
|
||||
});
|
||||
@@ -212,8 +235,9 @@ export async function extractAllVideoFrames(
|
||||
|
||||
let videoDuration = video.end - video.start;
|
||||
|
||||
// Fallback: if no data-duration/data-end was specified, probe the actual file
|
||||
if (videoDuration <= 0) {
|
||||
// Fallback: if no data-duration/data-end was specified (end is Infinity or 0),
|
||||
// probe the actual video file to get its natural duration.
|
||||
if (!Number.isFinite(videoDuration) || videoDuration <= 0) {
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
const sourceDuration = metadata.durationSeconds - video.mediaStart;
|
||||
videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
|
||||
|
||||
@@ -146,6 +146,12 @@ async function compileHtmlFile(
|
||||
compiledHtml = clampDurations(compiledHtml, clampList);
|
||||
}
|
||||
|
||||
// Strip crossorigin from video elements: the render pipeline replaces them with
|
||||
// injected frame images, so the browser never needs to load the source.
|
||||
// Without this, videos with crossorigin="anonymous" targeting CORS-restricted
|
||||
// origins (e.g. S3 without CORS headers) keep readyState=0, blocking page setup.
|
||||
compiledHtml = compiledHtml.replace(/(<video\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");
|
||||
|
||||
return { html: compiledHtml, unresolvedCompositions };
|
||||
}
|
||||
|
||||
@@ -728,6 +734,28 @@ export async function compileForRender(
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Persist auto-assigned IDs back into the HTML so the compiled file served
|
||||
// to Puppeteer has matching element IDs. parseVideoElements uses parseHTML
|
||||
// internally and sets el.id = "hf-video-N" on the JSDOM node, but that does
|
||||
// not mutate the html string. We do one more DOM pass here to write those IDs
|
||||
// into the document and re-serialize — only if there are any id-less videos.
|
||||
const autoIdVideos = videos.filter((v) => v.id.startsWith("hf-video-"));
|
||||
let htmlWithIds = html;
|
||||
if (autoIdVideos.length > 0) {
|
||||
const { document: idDoc } = parseHTML(html);
|
||||
let changed = false;
|
||||
for (const v of autoIdVideos) {
|
||||
const el = idDoc.querySelector(`video[src="${v.src}"]:not([id])`);
|
||||
if (el) {
|
||||
el.id = v.id;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
htmlWithIds = idDoc.documentElement?.outerHTML ?? html;
|
||||
}
|
||||
}
|
||||
|
||||
// Read dimensions from root composition element using DOM parser
|
||||
const { document } = parseHTML(html);
|
||||
const rootEl = document.querySelector("[data-composition-id]");
|
||||
@@ -745,7 +773,7 @@ export async function compileForRender(
|
||||
: 0;
|
||||
|
||||
return {
|
||||
html,
|
||||
html: htmlWithIds,
|
||||
subCompositions,
|
||||
videos,
|
||||
audios,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:73b0bf5379865cec6a606c9d4d79cc7604ad4051e72d68499c60e4cc40c53afe
|
||||
size 115820
|
||||
oid sha256:7a2e53926e44d66a469e74eee88035987e309786eb44fef17de9f34114de0813
|
||||
size 105638
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Sub-composition video in photo area",
|
||||
"description": "Tests that video elements inside sub-compositions (lacking explicit id attributes) render correctly. Regression for: parseVideoElements selector only matched video[id][src], silently dropping id-less videos; end derived from data-end only (not data-duration); compiled HTML did not persist auto-assigned ids.",
|
||||
"tags": ["video", "sub-composition", "regression"],
|
||||
"minPsnr": 25,
|
||||
"maxFrameFailures": 5,
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c08dcb89ed447e5bbff18214576ea7fff9ed6b5129cf12795e97199dff3d8e21
|
||||
size 3830455
|
||||
@@ -0,0 +1,211 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<template id="polaroid-template">
|
||||
<div data-composition-id="polaroid" data-width="1080" data-height="1920" data-duration="13">
|
||||
<!-- Main Container for Floating Animation -->
|
||||
<div class="polaroid-wrapper">
|
||||
<!-- The Polaroid Frame -->
|
||||
<div class="polaroid-frame">
|
||||
<!-- Photo Area -->
|
||||
<div class="photo-area">
|
||||
<video
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/uploaded_assets/fb7e48ac_f6bf2ab079394d7ebd0491e7008a9242.mp4"
|
||||
data-start="0"
|
||||
data-track-index="0"
|
||||
crossorigin="anonymous"
|
||||
></video>
|
||||
</div>
|
||||
<!-- Bottom Margin for Captions -->
|
||||
<div class="caption-area">
|
||||
<div id="caption-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;700&display=swap');
|
||||
|
||||
[data-composition-id="polaroid"] {
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .polaroid-wrapper {
|
||||
width: 800px;
|
||||
height: 1000px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0; /* Start hidden for fade-in */
|
||||
transform: translateY(100px); /* Start lower for slide-up */
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .polaroid-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fdfdfd;
|
||||
padding: 40px 40px 160px 40px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
|
||||
transform: rotate(2.5deg); /* Slight rotation for physical feel */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .photo-area {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
background: #222;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .photo-area video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .caption-area {
|
||||
height: 120px;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] #caption-container {
|
||||
font-family: 'Caveat', cursive;
|
||||
font-size: 54px;
|
||||
color: #1a2a4a; /* Dark blue ink style */
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .caption-word {
|
||||
display: inline-block;
|
||||
margin: 0 6px;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const TRANSCRIPT = [{'text': 'Also', 'start': 0.099, 'end': 0.299}, {'text': 'ich', 'start': 0.379, 'end': 0.479}, {'text': 'hab', 'start': 0.519, 'end': 0.659}, {'text': 'dieses', 'start': 0.699, 'end': 0.979}, {'text': 'AI-Tool', 'start': 1.12, 'end': 1.699}, {'text': 'letzte', 'start': 1.74, 'end': 1.979}, {'text': 'Woche', 'start': 2.059, 'end': 2.259}, {'text': 'gefunden', 'start': 2.299, 'end': 2.74}, {'text': 'und', 'start': 2.799, 'end': 2.98}, {'text': 'ehrlich?', 'start': 3.039, 'end': 3.539}, {'text': 'Es', 'start': 4.179, 'end': 4.279}, {'text': 'ist', 'start': 4.339, 'end': 4.519}, {'text': 'krass.', 'start': 4.559, 'end': 4.979}, {'text': 'Früher', 'start': 5.5, 'end': 5.819}, {'text': 'hab', 'start': 5.859, 'end': 5.96}, {'text': 'ich', 'start': 6.019, 'end': 6.119}, {'text': 'stundenlang', 'start': 6.179, 'end': 6.799}, {'text': 'Videos', 'start': 6.899, 'end': 7.259}, {'text': 'bearbeitet,', 'start': 7.339, 'end': 8.039}, {'text': 'jetzt', 'start': 8.399, 'end': 8.599}, {'text': 'dauert', 'start': 8.639, 'end': 8.88}, {'text': 'es', 'start': 8.92, 'end': 9.0}, {'text': 'fünf', 'start': 9.079, 'end': 9.279}, {'text': 'Minuten.', 'start': 9.359, 'end': 9.819}, {'text': 'Wenn', 'start': 10.359, 'end': 10.46}, {'text': 'du', 'start': 10.539, 'end': 10.619}, {'text': 'das', 'start': 10.659, 'end': 10.8}, {'text': 'noch', 'start': 10.84, 'end': 10.96}, {'text': 'nicht', 'start': 11.019, 'end': 11.159}, {'text': 'nutzt,', 'start': 11.239, 'end': 11.519}, {'text': 'bist', 'start': 11.599, 'end': 11.759}, {'text': 'du', 'start': 11.8, 'end': 11.979}, {'text': 'echt', 'start': 12.059, 'end': 12.259}, {'text': 'verrückt.', 'start': 12.3, 'end': 12.779}];
|
||||
const DURATION = 13;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// 1. Entrance Animation
|
||||
tl.to('.polaroid-wrapper', {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 1.2,
|
||||
ease: 'power3.out'
|
||||
}, 0);
|
||||
|
||||
// 2. Floating/Breathing Animation (Deterministic)
|
||||
const floatTl = gsap.timeline();
|
||||
const steps = 4;
|
||||
const stepDuration = DURATION / steps;
|
||||
|
||||
floatTl.to('.polaroid-frame', {
|
||||
y: -15,
|
||||
rotation: 3.5,
|
||||
scale: 1.02,
|
||||
duration: stepDuration,
|
||||
ease: 'sine.inOut'
|
||||
})
|
||||
.to('.polaroid-frame', {
|
||||
y: 0,
|
||||
rotation: 2.5,
|
||||
scale: 1,
|
||||
duration: stepDuration,
|
||||
ease: 'sine.inOut'
|
||||
})
|
||||
.to('.polaroid-frame', {
|
||||
y: 15,
|
||||
rotation: 1.5,
|
||||
scale: 0.98,
|
||||
duration: stepDuration,
|
||||
ease: 'sine.inOut'
|
||||
})
|
||||
.to('.polaroid-frame', {
|
||||
y: 0,
|
||||
rotation: 2.5,
|
||||
scale: 1,
|
||||
duration: stepDuration,
|
||||
ease: 'sine.inOut'
|
||||
});
|
||||
|
||||
tl.add(floatTl, 0);
|
||||
|
||||
// 3. Caption Logic
|
||||
const container = document.getElementById('caption-container');
|
||||
|
||||
let currentGroup = [];
|
||||
const groups = [];
|
||||
|
||||
TRANSCRIPT.forEach((word, i) => {
|
||||
currentGroup.push(word);
|
||||
const nextWord = TRANSCRIPT[i+1];
|
||||
const isGap = nextWord && (nextWord.start - word.end > 0.8);
|
||||
if (currentGroup.length >= 5 || isGap || !nextWord) {
|
||||
groups.push([...currentGroup]);
|
||||
currentGroup = [];
|
||||
}
|
||||
});
|
||||
|
||||
groups.forEach((group, gIndex) => {
|
||||
const groupDiv = document.createElement('div');
|
||||
groupDiv.style.position = 'absolute';
|
||||
groupDiv.style.width = '100%';
|
||||
groupDiv.style.opacity = 0;
|
||||
groupDiv.style.top = '50%';
|
||||
groupDiv.style.left = '50%';
|
||||
groupDiv.style.transform = 'translate(-50%, -50%)';
|
||||
container.appendChild(groupDiv);
|
||||
|
||||
group.forEach((word, wIndex) => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'caption-word';
|
||||
span.style.opacity = 0;
|
||||
span.textContent = word.text;
|
||||
groupDiv.appendChild(span);
|
||||
|
||||
tl.to(span, {
|
||||
opacity: 1,
|
||||
duration: 0.1,
|
||||
ease: 'none'
|
||||
}, word.start);
|
||||
});
|
||||
|
||||
tl.to(groupDiv, {
|
||||
opacity: 1,
|
||||
duration: 0.1
|
||||
}, group[0].start);
|
||||
|
||||
const nextGroup = groups[gIndex + 1];
|
||||
const exitTime = nextGroup ? nextGroup[0].start - 0.1 : DURATION - 0.2;
|
||||
tl.to(groupDiv, {
|
||||
opacity: 0,
|
||||
duration: 0.2
|
||||
}, exitTime);
|
||||
});
|
||||
|
||||
tl.to({}, { duration: 2 }, DURATION);
|
||||
|
||||
window.__timelines["polaroid"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Polaroid Speaker</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap');
|
||||
body { margin: 0; background: #000; overflow: hidden; font-family: 'Montserrat', sans-serif; }
|
||||
#main-comp { position: relative; width: 1080px; height: 1920px; }
|
||||
|
||||
/* Speaker Background Video */
|
||||
#speaker-video {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(5px) brightness(0.6); /* Reduced blur, slightly brighter */
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#polaroid-comp {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-comp" data-composition-id="main-comp" data-width="1080" data-height="1920" data-duration="13">
|
||||
<!-- Background Layer -->
|
||||
<video id="speaker-video"
|
||||
data-start="0"
|
||||
data-track-index="0"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/uploaded_assets/fb7e48ac_f6bf2ab079394d7ebd0491e7008a9242.mp4">
|
||||
</video>
|
||||
|
||||
<!-- Polaroid Composition Layer -->
|
||||
<div id="polaroid-comp"
|
||||
data-composition-id="polaroid"
|
||||
data-composition-src="compositions/polaroid.html"
|
||||
data-start="0"
|
||||
data-track-index="1">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
// Subtle Ken Burns effect on background
|
||||
tl.fromTo('#speaker-video', { scale: 1.1 }, { scale: 1, duration: 13, ease: 'none' }, 0);
|
||||
window.__timelines["main-comp"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||
import type { RenderJob } from "./useRenderQueue";
|
||||
|
||||
interface RenderQueueItemProps {
|
||||
@@ -19,34 +20,84 @@ function formatTimeAgo(timestamp: number): string {
|
||||
return `${Math.floor(diff / 3600000)}h ago`;
|
||||
}
|
||||
|
||||
/** Static frame extracted once via hidden video + canvas. */
|
||||
|
||||
export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
job,
|
||||
onDelete,
|
||||
}: RenderQueueItemProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/render/${job.id}/download`;
|
||||
a.download = job.filename;
|
||||
a.click();
|
||||
}, [job.id, job.filename]);
|
||||
const handleOpen = useCallback(() => {
|
||||
window.open(`/api/render/${job.id}/view`, "_blank");
|
||||
}, [job.id]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/render/${job.id}/download`;
|
||||
a.download = job.filename;
|
||||
a.click();
|
||||
},
|
||||
[job.id, job.filename],
|
||||
);
|
||||
|
||||
const viewSrc = `/api/render/${job.id}/view`;
|
||||
const isComplete = job.status === "complete";
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
className="px-3 py-2.5 border-b border-neutral-800/30 last:border-0"
|
||||
onClick={isComplete ? handleOpen : undefined}
|
||||
className={[
|
||||
"px-3 py-2.5 border-b border-neutral-800/30 last:border-0 transition-colors duration-150",
|
||||
isComplete ? "cursor-pointer hover:bg-neutral-800/30" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status indicator */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Thumbnail — static frame; swaps to live video on hover */}
|
||||
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
{isComplete && (
|
||||
<>
|
||||
{/* Live video — visible on hover */}
|
||||
{hovered && (
|
||||
<video
|
||||
src={viewSrc}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
{/* Static frame — visible when not hovering */}
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-150"
|
||||
style={{ opacity: hovered ? 0 : 1 }}
|
||||
>
|
||||
<VideoFrameThumbnail src={viewSrc} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{job.status === "rendering" && (
|
||||
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "failed" && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-red-400" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "cancelled" && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-neutral-600" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "complete" && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||
{job.status === "failed" && <div className="w-2 h-2 rounded-full bg-red-400" />}
|
||||
{job.status === "cancelled" && <div className="w-2 h-2 rounded-full bg-neutral-600" />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
@@ -62,7 +113,6 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar + percentage */}
|
||||
{job.status === "rendering" && (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
@@ -90,7 +140,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
{/* Actions */}
|
||||
{hovered && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{job.status === "complete" && (
|
||||
{isComplete && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
|
||||
@@ -113,7 +163,10 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
|
||||
@@ -128,6 +128,7 @@ export function useRenderQueue(projectId: string | null) {
|
||||
? "failed"
|
||||
: j.status,
|
||||
durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
|
||||
error: data.error ?? j.error,
|
||||
}
|
||||
: j,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { memo, useState, useCallback, useRef } from "react";
|
||||
import { ExpandOnHover } from "../ui/ExpandOnHover";
|
||||
import { ExpandedVideoPreview } from "../ui/ExpandedVideoPreview";
|
||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||
|
||||
interface AssetsTabProps {
|
||||
projectId: string;
|
||||
@@ -32,28 +34,13 @@ function AssetThumbnail({
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVideo && (
|
||||
<>
|
||||
<video
|
||||
src={serveUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="white" className="opacity-80">
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
||||
{isAudio && (
|
||||
<div className="w-full h-full flex items-center justify-center bg-neutral-900">
|
||||
<svg
|
||||
@@ -112,22 +99,33 @@ function ExpandedAssetPreview({
|
||||
isAudio: boolean;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
if (isVideo) {
|
||||
return (
|
||||
<ExpandedVideoPreview
|
||||
src={serveUrl}
|
||||
name={name}
|
||||
subtitle={asset}
|
||||
action={
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors flex-shrink-0"
|
||||
>
|
||||
Copy Path
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
|
||||
{isImage && (
|
||||
<img src={serveUrl} alt={name} className="max-w-full max-h-full object-contain rounded" />
|
||||
)}
|
||||
{isVideo && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
{isAudio && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg
|
||||
|
||||
@@ -146,7 +146,7 @@ function CompCard({
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=0.5`;
|
||||
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
||||
|
||||
const card = (
|
||||
@@ -162,7 +162,7 @@ function CompCard({
|
||||
src={thumbnailUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ExpandedVideoPreviewProps {
|
||||
src: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
action: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared expanded video preview used by AssetsTab (video assets) and
|
||||
* the Renders panel. Autoplays the video muted+looped inside a full-bleed
|
||||
* card. Caller provides the footer action slot (Copy Path, Open, etc.).
|
||||
*/
|
||||
export function ExpandedVideoPreview({ src, name, subtitle, action }: ExpandedVideoPreviewProps) {
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
|
||||
<video
|
||||
src={src}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
||||
<div className="min-w-0 flex-1 mr-4">
|
||||
<div className="text-sm font-medium text-neutral-200 truncate">{name}</div>
|
||||
<div className="text-[10px] text-neutral-600 font-mono mt-0.5 truncate">{subtitle}</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Extracts a representative JPEG frame from a video URL using a hidden
|
||||
* video + canvas. Seeks to ~10% of duration to avoid black opening frames.
|
||||
* Used by AssetThumbnail (assets tab) and RenderQueueItem (renders tab).
|
||||
*/
|
||||
export function VideoFrameThumbnail({ src }: { src: string }) {
|
||||
const [frame, setFrame] = useState<string | null>(null);
|
||||
const didExtract = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (didExtract.current) return;
|
||||
didExtract.current = true;
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.crossOrigin = "anonymous";
|
||||
video.muted = true;
|
||||
video.preload = "metadata";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const cleanup = () => {
|
||||
video.src = "";
|
||||
video.load();
|
||||
};
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
video.currentTime = Math.min(2, video.duration * 0.1 || 2);
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", () => {
|
||||
if (!ctx) return;
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0);
|
||||
setFrame(canvas.toDataURL("image/jpeg", 0.7));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
video.addEventListener("error", cleanup);
|
||||
video.src = src;
|
||||
video.load();
|
||||
|
||||
return cleanup;
|
||||
}, [src]);
|
||||
|
||||
if (!frame) {
|
||||
return <div className="w-full h-full bg-neutral-800 animate-pulse" />;
|
||||
}
|
||||
|
||||
return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
previewUrl,
|
||||
label,
|
||||
labelColor,
|
||||
seekTime = 0.4,
|
||||
seekTime = 2,
|
||||
duration = 5,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
@@ -112,7 +112,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
onLoad={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "1";
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
width: dims.w,
|
||||
height: dims.h,
|
||||
border: "none",
|
||||
outline: "1px solid black",
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -749,7 +749,7 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
{/* Keyboard shortcut hint — always visible */}
|
||||
{!showPopover && !rangeSelection && (
|
||||
<div className="absolute bottom-2 right-3 pointer-events-none">
|
||||
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-neutral-800/50 border border-neutral-700/20">
|
||||
<kbd className="text-[9px] font-mono text-neutral-500 bg-neutral-700/40 px-1 py-0.5 rounded">
|
||||
Shift
|
||||
|
||||
Reference in New Issue
Block a user