Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,15 +318,31 @@ async function runEmbeddedMode(
projectName?: string,
forceNew = false,
): Promise<void> {
const { createStudioServer } = await import("../server/studioServer.js");
const { createStudioServer, resolveStudioBundle } = await import("../server/studioServer.js");

const pName = projectName ?? basename(dir);
const { app } = createStudioServer({ projectDir: dir, projectName: pName });
const studioBundle = resolveStudioBundle();

clack.intro(c.bold("hyperframes preview"));
const s = clack.spinner();
s.start("Starting studio...");

if (!studioBundle.available) {
s.stop(c.error("Studio build missing"));
console.error();
console.error(` ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`);
for (const checkedPath of studioBundle.checkedPaths) {
console.error(` ${c.dim("-")} ${checkedPath}`);
}
console.error();
console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("pnpm run build")}`);
console.error();
process.exitCode = 1;
return;
}

const { app } = createStudioServer({ projectDir: dir, projectName: pName });

let result: FindPortResult;
try {
result = await findPortAndServe(app.fetch, startPort, dir, forceNew);
Expand Down
41 changes: 34 additions & 7 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const FFMPEG_EXTRACT_TIMEOUT_MS = 30_000;
async function extractVideoFrameToBuffer(
videoPath: string,
timeSeconds: number,
useVp9AlphaDecoder = false,
): Promise<Buffer | null> {
const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frame-"));
const outPath = join(tmp, "frame.png");
Expand All @@ -33,10 +34,11 @@ async function extractVideoFrameToBuffer(
(resolvePromise) => {
// `-ss` before `-i` performs a fast keyframe seek; adequate for snapshot accuracy
// (±1 frame) and orders of magnitude faster than the decode-and-scan alternative.
const ff = spawn("ffmpeg", [
"-hide_banner",
"-loglevel",
"error",
const args = ["-hide_banner", "-loglevel", "error"];
if (useVp9AlphaDecoder) {
args.push("-c:v", "libvpx-vp9");
}
args.push(
"-ss",
String(Math.max(0, timeSeconds)),
"-i",
Expand All @@ -47,7 +49,8 @@ async function extractVideoFrameToBuffer(
"2",
"-y",
outPath,
]);
);
const ff = spawn("ffmpeg", args);
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
Expand Down Expand Up @@ -252,19 +255,36 @@ async function captureSnapshots(
updates: Array<{ videoId: string; dataUri: string }>,
) => Promise<void>;
type SyncVisibilityFn = (page: unknown, activeVideoIds: string[]) => Promise<void>;
type ExtractMediaMetadataFn = (
filePath: string,
) => Promise<{ videoCodec: string; hasAlpha: boolean }>;
let injectVideoFramesBatch: InjectFn | null = null;
let syncVideoFrameVisibility: SyncVisibilityFn | null = null;
let extractMediaMetadata: ExtractMediaMetadataFn | null = null;
try {
const engine = (await import("@hyperframes/engine")) as {
injectVideoFramesBatch: InjectFn;
syncVideoFrameVisibility: SyncVisibilityFn;
extractMediaMetadata: ExtractMediaMetadataFn;
};
injectVideoFramesBatch = engine.injectVideoFramesBatch;
syncVideoFrameVisibility = engine.syncVideoFrameVisibility;
extractMediaMetadata = engine.extractMediaMetadata;
} catch {
// Engine unavailable in this install — snapshot will still run, and
// compositions without <video data-start> get exactly the old behaviour.
}
const alphaDecoderCache = new Map<string, Promise<boolean>>();
const shouldUseVp9AlphaDecoder = (filePath: string): Promise<boolean> => {
if (!extractMediaMetadata) return Promise.resolve(false);
const cached = alphaDecoderCache.get(filePath);
if (cached) return cached;
const pending = extractMediaMetadata(filePath)
.then((meta) => meta.hasAlpha && meta.videoCodec === "vp9")
.catch(() => false);
alphaDecoderCache.set(filePath, pending);
return pending;
};

// Seek and capture each frame
for (let i = 0; i < positions.length; i++) {
Expand Down Expand Up @@ -324,7 +344,10 @@ async function captureSnapshots(
: srcDur > 0
? Math.max(0, (srcDur - mediaStart) / playbackRate)
: Number.POSITIVE_INFINITY;
const relTime = (t - start) * playbackRate + mediaStart;
let relTime = (t - start) * playbackRate + mediaStart;
if (v.loop && srcDur > mediaStart && relTime >= srcDur) {
relTime = mediaStart + ((relTime - mediaStart) % (srcDur - mediaStart));
}
const activeNow = t >= start && t < start + duration && relTime >= 0 && !!v.id;
return {
id: v.id,
Expand Down Expand Up @@ -356,7 +379,11 @@ async function captureSnapshots(
/* unresolvable src (e.g. blob:, data:) — skip */
}
if (!filePath) continue;
const png = await extractVideoFrameToBuffer(filePath, Math.max(0, v.relTime));
const png = await extractVideoFrameToBuffer(
filePath,
Math.max(0, v.relTime),
await shouldUseVp9AlphaDecoder(filePath),
);
if (!png) continue;
updates.push({
videoId: v.id,
Expand Down
88 changes: 84 additions & 4 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,38 @@ import {
// ── Path resolution ─────────────────────────────────────────────────────────

function resolveDistDir(): string {
return resolveStudioBundle().dir;
}

export interface StudioBundleResolution {
dir: string;
indexPath: string;
available: boolean;
checkedPaths: string[];
}

export function resolveStudioBundle(): StudioBundleResolution {
const builtPath = resolve(__dirname, "studio");
if (existsSync(resolve(builtPath, "index.html"))) return builtPath;
const builtIndex = resolve(builtPath, "index.html");
if (existsSync(builtIndex)) {
return { dir: builtPath, indexPath: builtIndex, available: true, checkedPaths: [builtIndex] };
}
const devPath = resolve(__dirname, "..", "..", "..", "studio", "dist");
if (existsSync(resolve(devPath, "index.html"))) return devPath;
return builtPath;
const devIndex = resolve(devPath, "index.html");
if (existsSync(devIndex)) {
return {
dir: devPath,
indexPath: devIndex,
available: true,
checkedPaths: [builtIndex, devIndex],
};
}
return {
dir: builtPath,
indexPath: builtIndex,
available: false,
checkedPaths: [builtIndex, devIndex],
};
}

function resolveRuntimePath(): string {
Expand Down Expand Up @@ -348,7 +375,60 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
app.get("*", (c) => {
const indexPath = resolve(studioDir, "index.html");
if (!existsSync(indexPath)) {
return c.text("Studio not found. Rebuild with: pnpm run build", 500);
return c.html(
`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HyperFrames Studio unavailable</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #0d0f14;
color: #eef2f7;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(560px, calc(100vw - 48px));
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 28px;
background: #151923;
}
h1 {
margin: 0 0 12px;
font-size: 22px;
line-height: 1.2;
}
p {
margin: 0 0 18px;
color: #aab3c2;
line-height: 1.5;
}
code {
display: block;
padding: 12px 14px;
border-radius: 6px;
background: #090b10;
color: #8ff0c2;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<main>
<h1>Studio bundle missing</h1>
<p>The preview server started, but this CLI build does not contain the Studio assets.</p>
<code>pnpm run build</code>
</main>
</body>
</html>`,
500,
);
}
return c.html(readFileSync(indexPath, "utf-8"));
});
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/compiler/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { compileHtml } from "./htmlCompiler.js";

describe("compileHtml", () => {
it("preserves explicit looped media durations that exceed source duration", async () => {
const html =
'<video id="hero" src="hero.webm" data-start="0" data-duration="4" data-end="4" loop>';

const compiled = await compileHtml(html, "/project", async () => 3.125);

expect(compiled).toContain('data-duration="4"');
expect(compiled).toContain('data-end="4"');
});

it("still clamps non-looping media durations to source duration", async () => {
const html = '<video id="hero" src="hero.webm" data-start="0" data-duration="4" data-end="4">';

const compiled = await compileHtml(html, "/project", async () => 3.125);

expect(compiled).toContain('data-duration="3.125"');
expect(compiled).toContain('data-end="3.125"');
});
});
1 change: 1 addition & 0 deletions packages/core/src/compiler/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export async function compileHtml(

for (const el of preResolved) {
if (!el.src) continue;
if (el.loop) continue;
const src = resolveMediaSrc(el.src, projectDir);
const fileDuration = await probeMediaDuration(src);
if (fileDuration <= 0) continue;
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,26 @@ describe("extractResolvedMedia", () => {
expect(resolved[0].tagName).toBe("video");
expect(resolved[0].duration).toBe(5);
expect(resolved[0].start).toBe(1);
expect(resolved[0].loop).toBe(false);
expect(resolved[1].id).toBe("a1");
expect(resolved[1].tagName).toBe("audio");
expect(resolved[1].duration).toBe(10);
});

it("marks looped media so render compilation can preserve display duration", () => {
const html = '<video id="v1" src="vid.webm" data-start="0" data-duration="4" loop>';

const resolved = extractResolvedMedia(html);

expect(resolved).toHaveLength(1);
expect(resolved[0]).toMatchObject({
id: "v1",
tagName: "video",
duration: 4,
loop: true,
});
});

it("skips elements with invalid durations", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="NaN">';
const resolved = extractResolvedMedia(html);
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/compiler/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface ResolvedMediaElement {
start: number;
duration: number;
mediaStart: number;
loop: boolean;
}

export interface CompilationResult {
Expand All @@ -55,7 +56,7 @@ function getAttr(tag: string, attr: string): string | null {
}

function hasAttr(tag: string, attr: string): boolean {
return new RegExp(`${attr}=["']`).test(tag);
return new RegExp(`\\s${attr}(?:\\s|=|>|/)`).test(tag);
}

function injectAttr(tag: string, attr: string, value: string): string {
Expand Down Expand Up @@ -229,6 +230,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
start: startStr !== null ? parseFloat(startStr) : 0,
duration,
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
loop: hasAttr(tag, "loop"),
});
}

Expand Down
Loading
Loading