fix daemon HyperFrames scaffold and runtime (#7030)

* fix daemon HyperFrames scaffold and runtime

* fix CI snapshots and Nix hashes

* fix(pack): preserve HyperFrames sharp runtime

Copy the target-native sharp closure into final standalone payloads and execute the packaged HyperFrames CLI during afterPack.

Generated-By: looper 0.11.8 (runner=fixer, agent=codex)

* test(pack): avoid chmod on linked Node fixture

Only chmod the copied fallback so a successful hard link does not try to change the CI-owned Node inode.

Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
This commit is contained in:
Ray Xi
2026-08-19 03:16:46 +00:00
committed by GitHub
parent e711d112eb
commit 0d53c96beb
43 changed files with 2276 additions and 168 deletions
+1
View File
@@ -52,6 +52,7 @@
"cheerio": "1.2.0",
"chokidar": "3.6.0",
"express": "5.2.1",
"hyperframes": "0.8.1",
"jszip": "3.10.1",
"kiwi-schema": "0.5.0",
"multer": "2.2.0",
+84 -7
View File
@@ -88,6 +88,14 @@ const MEDIA_GENERATE_BOOLEAN_FLAGS = new Set([
'h',
'loop',
]);
const MEDIA_SCAFFOLD_STRING_FLAGS = new Set([
'project',
'workspace',
'workspace-member',
'composition-dir',
'daemon-url',
]);
const MEDIA_SCAFFOLD_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
const MCP_STRING_FLAGS = new Set([
'daemon-url',
@@ -936,6 +944,10 @@ function printRootHelp() {
Designed to be invoked by a code agent - picks up OD_DAEMON_URL
and OD_PROJECT_ID from the env that the daemon injected on spawn.
od media scaffold --composition-dir .hyperframes-cache/<id>
Create a deterministic HyperFrames composition without npx or global
skill installation, before dispatching it through media generate.
od mcp [--daemon-url <url>]
Run a stdio MCP server that proxies project tool calls to a
running OpenDesign daemon. Wire it into a coding agent
@@ -954,8 +966,8 @@ What the daemon does:
* scans PATH for installed code-agent CLIs (claude, codex, devin, opencode, cursor-agent, ...)
* serves the chat UI at http://<host>:<port>
* proxies messages (text + images) to the selected agent via child-process spawn
* exposes /api/projects/:id/media/generate — the unified image/video/audio
dispatcher that the agent calls via \`od media generate\`.`);
* exposes project-scoped media scaffold/generate APIs — the unified path
that the agent calls via \`od media scaffold\` and \`od media generate\`.`);
}
// ---------------------------------------------------------------------------
@@ -1626,7 +1638,7 @@ async function runMedia(args) {
printMediaHelp();
return;
}
if (sub !== 'generate' && sub !== 'wait') {
if (sub !== 'generate' && sub !== 'wait' && sub !== 'scaffold') {
console.error(`unknown subcommand: od media ${sub}`);
printMediaHelp();
process.exit(1);
@@ -1635,9 +1647,66 @@ async function runMedia(args) {
const idx = args.indexOf(sub);
const subArgs = [...args.slice(0, idx), ...args.slice(idx + 1)];
if (sub === 'wait') return runMediaWait(subArgs);
if (sub === 'scaffold') return runMediaScaffold(subArgs);
return runMediaGenerate(subArgs);
}
async function runMediaScaffold(rawArgs) {
let flags;
try {
flags = parseFlags(rawArgs, {
string: MEDIA_SCAFFOLD_STRING_FLAGS,
boolean: MEDIA_SCAFFOLD_BOOLEAN_FLAGS,
});
} catch (err) {
console.error(err.message);
printMediaHelp();
process.exit(2);
}
if (flags.help || flags.h) {
printMediaHelp();
return;
}
const daemonUrl = await cliDaemonUrl(flags);
const projectId = flags.project || process.env.OD_PROJECT_ID;
const token = process.env.OD_TOOL_TOKEN;
if (!projectId && !token) {
console.error('project id required. Pass --project <id> or set OD_PROJECT_ID.');
process.exit(2);
}
const compositionDir = flags['composition-dir'];
if (!compositionDir) {
console.error('--composition-dir required (expected .hyperframes-cache/<id>)');
process.exit(2);
}
const url = token
? `${daemonUrl.replace(/\/$/, '')}/api/tools/media/hyperframes/scaffold`
: `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/hyperframes/scaffold`;
let resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
...(token ? {} : workspaceHeadersFromExplicitFlags(flags) ?? {}),
},
body: JSON.stringify({ compositionDir }),
});
} catch (err) {
surfaceFetchError(err, daemonUrl);
process.exit(3);
}
if (!resp.ok) {
const responseText = await resp.text();
console.error(`daemon ${resp.status}: ${responseText}`);
process.exit(4);
}
process.stdout.write(`${JSON.stringify(await resp.json())}\n`);
}
async function runMediaGenerate(rawArgs) {
let flags;
try {
@@ -2029,10 +2098,17 @@ async function cliDaemonBaseUrl(flags) {
}
function printMediaHelp() {
console.log(`Usage: od media generate --surface <image|video|audio> --model <id> [opts]
console.log(`Usage: od media scaffold --composition-dir .hyperframes-cache/<id> [opts]
od media generate --surface <image|video|audio> --model <id> [opts]
"$OD_NODE_BIN" "$OD_BIN" media generate --surface <image|video|audio> --model <id> [opts]
Required:
Scaffold:
Creates hyperframes.json, meta.json, and index.html without running
HyperFrames init or installing global skills. The target must be new and
live directly under .hyperframes-cache.
--json is accepted for consistency; scaffold output is always one JSON line.
Generate required:
--surface image | video | audio
--model Model id from /api/media/models (e.g. gpt-image-2, seedance-2, suno-v5).
--project Project id. Auto-resolved from OD_PROJECT_ID when invoked by the daemon.
@@ -2060,8 +2136,9 @@ Common options:
--audio-kind music|speech|sfx
--composition-dir <path> hyperframes-html only — project-relative path
to the dir containing hyperframes.json /
meta.json / index.html. The daemon runs
\`npx hyperframes render\` against it.
meta.json / index.html. Use \`media scaffold\` to
create it; the daemon renders it with its pinned
HyperFrames runtime.
--image <path> Project-relative reference image; repeat up to 5
times for Vela image editing or video references.
The first video image is the first frame; the rest
@@ -0,0 +1,35 @@
import { createRequire } from 'node:module';
import path from 'node:path';
const require = createRequire(import.meta.url);
export const HYPERFRAMES_CLI_ENV = 'OD_HYPERFRAMES_BIN';
export function resolveHyperFramesCliPath({
env = process.env,
resolvePackage = require.resolve,
}: {
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
resolvePackage?: (id: string) => string;
} = {}): string {
const configured = env[HYPERFRAMES_CLI_ENV]?.trim();
if (configured) return configured;
try {
const manifestPath = resolvePackage('hyperframes/package.json');
return path.join(path.dirname(manifestPath), 'bin', 'hyperframes.mjs');
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Bundled HyperFrames CLI is unavailable. Reinstall Open Design so its pinned ` +
`HyperFrames runtime and native dependencies match this platform. ${detail}`,
);
}
}
export function resolveHyperFramesNodeBin(
env: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env,
execPath: string = process.execPath,
): string {
return env.OD_NODE_BIN?.trim() || execPath;
}
@@ -0,0 +1,114 @@
import { lstat, mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
const HYPERFRAMES_CACHE_DIR = '.hyperframes-cache';
const COMPOSITION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const HYPERFRAMES_CONFIG = `${JSON.stringify({
$schema: 'https://hyperframes.heygen.com/schema/hyperframes.json',
registry: 'https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry',
paths: {
blocks: 'compositions',
components: 'compositions/components',
assets: 'assets',
},
media: { autoProxy: true },
}, null, 2)}\n`;
const BLANK_COMPOSITION_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="10"
data-width="1920"
data-height="1080"
></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body>
</html>
`;
export interface HyperFramesScaffoldResult {
compositionDir: string;
files: ['hyperframes.json', 'meta.json', 'index.html'];
}
export async function scaffoldHyperFramesComposition(input: {
projectDir: string;
compositionDir: string;
now?: Date;
}): Promise<HyperFramesScaffoldResult> {
if (!path.isAbsolute(input.projectDir)) {
throw new Error('projectDir must be absolute');
}
const requested = input.compositionDir.trim();
const normalized = path.normalize(requested);
const parts = normalized.split(path.sep);
const compositionId = parts[1] ?? '';
if (
parts.length !== 2
|| parts[0] !== HYPERFRAMES_CACHE_DIR
|| !COMPOSITION_ID_RE.test(compositionId)
) {
throw new Error('compositionDir must be inside .hyperframes-cache as .hyperframes-cache/<id>');
}
const cacheDir = path.join(input.projectDir, HYPERFRAMES_CACHE_DIR);
await mkdir(cacheDir, { recursive: true });
const cacheStat = await lstat(cacheDir);
if (!cacheStat.isDirectory() || cacheStat.isSymbolicLink()) {
throw new Error('.hyperframes-cache must be a real directory inside the project');
}
const targetDir = path.join(cacheDir, compositionId);
try {
await lstat(targetDir);
throw new Error(`composition already exists: ${HYPERFRAMES_CACHE_DIR}/${compositionId}`);
} catch (error: any) {
if (error?.code !== 'ENOENT') throw error;
}
await mkdir(targetDir);
const files = ['hyperframes.json', 'meta.json', 'index.html'] as const;
try {
const createdAt = (input.now ?? new Date()).toISOString();
const metadata = `${JSON.stringify({
id: compositionId,
name: compositionId,
createdAt,
}, null, 2)}\n`;
await Promise.all([
writeFile(path.join(targetDir, files[0]), HYPERFRAMES_CONFIG, { encoding: 'utf8', flag: 'wx' }),
writeFile(path.join(targetDir, files[1]), metadata, { encoding: 'utf8', flag: 'wx' }),
writeFile(path.join(targetDir, files[2]), BLANK_COMPOSITION_HTML, { encoding: 'utf8', flag: 'wx' }),
]);
} catch (error) {
await rm(targetDir, { recursive: true, force: true });
throw error;
}
return {
compositionDir: `${HYPERFRAMES_CACHE_DIR}/${compositionId}`,
files: [...files],
};
}
+19 -11
View File
@@ -77,6 +77,10 @@ import {
fetchImageGenerationWithResponseRetry,
type ImageGenerationRequestSummary,
} from './image-generation-retry.js';
import {
resolveHyperFramesCliPath,
resolveHyperFramesNodeBin,
} from './hyperframes-runtime.js';
import { renderVelaImage, renderVelaVideo } from './vela.js';
import {
ensureProject,
@@ -3803,7 +3807,8 @@ async function renderFalVideo(ctx: MediaContext, credentials: ProviderConfig, on
// with a GSAP timeline) into a hidden cache dir under the project, then
// dispatches here with `--composition-dir <relative-path>`.
//
// We run `npx hyperframes render <absolutePath> --output <tmp>/render.mp4`
// We run the pinned HyperFrames CLI with the daemon's Node-compatible runtime:
// `<node> <hyperframes-cli> render <absolutePath> --output <tmp>/render.mp4`
// from the daemon process (NOT the agent's shell) for two reasons:
// 1. HyperFrames spawns a puppeteer-controlled Chrome to capture frames.
// Claude Code's Bash tool wraps subprocesses in macOS sandbox-exec,
@@ -3860,13 +3865,13 @@ async function renderHyperFramesViaCli(ctx: MediaContext, projectDir: string, on
compAbs,
compRel,
'hyperframes.json',
'Run `npx hyperframes init "$OD_PROJECT_DIR/$COMP_REL" --example blank --skip-skills --non-interactive` before editing the composition.',
'Run `"$OD_NODE_BIN" "$OD_BIN" media scaffold --project "$OD_PROJECT_ID" --composition-dir "$COMP_REL"` before editing the composition.',
);
await assertHyperFramesCompositionFile(
compAbs,
compRel,
'meta.json',
'Run `npx hyperframes init` so the renderer has duration/scene metadata before dispatch.',
'Run `"$OD_NODE_BIN" "$OD_BIN" media scaffold --composition-dir "$COMP_REL"` so the renderer has duration/scene metadata before dispatch.',
);
await assertHyperFramesCompositionFile(
compAbs,
@@ -3915,7 +3920,7 @@ async function assertHyperFramesCompositionFile(
}
/**
* Run `npx hyperframes render` and stream every line of stdout/stderr
* Run the pinned HyperFrames CLI and stream every line of stdout/stderr
* through `onProgress`. Resolves on a clean exit, rejects on non-zero
* exit (with the stderr tail attached so the dispatcher can surface it).
*
@@ -3927,11 +3932,11 @@ async function assertHyperFramesCompositionFile(
*/
function runHyperFramesRender(compAbs: string, tmpOutput: string, onProgress?: ProgressFn): Promise<void> {
return new Promise<void>((resolve, reject) => {
const hyperFramesCli = resolveHyperFramesCliPath();
const child = spawn(
'npx',
resolveHyperFramesNodeBin(),
[
'-y',
'hyperframes',
hyperFramesCli,
'render',
compAbs,
'--output',
@@ -3940,10 +3945,13 @@ function runHyperFramesRender(compAbs: string, tmpOutput: string, onProgress?: P
'1',
],
{
// Inherit env so npx can find the cached hyperframes install
// and any user-level node config. stdin closed (HF doesn't
// read from it), stdout/stderr piped so we can stream.
env: process.env,
// Use the same Node-compatible runtime that owns the daemon and a
// pinned HyperFrames CLI shipped with Open Design. Do not delegate
// native dependency selection to a user-level npx cache.
env: {
...process.env,
OD_HYPERFRAMES_BIN: hyperFramesCli,
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
+9 -8
View File
@@ -274,7 +274,7 @@ capture. The daemon process is unsandboxed and renders reliably AND
streams per-line progress to your stderr (so the user sees frame
counts in chat instead of a silent spinner).
**Default recipe — use \`hyperframes init\`, don't write from scratch.**
**Default recipe — use Open Design's scaffold, don't write from scratch.**
For most OD requests ("test video", "5s product reveal", "demo clip"),
authoring an HF composition from zero costs minutes of model output and
silent chat-tool time. The init scaffold gives you a valid GSAP-ready
@@ -285,8 +285,8 @@ actually changes.
COMP_REL=".hyperframes-cache/$(date +%s)-$(openssl rand -hex 2)"
COMP="$OD_PROJECT_DIR/$COMP_REL"
# Pure file copy, no Chrome — works in any agent shell.
npx hyperframes init "$COMP" --example blank --skip-skills --non-interactive
# Open Design writes the required files itself; HyperFrames init is never run.
"$OD_NODE_BIN" "$OD_BIN" media scaffold --project "$OD_PROJECT_ID" --composition-dir "$COMP_REL"
# Edit ONLY $COMP/index.html: tweak data-duration on the root, swap
# the placeholder palette, add 1–3 clip <div>s, and append matching
@@ -313,10 +313,11 @@ The chat surfaces the mp4 as a download/open chip automatically. Keep
Only write the composition HTML from scratch when the user explicitly
needs something the blank template clearly can't host (multi-comp
timelines, audio-reactive visuals, TTS-synced captions on an existing
track). For typical test renders, the init+edit path is the default.
track). For typical test renders, the scaffold+edit path is the default.
You MAY still run lighter HF subcommands from your own shell:
\`npx hyperframes lint "$COMP"\`, \`transcribe\`, \`tts\` — none of
You MAY still run lighter HF subcommands from your own shell through
\`"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN"\`: \`lint "$COMP"\`, \`transcribe\`,
\`tts\` — none of
these spawn Chrome so the agent-side sandbox doesn't trip them.
Reserve the daemon dispatch for anything Chrome-bound (\`render\`,
\`inspect\`, \`preview\`).
@@ -477,10 +478,10 @@ path is given.
For \`hyperframes-html\`, the discovery turn is the last turn before
you start authoring. Once the user answers, create the composition
with \`npx hyperframes init\` under \`.hyperframes-cache/\`, edit the
with \`"$OD_NODE_BIN" "$OD_BIN" media scaffold\` under \`.hyperframes-cache/\`, edit the
generated \`index.html\`, and dispatch through
\`"$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model hyperframes-html --composition-dir <rel>\`.
Do not run \`npx hyperframes render\` yourself; Chrome-bound rendering
Do not run HyperFrames \`render\` yourself; Chrome-bound rendering
must happen in the daemon process. Do not add a second "plan" or
"environment check" message first.
3. **Generate by shell, then follow the user-facing completion contract.** When you invoke
+1 -1
View File
@@ -1761,7 +1761,7 @@ function renderMetadataBlock(
));
if (metadata.videoModel === 'hyperframes-html') {
lines.push(
'Special case: `hyperframes-html` is a local HTML-to-MP4 renderer, not a photoreal text-to-video model. Treat it like a motion design renderer, ask at most one clarifying question, then create a HyperFrames composition with `npx hyperframes init` under `.hyperframes-cache/`, edit `index.html`, and dispatch via `"$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model hyperframes-html --composition-dir <rel>`. Do not run `npx hyperframes render` yourself.',
'Special case: `hyperframes-html` is a local HTML-to-MP4 renderer, not a photoreal text-to-video model. Treat it like a motion design renderer, ask at most one clarifying question, then create a HyperFrames composition with `"$OD_NODE_BIN" "$OD_BIN" media scaffold --composition-dir <rel>` under `.hyperframes-cache/`, edit `index.html`, and dispatch via `"$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model hyperframes-html --composition-dir <rel>`. Do not run HyperFrames `init` or `render` yourself.',
);
}
}
+74
View File
@@ -1,6 +1,8 @@
import fs from 'node:fs';
import type { Express } from 'express';
import type {
HyperFramesScaffoldRequest,
HyperFramesScaffoldResponse,
MediaExecutionPolicy,
MediaGenerationResultProps,
} from '@open-design/contracts';
@@ -24,9 +26,11 @@ import {
} from '../integrations/aihubmix.js';
import { isSandboxModeEnabled } from '../sandbox-mode.js';
import {
HYPERFRAMES_SCAFFOLD_TOOL_ENDPOINT,
MEDIA_TASK_WAIT_TOOL_ENDPOINT,
type ToolTokenGrant,
} from '../tool-tokens.js';
import { scaffoldHyperFramesComposition } from '../media/hyperframes-scaffold.js';
import { normalizePersistedAutomationWorkspaceScope } from '../automations/workspace-scope.js';
const LONG_MEDIA_PROXY_TIMEOUT_MS = 10 * 60 * 1000;
@@ -133,6 +137,7 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
const { orbitService } = ctx.orbit;
const { openBrowser, openNativeFolderDialog } = ctx.nativeDialogs;
const { findTeamWorkspaceIdForProject, getProject } = ctx.projectStore;
const { resolveProjectDir } = ctx.projectFiles;
const { insertConversation, upsertMessage } = ctx.conversations;
const { searchResearch, ResearchError } = ctx.research;
const getResolvedPort = () => resolvedPortRef.current;
@@ -375,6 +380,27 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
}
};
const handleHyperFramesScaffold = async (
req: any,
res: any,
projectId: string,
) => {
const project = getProject(db, projectId);
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const body = (req.body ?? {}) as Partial<HyperFramesScaffoldRequest>;
if (typeof body.compositionDir !== 'string' || !body.compositionDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'compositionDir is required');
}
const projectDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
const result: HyperFramesScaffoldResponse = await scaffoldHyperFramesComposition({
projectDir,
compositionDir: body.compositionDir,
});
return res.status(201).json(result);
};
const captureMediaGenerationResult = (input: {
analyticsContext: AnalyticsContext;
durationMs: number;
@@ -742,6 +768,54 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
}
});
app.post('/api/projects/:id/media/hyperframes/scaffold', async (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({
error: 'cross-origin request rejected: HyperFrames scaffolding is restricted to the local UI / CLI',
});
}
try {
const project = getProject(db, req.params.id);
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
if (!await ctx.authorizeProjectRequest(
req,
res,
project.id,
{ mode: 'write', capability: 'writeFiles' },
)) return;
await handleHyperFramesScaffold(req, res, project.id);
} catch (err: any) {
const status = typeof err?.status === 'number' ? err.status : 400;
const code = err?.code;
const body: any = { error: String(err && err.message ? err.message : err) };
if (code) body.code = code;
res.status(status).json(body);
}
});
app.post(HYPERFRAMES_SCAFFOLD_TOOL_ENDPOINT, async (req, res) => {
const grant = authorizeToolRequest(req, res, 'media:scaffold', {
endpoint: HYPERFRAMES_SCAFFOLD_TOOL_ENDPOINT,
});
if (!grant) return;
try {
if (!await ctx.authorizeProjectToolRequest(
res,
grant.projectId,
{ mode: 'write', capability: 'writeFiles' },
)) return;
await handleHyperFramesScaffold(req, res, grant.projectId);
} catch (err: any) {
const status = typeof err?.status === 'number' ? err.status : 400;
const code = err?.code;
const body: any = { error: String(err && err.message ? err.message : err) };
if (code) body.code = code;
res.status(status).json(body);
}
});
app.post('/api/projects/:id/media/generate', async (req, res) => {
if (!isLocalSameOrigin(req, getResolvedPort())) {
return res.status(403).json({
+1
View File
@@ -148,6 +148,7 @@ const CODEX_SHELL_ENVIRONMENT_INCLUDE_KEYS = [
'HOMEDRIVE',
'HOMEPATH',
'OD_BIN',
'OD_HYPERFRAMES_BIN',
'OD_NODE_BIN',
'OD_DAEMON_URL',
'OD_TOOL_TOKEN',
+5
View File
@@ -512,6 +512,7 @@ import { loadCraftSections, resolveCraftRequirements } from './craft.js';
import { skillCwdAliasSegment, stageActiveSkill } from './cwd-aliases.js';
import { buildDesktopArtifactExportInput, buildDesktopPdfExportInput } from './pdf-export.js';
import { generateMedia } from './media/index.js';
import { resolveHyperFramesCliPath } from './media/hyperframes-runtime.js';
import { listElevenLabsVoiceOptions } from './integrations/elevenlabs-voices.js';
import { searchResearch, ResearchError } from './research/index.js';
import { openBrowser } from './browser/index.js';
@@ -1683,6 +1684,7 @@ export function createAgentRuntimeToolPrompt(
'',
`- Daemon URL: \`${daemonUrl}\` (also available as \`OD_DAEMON_URL\`).`,
'- `OD_NODE_BIN` is the absolute path to the Node-compatible runtime that started the daemon; packaged desktop installs provide this even when the user has no system `node` on PATH.',
'- `OD_HYPERFRAMES_BIN` is the absolute path to Open Design\'s pinned HyperFrames CLI. Run lightweight commands through `OD_NODE_BIN`; use `"$OD_NODE_BIN" "$OD_BIN" media scaffold` for composition setup and never use a user-level `npx` cache.',
'- `OD_BIN` is the absolute path to the OpenDesign CLI script. On POSIX shells run wrappers with `"$OD_NODE_BIN" "$OD_BIN" tools ...`; do not call bare `od`, which may resolve to the system octal-dump command on Unix-like systems.',
'- On PowerShell use `& $env:OD_NODE_BIN $env:OD_BIN tools ...`; on cmd.exe use `"%OD_NODE_BIN%" "%OD_BIN%" tools ...`.',
tokenLine,
@@ -1692,16 +1694,19 @@ export function createAgentRuntimeToolPrompt(
export function createOpenDesignToolEnv({
daemonUrl,
hyperFramesBin = resolveHyperFramesCliPath(),
projectDir,
projectId,
}: {
daemonUrl: string;
hyperFramesBin?: string;
projectDir?: string | null;
projectId?: string | null;
}): NodeJS.ProcessEnv {
return {
OD_BIN,
OD_DATA_DIR: RUNTIME_DATA_DIR,
OD_HYPERFRAMES_BIN: hyperFramesBin,
OD_NODE_BIN,
OD_DAEMON_URL: daemonUrl,
...(typeof projectId === 'string' && projectId && projectDir
+3
View File
@@ -18,6 +18,7 @@ export function resolveChatToolTokenTtlMs(inactivityTimeoutMs: number): number {
// Capability key for the parameterized media wait route. Token grants cannot
// enumerate a task id that is created after the grant is minted.
export const MEDIA_TASK_WAIT_TOOL_ENDPOINT = '/api/media/tasks/:id/wait';
export const HYPERFRAMES_SCAFFOLD_TOOL_ENDPOINT = '/api/tools/media/hyperframes/scaffold';
export const PROJECT_EXPORT_TOOL_ENDPOINT = '/api/projects/:id/export/:format';
export const CHAT_TOOL_ENDPOINTS = [
@@ -31,6 +32,7 @@ export const CHAT_TOOL_ENDPOINTS = [
'/api/tools/design-systems/resolve-intent',
'/api/tools/design-systems/validate-adherence',
'/api/tools/media/generate',
HYPERFRAMES_SCAFFOLD_TOOL_ENDPOINT,
MEDIA_TASK_WAIT_TOOL_ENDPOINT,
PROJECT_EXPORT_TOOL_ENDPOINT,
'/api/tools/library/search',
@@ -48,6 +50,7 @@ export const CHAT_TOOL_OPERATIONS = [
'design-systems:resolve-intent',
'design-systems:validate-adherence',
'media:generate',
'media:scaffold',
'project:export',
'library:search',
'library:apply',
@@ -0,0 +1,98 @@
import { spawn } from 'node:child_process';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const daemonRoot = fileURLToPath(new URL('..', import.meta.url));
const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
let server: http.Server | undefined;
let baseUrl = '';
let seen: { url: string; body: string; authorization?: string } | undefined;
beforeEach(async () => {
seen = undefined;
server = http.createServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
seen = {
url: req.url ?? '',
body,
...(req.headers.authorization ? { authorization: req.headers.authorization } : {}),
};
res.writeHead(201, { 'content-type': 'application/json' });
res.end(JSON.stringify({
compositionDir: '.hyperframes-cache/launch-video',
files: ['hyperframes.json', 'meta.json', 'index.html'],
}));
});
});
await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('server did not bind');
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterEach(async () => {
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
server = undefined;
});
async function runScaffoldCli(env: NodeJS.ProcessEnv) {
return new Promise<{ code: number; stdout: string; stderr: string }>((resolve, reject) => {
const child = spawn(process.execPath, [
'--import', 'tsx', cliEntry,
'media', 'scaffold',
'--composition-dir', '.hyperframes-cache/launch-video',
'--daemon-url', baseUrl,
'--json',
], {
cwd: daemonRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => (stdout += chunk));
child.stderr.on('data', (chunk) => (stderr += chunk));
child.on('error', reject);
child.on('close', (code) => resolve({ code: code ?? -1, stdout, stderr }));
});
}
describe('od media scaffold', () => {
it('asks the daemon to create a project-scoped HyperFrames composition', async () => {
const result = await runScaffoldCli({
...process.env,
OD_PROJECT_ID: 'project-1',
OD_TOOL_TOKEN: '',
});
expect(result, result.stderr).toMatchObject({ code: 0 });
expect(seen?.url).toBe('/api/projects/project-1/media/hyperframes/scaffold');
expect(JSON.parse(seen?.body ?? '{}')).toEqual({
compositionDir: '.hyperframes-cache/launch-video',
});
expect(JSON.parse(result.stdout)).toMatchObject({
compositionDir: '.hyperframes-cache/launch-video',
});
});
it('derives project authority from the injected tool token', async () => {
const result = await runScaffoldCli({
...process.env,
OD_PROJECT_ID: '',
OD_TOOL_TOKEN: 'token-1',
});
expect(result, result.stderr).toMatchObject({ code: 0 });
expect(seen).toMatchObject({
url: '/api/tools/media/hyperframes/scaffold',
authorization: 'Bearer token-1',
});
});
});
@@ -0,0 +1,121 @@
import type http from 'node:http';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import express from 'express';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerMediaRoutes } from '../../src/routes/media.js';
import { HYPERFRAMES_SCAFFOLD_TOOL_ENDPOINT } from '../../src/tool-tokens.js';
const servers: http.Server[] = [];
const roots: string[] = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => {
server.close(() => resolve());
})));
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function noop() {}
function functionProxy(overrides: Record<string, unknown> = {}) {
return new Proxy(overrides, {
get(target, property) {
return property in target ? target[property as string] : noop;
},
});
}
async function startServer() {
const projectDir = await mkdtemp(path.join(tmpdir(), 'od-hyperframes-route-'));
roots.push(projectDir);
const authorizeToolRequest = vi.fn(() => ({
token: 'tool-token',
runId: 'run-1',
projectId: 'project-1',
allowedEndpoints: [HYPERFRAMES_SCAFFOLD_TOOL_ENDPOINT],
allowedOperations: ['media:scaffold'],
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
}));
const deps = {
db: {},
design: functionProxy(),
http: {
sendApiError: (res: express.Response, status: number, code: string, message: string) =>
res.status(status).json({ error: { code, message } }),
requireLocalDaemonRequest: (_req: unknown, _res: unknown, next: () => void) => next(),
isLocalSameOrigin: () => true,
resolvedPortRef: { current: 0 },
},
paths: {
PROJECT_ROOT: projectDir,
PROJECTS_DIR: path.dirname(projectDir),
RUNTIME_DATA_DIR: projectDir,
},
ids: { randomUUID: () => 'unused' },
auth: functionProxy({ authorizeToolRequest }),
media: functionProxy(),
appConfig: functionProxy(),
orbit: functionProxy({ orbitService: functionProxy() }),
nativeDialogs: functionProxy(),
projectStore: functionProxy({
getProject: (_db: unknown, projectId: string) => ({ id: projectId, metadata: {} }),
}),
projectFiles: functionProxy({ resolveProjectDir: () => projectDir }),
conversations: functionProxy(),
research: functionProxy({ ResearchError: class ResearchError extends Error {} }),
authorizeProjectRequest: async () => true,
authorizeProjectToolRequest: async () => true,
} as unknown as Parameters<typeof registerMediaRoutes>[1];
const app = express();
app.use(express.json());
registerMediaRoutes(app, deps);
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('server did not bind');
return {
authorizeToolRequest,
projectDir,
url: `http://127.0.0.1:${address.port}`,
};
}
describe('HyperFrames scaffold routes', () => {
it('creates project and tool-token compositions through the same daemon helper', async () => {
const started = await startServer();
const projectResponse = await fetch(
`${started.url}/api/projects/project-1/media/hyperframes/scaffold`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ compositionDir: '.hyperframes-cache/project-route' }),
},
);
expect(projectResponse.status).toBe(201);
const toolResponse = await fetch(`${started.url}${HYPERFRAMES_SCAFFOLD_TOOL_ENDPOINT}`, {
method: 'POST',
headers: {
authorization: 'Bearer tool-token',
'content-type': 'application/json',
},
body: JSON.stringify({ compositionDir: '.hyperframes-cache/tool-route' }),
});
expect(toolResponse.status).toBe(201);
expect(started.authorizeToolRequest).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
'media:scaffold',
{ endpoint: HYPERFRAMES_SCAFFOLD_TOOL_ENDPOINT },
);
await expect(readFile(
path.join(started.projectDir, '.hyperframes-cache/tool-route/hyperframes.json'),
'utf8',
)).resolves.toContain('hyperframes.heygen.com/schema');
});
});
@@ -0,0 +1,54 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { scaffoldHyperFramesComposition } from '../../src/media/hyperframes-scaffold.js';
describe('HyperFrames composition scaffold', () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it('creates the daemon-owned minimal composition without running HyperFrames init', async () => {
const projectDir = await mkdtemp(path.join(tmpdir(), 'od-hyperframes-scaffold-'));
roots.push(projectDir);
const result = await scaffoldHyperFramesComposition({
projectDir,
compositionDir: '.hyperframes-cache/launch-video',
now: new Date('2026-08-18T00:00:00.000Z'),
});
expect(result).toEqual({
compositionDir: '.hyperframes-cache/launch-video',
files: ['hyperframes.json', 'meta.json', 'index.html'],
});
await expect(readFile(path.join(projectDir, result.compositionDir, 'hyperframes.json'), 'utf8'))
.resolves.toContain('https://hyperframes.heygen.com/schema/hyperframes.json');
await expect(readFile(path.join(projectDir, result.compositionDir, 'meta.json'), 'utf8'))
.resolves.toContain('"createdAt": "2026-08-18T00:00:00.000Z"');
await expect(readFile(path.join(projectDir, result.compositionDir, 'index.html'), 'utf8'))
.resolves.toContain('window.__timelines["main"] = tl');
});
it('rejects paths outside the dedicated cache and refuses to overwrite a composition', async () => {
const projectDir = await mkdtemp(path.join(tmpdir(), 'od-hyperframes-scaffold-'));
roots.push(projectDir);
await expect(scaffoldHyperFramesComposition({
projectDir,
compositionDir: '../outside',
})).rejects.toThrow(/inside \.hyperframes-cache/);
await scaffoldHyperFramesComposition({
projectDir,
compositionDir: '.hyperframes-cache/existing',
});
await expect(scaffoldHyperFramesComposition({
projectDir,
compositionDir: '.hyperframes-cache/existing',
})).rejects.toThrow(/already exists/);
});
});
+58 -1
View File
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -10,6 +10,9 @@ describe('hyperframes-html media renderer preflight', () => {
let projectRoot: string;
let projectsRoot: string;
const originalAllowStubs = process.env.OD_MEDIA_ALLOW_STUBS;
const originalHyperFramesBin = process.env.OD_HYPERFRAMES_BIN;
const originalNodeBin = process.env.OD_NODE_BIN;
const originalPath = process.env.PATH;
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'od-hyperframes-media-'));
@@ -25,6 +28,21 @@ describe('hyperframes-html media renderer preflight', () => {
} else {
process.env.OD_MEDIA_ALLOW_STUBS = originalAllowStubs;
}
if (originalHyperFramesBin == null) {
delete process.env.OD_HYPERFRAMES_BIN;
} else {
process.env.OD_HYPERFRAMES_BIN = originalHyperFramesBin;
}
if (originalNodeBin == null) {
delete process.env.OD_NODE_BIN;
} else {
process.env.OD_NODE_BIN = originalNodeBin;
}
if (originalPath == null) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
await rm(root, { recursive: true, force: true });
});
@@ -62,4 +80,43 @@ describe('hyperframes-html media renderer preflight', () => {
compositionDir: compRel,
})).rejects.toThrow(/compositionDir is missing meta\.json/);
});
it('renders through the daemon-owned HyperFrames CLI without npx on PATH', async () => {
const compRel = '.hyperframes-cache/managed-runtime';
const compDir = path.join(projectsRoot, 'project-1', compRel);
const fakeCli = path.join(root, 'fake-hyperframes.mjs');
await mkdir(compDir, { recursive: true });
await writeFile(path.join(compDir, 'hyperframes.json'), '{}', 'utf8');
await writeFile(path.join(compDir, 'meta.json'), '{}', 'utf8');
await writeFile(path.join(compDir, 'index.html'), '<!doctype html><div id="root"></div>', 'utf8');
await writeFile(
fakeCli,
[
"import { writeFile } from 'node:fs/promises';",
"const outputIndex = process.argv.indexOf('--output');",
"if (process.argv[2] !== 'render' || outputIndex < 0) process.exit(64);",
"await writeFile(process.argv[outputIndex + 1], 'managed-hyperframes-render');",
"process.stderr.write('Capturing frame 1/1\\n');",
].join('\n'),
'utf8',
);
process.env.OD_HYPERFRAMES_BIN = fakeCli;
process.env.OD_NODE_BIN = process.execPath;
process.env.PATH = path.join(root, 'empty-path');
const result = await generateMedia({
projectRoot,
projectsRoot,
projectId: 'project-1',
surface: 'video',
model: 'hyperframes-html',
output: 'managed.mp4',
compositionDir: compRel,
});
expect(result.name).toBe('managed.mp4');
expect(result.providerNote).toContain('hyperframes/local-html');
await expect(readFile(path.join(projectsRoot, 'project-1', 'managed.mp4'), 'utf8'))
.resolves.toBe('managed-hyperframes-render');
});
});
@@ -48,7 +48,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
"clarifying-questions",
"role-marker-guard",
],
"totalChars": 45962,
"totalChars": 46059,
},
"critique-enabled": {
"sections": [
@@ -279,7 +279,7 @@ exports[`composeSystemPrompt — scenario × section golden matrix > keeps the s
"clarifying-questions",
"role-marker-guard",
],
"totalChars": 45960,
"totalChars": 46057,
},
"memory-hooks-off": {
"sections": [
+6 -2
View File
@@ -270,8 +270,10 @@ describe('composeSystemPrompt', () => {
expect(prompt).toContain('## Active skill — hyperframes');
expect(prompt).toContain('**Pre-flight (do this before any other tool):**');
expect(prompt).toContain('`references/html-in-canvas.md`');
expect(prompt).toContain('`"$OD_NODE_BIN" "$OD_BIN" media scaffold`');
expect(prompt).toContain('media generate --surface video --model hyperframes-html --composition-dir <rel>');
expect(prompt).toContain('Do not run `npx hyperframes render` yourself');
expect(prompt).toContain('Do not run HyperFrames `render` yourself');
expect(prompt).not.toContain('npx hyperframes');
expect(prompt).not.toContain('intentionally rejected for this model');
expect(prompt).not.toContain('AGENT_RENDERED');
expect(prompt).not.toContain('rendered by you directly via npx');
@@ -279,8 +281,10 @@ describe('composeSystemPrompt', () => {
it('keeps both hyperframes skill copies aligned with the daemon render handoff', () => {
for (const markdown of [hyperframesSkillMarkdown, officialHyperframesSkillMarkdown]) {
expect(markdown).toContain('"$OD_NODE_BIN" "$OD_BIN" media scaffold');
expect(markdown).toContain('media generate --surface video --model hyperframes-html --composition-dir <rel>');
expect(markdown).toContain('Do not run `npx hyperframes render`');
expect(markdown).toContain('Do not run HyperFrames `render`');
expect(markdown).not.toContain('npx hyperframes');
expect(markdown).not.toContain('AGENT_RENDERED');
expect(markdown).not.toContain('rendered by you directly via npx');
expect(markdown).not.toContain('dispatcher path returns a 400');
@@ -209,6 +209,7 @@ describe('agent runtime tool environment', () => {
),
...createOpenDesignToolEnv({
daemonUrl: 'http://127.0.0.1:7456',
hyperFramesBin: '/opt/open-design/hyperframes/bin/hyperframes.mjs',
projectDir: '/tmp/project',
projectId: 'project-1',
}),
@@ -220,6 +221,7 @@ describe('agent runtime tool environment', () => {
);
expect(env.OD_PROJECT_ID).toBe('project-1');
expect(env.OD_PROJECT_DIR).toBe('/tmp/project');
expect(env.OD_HYPERFRAMES_BIN).toBe('/opt/open-design/hyperframes/bin/hyperframes.mjs');
});
it('keeps non-sandbox NO_PROXY behavior unchanged', () => {
@@ -270,6 +272,7 @@ describe('agent runtime tool environment', () => {
expect(prompt).toContain('Daemon URL: `http://127.0.0.1:7456`');
expect(prompt).toContain('`OD_DAEMON_URL`');
expect(prompt).toContain('`OD_NODE_BIN`');
expect(prompt).toContain('`OD_HYPERFRAMES_BIN`');
expect(prompt).toContain('`"$OD_NODE_BIN" "$OD_BIN" tools ...`');
expect(prompt).toContain('& $env:OD_NODE_BIN $env:OD_BIN tools ...');
expect(prompt).toContain('`OD_TOOL_TOKEN` is available');
@@ -112,6 +112,7 @@ describe('codex buildArgs session resume', () => {
);
expect(includeOnly).toContain('"PATH"');
expect(includeOnly).toContain('"OD_NODE_BIN"');
expect(includeOnly).toContain('"OD_HYPERFRAMES_BIN"');
expect(includeOnly).toContain('"OD_BIN"');
expect(includeOnly).toContain('"OD_DAEMON_URL"');
expect(includeOnly).toContain('"OD_TOOL_TOKEN"');
@@ -221,6 +221,8 @@ describe('server route inventory', () => {
'POST /api/orbit/run',
'POST /api/system/open-external',
'POST /api/dialog/open-folder',
'POST /api/projects/:id/media/hyperframes/scaffold',
'POST /api/tools/media/hyperframes/scaffold',
'POST /api/projects/:id/media/generate',
'POST /api/tools/media/generate',
'POST /api/research/search',
+17 -15
View File
@@ -44,8 +44,8 @@ clutter the user's FileViewer or the chat's "produced files" chips.
**Render workflow inside OD — fast path**:
For most OD requests ("test video", "5s product reveal", "demo clip"),
do NOT write the composition HTML from scratch. Use HyperFrames'
built-in scaffold and edit only what the prompt actually changes. The
do NOT write the composition HTML from scratch. Use Open Design's
deterministic scaffold and edit only what the prompt actually changes. The
"author from scratch" path costs minutes of model output and silent
chat-tool time; the scaffold path costs seconds.
@@ -57,9 +57,11 @@ COMP="$OD_PROJECT_DIR/$COMP_REL"
# 2. Get an immediately-renderable scaffold (hyperframes.json,
# meta.json, index.html with GSAP CDN + window.__timelines.main
# already registered). This runs in your shell — pure file copy,
# no Chrome, no network beyond the npx cache.
npx hyperframes init "$COMP" --example blank --skip-skills --non-interactive
# already registered). Open Design writes these files itself; it does
# not run `hyperframes init`, consult an npx cache, or install global skills.
"$OD_NODE_BIN" "$OD_BIN" media scaffold \
--project "$OD_PROJECT_ID" \
--composition-dir "$COMP_REL"
# 3. Edit ONLY $COMP/index.html — change `data-duration` on the root
# if you need a non-default length, swap the placeholder palette
@@ -68,8 +70,8 @@ npx hyperframes init "$COMP" --example blank --skip-skills --non-interactive
# `window.__timelines["main"] = gsap.timeline({paused:true})` block.
# Keep edits minimal; the scaffold is already valid HF.
# 4. Dispatch render through the OD daemon. Do NOT run `npx hyperframes
# render` from this shell — the daemon runs it for you in an
# 4. Dispatch render through the OD daemon. Do NOT run HyperFrames
# `render` from this shell — the daemon runs it for you in an
# unsandboxed process. (Many agent CLIs, Claude Code in particular,
# wrap Bash in macOS sandbox-exec under which puppeteer's Chrome
# subprocess hangs partway through frame capture. The daemon process
@@ -122,15 +124,15 @@ init + edit is the default path.
The lighter HF subcommands you CAN still run from your own shell
(they don't need to spawn Chrome):
- `npx hyperframes lint "$COMP"` — validate composition before dispatch
- `npx hyperframes transcribe <audio>` — generate captions
- `npx hyperframes tts <text>` — generate narration
- `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" lint "$COMP"` — validate composition before dispatch
- `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe <audio>` — generate captions
- `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts <text>` — generate narration
Reserve the daemon dispatch for `render`/`inspect`/`preview` (anything
Chrome-bound). After authoring the composition under `.hyperframes-cache/`,
render it by calling `"$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model hyperframes-html --composition-dir <rel>`.
The daemon runs the Chrome-bound HyperFrames render outside your shell
sandbox and streams progress back to you. Do not run `npx hyperframes render`
sandbox and streams progress back to you. Do not run HyperFrames `render`
yourself.
**Do NOT** drop `hyperframes.json` / `meta.json` / `index.html` in the
@@ -411,8 +413,8 @@ When no `visual-style.md` or animation direction is provided, follow [house-styl
## Output Checklist
- [ ] `npx hyperframes lint` and `npx hyperframes validate` both pass
- [ ] `npx hyperframes inspect` passes, or every reported overflow is intentionally marked
- [ ] `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" lint` and `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" validate` both pass
- [ ] `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" inspect` passes, or every reported overflow is intentionally marked
- [ ] Contrast warnings addressed (see Quality Checks below)
- [ ] Layout issues addressed (see Quality Checks below)
- [ ] Animation choreography verified (see Quality Checks below)
@@ -424,8 +426,8 @@ When no `visual-style.md` or animation direction is provided, follow [house-styl
`hyperframes inspect` runs the composition in headless Chrome, seeks through the timeline, and maps visual layout issues with timestamps, selectors, bounding boxes, and fix hints. Run it after `lint` and `validate`:
```bash
npx hyperframes inspect
npx hyperframes inspect --json
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" inspect
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" inspect --json
```
Failures usually mean text is spilling out of a bubble/card, a fixed-size label is clipping dynamic copy, or text has moved off the canvas. Fix by increasing container size or padding, reducing font size or letter spacing, adding a real `max-width` so text wraps inside the container, or using `window.__hyperframes.fitTextFontSize(...)` for dynamic copy.
@@ -12,7 +12,7 @@ The HTML-in-Canvas API (`drawElementImage`) lets you capture live, rendered DOM
>
> HyperFrames enables this flag automatically during rendering (`--enable-features=CanvasDrawElement`), so rendered videos work without manual setup. The flag is only needed for live preview in the Studio.
When this skill runs inside OpenDesign, the daemon shells out to `npx hyperframes render`, which inherits the auto-enable. You do **not** need to add browser flags or pass extra CLI args from the agent.
When this skill runs inside OpenDesign, the daemon invokes its pinned HyperFrames runtime, which inherits the auto-enable. You do **not** need to add browser flags or pass extra CLI args from the agent.
## How it works
@@ -118,29 +118,34 @@ When a HyperFrames timeline drives the underlying HTML (counter ticks, scroll an
Install all HTML-in-Canvas blocks at once:
```bash
npx hyperframes add html-in-canvas
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add html-in-canvas
```
Or install individually:
| Block | Description | Install |
|-------|-------------|---------|
| Liquid Glass | Voronoi glass fracture with parallax reveal | `npx hyperframes add vfx-liquid-glass` |
| iPhone & MacBook | Real 3D GLTF devices with live HTML screens | `npx hyperframes add vfx-iphone-device` |
| Text Cursor | Dramatic text reveal with chromatic shadows | `npx hyperframes add vfx-text-cursor` |
| Portal | Dimension breach with volumetric light | `npx hyperframes add vfx-portal` |
| Shatter | HTML shatters into glass fragments | `npx hyperframes add vfx-shatter` |
| Magnetic | Magnetic field particle visualization | `npx hyperframes add vfx-magnetic` |
| Liquid Background | Organic liquid simulation | `npx hyperframes add vfx-liquid-background` |
| Liquid Glass | Voronoi glass fracture with parallax reveal | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-liquid-glass` |
| iPhone & MacBook | Real 3D GLTF devices with live HTML screens | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-iphone-device` |
| Text Cursor | Dramatic text reveal with chromatic shadows | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-text-cursor` |
| Portal | Dimension breach with volumetric light | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-portal` |
| Shatter | HTML shatters into glass fragments | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-shatter` |
| Magnetic | Magnetic field particle visualization | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-magnetic` |
| Liquid Background | Organic liquid simulation | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-liquid-background` |
Block reference pages live at `https://hyperframes.heygen.com/catalog/blocks/<name>`.
## Rendering
HyperFrames enables the Chrome flag automatically during rendering. No special configuration needed:
HyperFrames enables the Chrome flag automatically during rendering. Inside Open Design, dispatch through the daemon rather than running the Chrome-bound CLI from the agent shell:
```bash
npx hyperframes render --output my-video.mp4
"$OD_NODE_BIN" "$OD_BIN" media generate \
--project "$OD_PROJECT_ID" \
--surface video \
--model hyperframes-html \
--composition-dir "$COMP_REL" \
--output my-video.mp4
```
For Docker renders, the flag is also enabled automatically inside the container. Inside OpenDesign, the daemon's `npx hyperframes render` call (`apps/daemon/src/media/index.ts`) inherits the same default — you don't need to thread anything through.
For Docker renders, the flag is also enabled automatically inside the container. OpenDesign's daemon-managed render inherits the same default — you don't need to thread anything through.
@@ -6,18 +6,18 @@
```bash
# Transcribe audio/video (uses whisper.cpp locally, no API key needed)
npx hyperframes transcribe audio.mp3
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3
# Use a larger model for better accuracy
npx hyperframes transcribe audio.mp3 --model medium.en
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --model medium.en
# Filter to English only (skips non-English speech)
npx hyperframes transcribe audio.mp3 --language en
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --language en
# Import an existing transcript from another tool
npx hyperframes transcribe captions.srt
npx hyperframes transcribe captions.vtt
npx hyperframes transcribe openai-response.json
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe captions.srt
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe captions.vtt
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe openai-response.json
```
## Supported Input Formats
@@ -52,10 +52,10 @@ The default model (`small.en`) balances accuracy and speed. For better results,
```bash
# Spanish audio
npx hyperframes transcribe audio.mp3 --model small --language es
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --model small --language es
# Unknown language — let whisper auto-detect
npx hyperframes transcribe audio.mp3 --model small
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --model small
```
**Music and vocals over instrumentation**: `small.en` will misidentify lyrics — use `medium.en` as the minimum, or import lyrics manually. Even `medium.en` struggles with heavily produced tracks; for music videos, providing known lyrics as an SRT/VTT and importing with `hyperframes transcribe lyrics.srt` will always beat automated transcription.
@@ -80,7 +80,7 @@ After every transcription, **read the transcript and check for quality issues be
1. **Retry with `medium.en`** if the original used `small.en` or smaller:
```bash
npx hyperframes transcribe audio.mp3 --model medium.en
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --model medium.en
```
2. **If `medium.en` also fails** (still >20% music tokens or garbled), tell the user the audio is too noisy for local transcription and suggest:
- Providing lyrics manually as an SRT/VTT file
@@ -123,7 +123,7 @@ curl https://api.openai.com/v1/audio/transcriptions \
-F "timestamp_granularities[]=word" \
-o transcript-openai.json
npx hyperframes transcribe transcript-openai.json
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe transcript-openai.json
```
**Groq Whisper API** (fast, free tier available):
@@ -136,7 +136,7 @@ curl https://api.groq.com/openai/v1/audio/transcriptions \
-F "timestamp_granularities[]=word" \
-o transcript-groq.json
npx hyperframes transcribe transcript-groq.json
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe transcript-groq.json
```
## If No Transcript Exists
@@ -146,6 +146,6 @@ npx hyperframes transcribe transcript-groq.json
- Speech/voiceover → `small.en`
- Music with vocals → `medium.en`
```bash
npx hyperframes transcribe <audio-or-video-file> --model medium.en
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe <audio-or-video-file> --model medium.en
```
3. **Read the transcript and run the quality check** (see above). If it fails, retry with a larger model or suggest manual lyrics.
@@ -14,21 +14,21 @@ Match voice to content. Default is `af_heart`.
| Documentation | `bf_emma`/`bm_george` | Clear British English |
| Casual | `af_heart`/`af_sky` | Approachable, natural |
Run `npx hyperframes tts --list` for all 54 voices (8 languages).
Run `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts --list` for all 54 voices (8 languages).
## Multilingual Phonemization
Kokoro voice IDs encode language in the first letter: `a`=American English, `b`=British English, `e`=Spanish, `f`=French, `h`=Hindi, `i`=Italian, `j`=Japanese, `p`=Brazilian Portuguese, `z`=Mandarin. The CLI auto-detects the phonemizer locale from that prefix — you don't need to pass `--lang` when the voice matches the text.
```bash
npx hyperframes tts "La reunión empieza a las nueve" --voice ef_dora --output es.wav
npx hyperframes tts "今日はいい天気ですね" --voice jf_alpha --output ja.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts "La reunión empieza a las nueve" --voice ef_dora --output es.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts "今日はいい天気ですね" --voice jf_alpha --output ja.wav
```
Use `--lang` only to override auto-detection (e.g. stylized accents):
```bash
npx hyperframes tts "Hello there" --voice af_heart --lang fr-fr --output accented.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts "Hello there" --voice af_heart --lang fr-fr --output accented.wav
```
Valid `--lang` codes: `en-us`, `en-gb`, `es`, `fr-fr`, `hi`, `it`, `pt-br`, `ja`, `zh`.
@@ -45,8 +45,8 @@ Non-English phonemization requires `espeak-ng` installed system-wide (`brew inst
## Usage
```bash
npx hyperframes tts "Your script here" --voice af_nova --output narration.wav
npx hyperframes tts script.txt --voice bf_emma --output narration.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts "Your script here" --voice af_nova --output narration.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts script.txt --voice bf_emma --output narration.wav
```
In compositions:
@@ -65,8 +65,8 @@ In compositions:
## TTS + Captions Workflow
```bash
npx hyperframes tts script.txt --voice af_heart --output narration.wav
npx hyperframes transcribe narration.wav # → transcript.json with word-level timestamps
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts script.txt --voice af_heart --output narration.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe narration.wav # → transcript.json with word-level timestamps
```
## Requirements
+2 -2
View File
@@ -9,6 +9,6 @@
# 1. Temporarily set the consuming `hash = lib.fakeHash;`
# 2. Run the relevant nix build/flake check
# 3. Copy the expected hash printed by Nix into the matching field below
daemonHash = "sha256-3hVB93psdqqEu7mRJeBKKDJ+NcS4Exq+8oJVmTLU0DY=";
webHash = "sha256-O/RJQY/wkUAmXQDrO8VEeKop+ePcs3WIbWe7nRm6PYM=";
daemonHash = "sha256-ujXjphrP9TvyLvIFaiplOfWMhQEbMsy74KkCmqALkus=";
webHash = "sha256-OfTksoOcxLu6OairFxQEYLQomj1JFCDEA3maKiH+9bQ=";
}
+1
View File
@@ -65,6 +65,7 @@
"electron",
"electron-winstaller",
"esbuild",
"onnxruntime-node",
"protobufjs",
"sharp"
]
+12
View File
@@ -81,3 +81,15 @@ export function mediaExecutionPolicyDenial(
}
return null;
}
/** Request for Open Design's deterministic local HyperFrames scaffold. */
export interface HyperFramesScaffoldRequest {
/** Project-relative path in the form `.hyperframes-cache/<id>`. */
compositionDir: string;
}
/** Files created before the agent authors the composition HTML. */
export interface HyperFramesScaffoldResponse {
compositionDir: string;
files: ['hyperframes.json', 'meta.json', 'index.html'];
}
+17 -15
View File
@@ -44,8 +44,8 @@ clutter the user's FileViewer or the chat's "produced files" chips.
**Render workflow inside OD — fast path**:
For most OD requests ("test video", "5s product reveal", "demo clip"),
do NOT write the composition HTML from scratch. Use HyperFrames'
built-in scaffold and edit only what the prompt actually changes. The
do NOT write the composition HTML from scratch. Use Open Design's
deterministic scaffold and edit only what the prompt actually changes. The
"author from scratch" path costs minutes of model output and silent
chat-tool time; the scaffold path costs seconds.
@@ -57,9 +57,11 @@ COMP="$OD_PROJECT_DIR/$COMP_REL"
# 2. Get an immediately-renderable scaffold (hyperframes.json,
# meta.json, index.html with GSAP CDN + window.__timelines.main
# already registered). This runs in your shell — pure file copy,
# no Chrome, no network beyond the npx cache.
npx hyperframes init "$COMP" --example blank --skip-skills --non-interactive
# already registered). Open Design writes these files itself; it does
# not run `hyperframes init`, consult an npx cache, or install global skills.
"$OD_NODE_BIN" "$OD_BIN" media scaffold \
--project "$OD_PROJECT_ID" \
--composition-dir "$COMP_REL"
# 3. Edit ONLY $COMP/index.html — change `data-duration` on the root
# if you need a non-default length, swap the placeholder palette
@@ -68,8 +70,8 @@ npx hyperframes init "$COMP" --example blank --skip-skills --non-interactive
# `window.__timelines["main"] = gsap.timeline({paused:true})` block.
# Keep edits minimal; the scaffold is already valid HF.
# 4. Dispatch render through the OD daemon. Do NOT run `npx hyperframes
# render` from this shell — the daemon runs it for you in an
# 4. Dispatch render through the OD daemon. Do NOT run HyperFrames
# `render` from this shell — the daemon runs it for you in an
# unsandboxed process. (Many agent CLIs, Claude Code in particular,
# wrap Bash in macOS sandbox-exec under which puppeteer's Chrome
# subprocess hangs partway through frame capture. The daemon process
@@ -122,15 +124,15 @@ init + edit is the default path.
The lighter HF subcommands you CAN still run from your own shell
(they don't need to spawn Chrome):
- `npx hyperframes lint "$COMP"` — validate composition before dispatch
- `npx hyperframes transcribe <audio>` — generate captions
- `npx hyperframes tts <text>` — generate narration
- `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" lint "$COMP"` — validate composition before dispatch
- `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe <audio>` — generate captions
- `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts <text>` — generate narration
Reserve the daemon dispatch for `render`/`inspect`/`preview` (anything
Chrome-bound). After authoring the composition under `.hyperframes-cache/`,
render it by calling `"$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model hyperframes-html --composition-dir <rel>`.
The daemon runs the Chrome-bound HyperFrames render outside your shell
sandbox and streams progress back to you. Do not run `npx hyperframes render`
sandbox and streams progress back to you. Do not run HyperFrames `render`
yourself.
**Do NOT** drop `hyperframes.json` / `meta.json` / `index.html` in the
@@ -411,8 +413,8 @@ When no `visual-style.md` or animation direction is provided, follow [house-styl
## Output Checklist
- [ ] `npx hyperframes lint` and `npx hyperframes validate` both pass
- [ ] `npx hyperframes inspect` passes, or every reported overflow is intentionally marked
- [ ] `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" lint` and `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" validate` both pass
- [ ] `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" inspect` passes, or every reported overflow is intentionally marked
- [ ] Contrast warnings addressed (see Quality Checks below)
- [ ] Layout issues addressed (see Quality Checks below)
- [ ] Animation choreography verified (see Quality Checks below)
@@ -424,8 +426,8 @@ When no `visual-style.md` or animation direction is provided, follow [house-styl
`hyperframes inspect` runs the composition in headless Chrome, seeks through the timeline, and maps visual layout issues with timestamps, selectors, bounding boxes, and fix hints. Run it after `lint` and `validate`:
```bash
npx hyperframes inspect
npx hyperframes inspect --json
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" inspect
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" inspect --json
```
Failures usually mean text is spilling out of a bubble/card, a fixed-size label is clipping dynamic copy, or text has moved off the canvas. Fix by increasing container size or padding, reducing font size or letter spacing, adding a real `max-width` so text wraps inside the container, or using `window.__hyperframes.fitTextFontSize(...)` for dynamic copy.
@@ -12,7 +12,7 @@ The HTML-in-Canvas API (`drawElementImage`) lets you capture live, rendered DOM
>
> HyperFrames enables this flag automatically during rendering (`--enable-features=CanvasDrawElement`), so rendered videos work without manual setup. The flag is only needed for live preview in the Studio.
When this skill runs inside OpenDesign, the daemon shells out to `npx hyperframes render`, which inherits the auto-enable. You do **not** need to add browser flags or pass extra CLI args from the agent.
When this skill runs inside OpenDesign, the daemon invokes its pinned HyperFrames runtime, which inherits the auto-enable. You do **not** need to add browser flags or pass extra CLI args from the agent.
## How it works
@@ -118,29 +118,34 @@ When a HyperFrames timeline drives the underlying HTML (counter ticks, scroll an
Install all HTML-in-Canvas blocks at once:
```bash
npx hyperframes add html-in-canvas
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add html-in-canvas
```
Or install individually:
| Block | Description | Install |
|-------|-------------|---------|
| Liquid Glass | Voronoi glass fracture with parallax reveal | `npx hyperframes add vfx-liquid-glass` |
| iPhone & MacBook | Real 3D GLTF devices with live HTML screens | `npx hyperframes add vfx-iphone-device` |
| Text Cursor | Dramatic text reveal with chromatic shadows | `npx hyperframes add vfx-text-cursor` |
| Portal | Dimension breach with volumetric light | `npx hyperframes add vfx-portal` |
| Shatter | HTML shatters into glass fragments | `npx hyperframes add vfx-shatter` |
| Magnetic | Magnetic field particle visualization | `npx hyperframes add vfx-magnetic` |
| Liquid Background | Organic liquid simulation | `npx hyperframes add vfx-liquid-background` |
| Liquid Glass | Voronoi glass fracture with parallax reveal | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-liquid-glass` |
| iPhone & MacBook | Real 3D GLTF devices with live HTML screens | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-iphone-device` |
| Text Cursor | Dramatic text reveal with chromatic shadows | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-text-cursor` |
| Portal | Dimension breach with volumetric light | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-portal` |
| Shatter | HTML shatters into glass fragments | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-shatter` |
| Magnetic | Magnetic field particle visualization | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-magnetic` |
| Liquid Background | Organic liquid simulation | `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" add vfx-liquid-background` |
Block reference pages live at `https://hyperframes.heygen.com/catalog/blocks/<name>`.
## Rendering
HyperFrames enables the Chrome flag automatically during rendering. No special configuration needed:
HyperFrames enables the Chrome flag automatically during rendering. Inside Open Design, dispatch through the daemon rather than running the Chrome-bound CLI from the agent shell:
```bash
npx hyperframes render --output my-video.mp4
"$OD_NODE_BIN" "$OD_BIN" media generate \
--project "$OD_PROJECT_ID" \
--surface video \
--model hyperframes-html \
--composition-dir "$COMP_REL" \
--output my-video.mp4
```
For Docker renders, the flag is also enabled automatically inside the container. Inside OpenDesign, the daemon's `npx hyperframes render` call (`apps/daemon/src/media/index.ts`) inherits the same default — you don't need to thread anything through.
For Docker renders, the flag is also enabled automatically inside the container. OpenDesign's daemon-managed render inherits the same default — you don't need to thread anything through.
@@ -6,18 +6,18 @@
```bash
# Transcribe audio/video (uses whisper.cpp locally, no API key needed)
npx hyperframes transcribe audio.mp3
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3
# Use a larger model for better accuracy
npx hyperframes transcribe audio.mp3 --model medium.en
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --model medium.en
# Filter to English only (skips non-English speech)
npx hyperframes transcribe audio.mp3 --language en
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --language en
# Import an existing transcript from another tool
npx hyperframes transcribe captions.srt
npx hyperframes transcribe captions.vtt
npx hyperframes transcribe openai-response.json
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe captions.srt
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe captions.vtt
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe openai-response.json
```
## Supported Input Formats
@@ -52,10 +52,10 @@ The default model (`small.en`) balances accuracy and speed. For better results,
```bash
# Spanish audio
npx hyperframes transcribe audio.mp3 --model small --language es
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --model small --language es
# Unknown language — let whisper auto-detect
npx hyperframes transcribe audio.mp3 --model small
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --model small
```
**Music and vocals over instrumentation**: `small.en` will misidentify lyrics — use `medium.en` as the minimum, or import lyrics manually. Even `medium.en` struggles with heavily produced tracks; for music videos, providing known lyrics as an SRT/VTT and importing with `hyperframes transcribe lyrics.srt` will always beat automated transcription.
@@ -80,7 +80,7 @@ After every transcription, **read the transcript and check for quality issues be
1. **Retry with `medium.en`** if the original used `small.en` or smaller:
```bash
npx hyperframes transcribe audio.mp3 --model medium.en
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe audio.mp3 --model medium.en
```
2. **If `medium.en` also fails** (still >20% music tokens or garbled), tell the user the audio is too noisy for local transcription and suggest:
- Providing lyrics manually as an SRT/VTT file
@@ -123,7 +123,7 @@ curl https://api.openai.com/v1/audio/transcriptions \
-F "timestamp_granularities[]=word" \
-o transcript-openai.json
npx hyperframes transcribe transcript-openai.json
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe transcript-openai.json
```
**Groq Whisper API** (fast, free tier available):
@@ -136,7 +136,7 @@ curl https://api.groq.com/openai/v1/audio/transcriptions \
-F "timestamp_granularities[]=word" \
-o transcript-groq.json
npx hyperframes transcribe transcript-groq.json
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe transcript-groq.json
```
## If No Transcript Exists
@@ -146,6 +146,6 @@ npx hyperframes transcribe transcript-groq.json
- Speech/voiceover → `small.en`
- Music with vocals → `medium.en`
```bash
npx hyperframes transcribe <audio-or-video-file> --model medium.en
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe <audio-or-video-file> --model medium.en
```
3. **Read the transcript and run the quality check** (see above). If it fails, retry with a larger model or suggest manual lyrics.
@@ -14,21 +14,21 @@ Match voice to content. Default is `af_heart`.
| Documentation | `bf_emma`/`bm_george` | Clear British English |
| Casual | `af_heart`/`af_sky` | Approachable, natural |
Run `npx hyperframes tts --list` for all 54 voices (8 languages).
Run `"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts --list` for all 54 voices (8 languages).
## Multilingual Phonemization
Kokoro voice IDs encode language in the first letter: `a`=American English, `b`=British English, `e`=Spanish, `f`=French, `h`=Hindi, `i`=Italian, `j`=Japanese, `p`=Brazilian Portuguese, `z`=Mandarin. The CLI auto-detects the phonemizer locale from that prefix — you don't need to pass `--lang` when the voice matches the text.
```bash
npx hyperframes tts "La reunión empieza a las nueve" --voice ef_dora --output es.wav
npx hyperframes tts "今日はいい天気ですね" --voice jf_alpha --output ja.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts "La reunión empieza a las nueve" --voice ef_dora --output es.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts "今日はいい天気ですね" --voice jf_alpha --output ja.wav
```
Use `--lang` only to override auto-detection (e.g. stylized accents):
```bash
npx hyperframes tts "Hello there" --voice af_heart --lang fr-fr --output accented.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts "Hello there" --voice af_heart --lang fr-fr --output accented.wav
```
Valid `--lang` codes: `en-us`, `en-gb`, `es`, `fr-fr`, `hi`, `it`, `pt-br`, `ja`, `zh`.
@@ -45,8 +45,8 @@ Non-English phonemization requires `espeak-ng` installed system-wide (`brew inst
## Usage
```bash
npx hyperframes tts "Your script here" --voice af_nova --output narration.wav
npx hyperframes tts script.txt --voice bf_emma --output narration.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts "Your script here" --voice af_nova --output narration.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts script.txt --voice bf_emma --output narration.wav
```
In compositions:
@@ -65,8 +65,8 @@ In compositions:
## TTS + Captions Workflow
```bash
npx hyperframes tts script.txt --voice af_heart --output narration.wav
npx hyperframes transcribe narration.wav # → transcript.json with word-level timestamps
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" tts script.txt --voice af_heart --output narration.wav
"$OD_NODE_BIN" "$OD_HYPERFRAMES_BIN" transcribe narration.wav # → transcript.json with word-level timestamps
```
## Requirements
+1163 -30
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -22,5 +22,6 @@ onlyBuiltDependencies:
- electron
- electron-winstaller
- esbuild
- onnxruntime-node
- protobufjs
- sharp
@@ -106,6 +106,7 @@ async function readHookConfig() {
return {
auditReportPath,
hyperframesRuntimeSourceRoot: requireAbsolutePath(raw, "hyperframesRuntimeSourceRoot"),
macAdhocBundleSign: optionalBoolean(raw, "macAdhocBundleSign", false),
pruneCopiedSharp: requireBoolean(raw, "pruneCopiedSharp"),
pruneRootNext: requireBoolean(raw, "pruneRootNext"),
@@ -829,6 +830,79 @@ async function pruneRootSharp(appNodeModulesRoot) {
return removedPaths;
}
function resolveHyperframesRuntimePackageNames(platformName) {
const arch = platformName === "win32" ? "x64" : process.arch;
if (arch !== "arm64" && arch !== "x64") {
throw new Error(`[tools-pack hyperframes] unsupported ${platformName} architecture: ${arch}`);
}
const platform = platformName === "darwin" ? "darwin" : "win32";
return [
"sharp",
"@img/colour",
`@img/sharp-${platform}-${arch}`,
`@img/sharp-libvips-${platform}-${arch}`,
];
}
function packagePath(nodeModulesRoot, packageName) {
return path.join(nodeModulesRoot, ...packageName.split("/"));
}
async function copyHyperframesRuntimeDependencies(config, appNodeModulesRoot, platformName) {
const sourceNodeModulesRoot = path.join(config.hyperframesRuntimeSourceRoot, "node_modules");
const copied = [];
for (const packageName of resolveHyperframesRuntimePackageNames(platformName)) {
const sourcePath = packagePath(sourceNodeModulesRoot, packageName);
const destinationPath = packagePath(appNodeModulesRoot, packageName);
await copyRequired(sourcePath, destinationPath, { dereference: true });
copied.push({
bytes: await sizePathBytes(destinationPath),
destinationPath,
packageName,
sourcePath,
});
}
return copied;
}
async function smokeHyperframesCli(
appPath,
appNodeModulesRoot,
platformName,
productFilename,
copiedPackages,
) {
const cliPath = path.join(appNodeModulesRoot, "hyperframes", "dist", "cli.js");
const nodePath = platformName === "win32"
? path.join(appPath, `${productFilename}.exe`)
: path.join(appPath, "Contents", "MacOS", productFilename);
const requiredPaths = [
cliPath,
nodePath,
...copiedPackages.map((entry) => path.join(entry.destinationPath, "package.json")),
];
for (const requiredPath of requiredPaths) {
if (!(await pathExists(requiredPath))) {
throw new Error(`[tools-pack hyperframes] final packaged runtime is missing: ${requiredPath}`);
}
}
const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_PATH: "" };
delete env.NODE_OPTIONS;
const result = await execFileAsync(nodePath, [cliPath, "--version"], {
cwd: appPath,
env,
maxBuffer: 20 * 1024 * 1024,
timeout: 30_000,
});
return {
cliPath,
nodePath,
stderr: result.stderr.trim(),
stdout: result.stdout.trim(),
};
}
async function pruneRootWebPackage(appNodeModulesRoot, platformName) {
if (platformName !== "win32") return [];
@@ -910,6 +984,11 @@ async function runWebStandaloneAfterPack(context) {
const copiedAudit = await auditCopiedStandalone(config, installResult, context.electronPlatformName);
const rootPrune = config.pruneRootNext ? await pruneRootNext(appNodeModulesRoot, context.electronPlatformName) : [];
const rootSharpPrune = config.pruneRootSharp ? await pruneRootSharp(appNodeModulesRoot) : [];
const hyperframesRuntimeCopies = await copyHyperframesRuntimeDependencies(
config,
appNodeModulesRoot,
context.electronPlatformName,
);
const rootWebPackagePrune = await pruneRootWebPackage(appNodeModulesRoot, context.electronPlatformName);
const rootBuildResiduePrune = context.electronPlatformName === "win32"
? await pruneSourceBuildResidue(appNodeModulesRoot, "root app source/build residue")
@@ -932,6 +1011,13 @@ async function runWebStandaloneAfterPack(context) {
appNodeModulesRoot,
"root app node_modules",
);
const hyperframesCliSmoke = await smokeHyperframesCli(
appPath,
appNodeModulesRoot,
context.electronPlatformName,
context.packager.appInfo.productFilename,
hyperframesRuntimeCopies,
);
const macAdhocBundleSign = context.electronPlatformName === "darwin" && config.macAdhocBundleSign
? await signMacAdhocBundle(appPath)
: [];
@@ -944,6 +1030,8 @@ async function runWebStandaloneAfterPack(context) {
copiedNextDedupeAudit,
copiedPrune,
generatedAt: new Date().toISOString(),
hyperframesCliSmoke,
hyperframesRuntimeCopies,
macAdhocBundleSign,
platformName: context.electronPlatformName,
resourcesRoot,
+6 -2
View File
@@ -26,7 +26,9 @@ export const MAC_PREBUNDLE_ENTRYPOINTS_DIR_NAME = "prebundle-entrypoints";
export const MAC_PREBUNDLE_RUNTIME_DEPENDENCIES = {
"better-sqlite3": "12.10.0",
"blake3-wasm": "2.1.5",
"hyperframes": "0.8.1",
"node-pty": "1.1.0",
"sharp": "0.35.3",
} as const;
// npm 11 synthesizes a `node-gyp rebuild` install step for fsevents even
@@ -61,13 +63,14 @@ export const MAC_PREBUNDLE_POLICIES = {
label: "packaged main",
},
daemonCli: {
externals: ["better-sqlite3", "blake3-wasm", "fsevents", "node-pty"],
externals: ["better-sqlite3", "blake3-wasm", "fsevents", "hyperframes", "node-pty"],
forbiddenInputs: [
"/node_modules/@open-design/daemon/",
"/node_modules/better-sqlite3/",
"/node_modules/blake3-wasm/",
"/node_modules/electron/",
"/node_modules/fsevents/",
"/node_modules/hyperframes/",
"/node_modules/next/",
"/node_modules/node-pty/",
"/node_modules/openai/",
@@ -77,13 +80,14 @@ export const MAC_PREBUNDLE_POLICIES = {
label: "daemon cli",
},
daemonSidecar: {
externals: ["better-sqlite3", "blake3-wasm", "fsevents", "node-pty"],
externals: ["better-sqlite3", "blake3-wasm", "fsevents", "hyperframes", "node-pty"],
forbiddenInputs: [
"/node_modules/@open-design/daemon/",
"/node_modules/better-sqlite3/",
"/node_modules/blake3-wasm/",
"/node_modules/electron/",
"/node_modules/fsevents/",
"/node_modules/hyperframes/",
"/node_modules/next/",
"/node_modules/node-pty/",
"/node_modules/openai/",
+1
View File
@@ -49,6 +49,7 @@ async function writeWebStandaloneHookConfig(config: ToolPackConfig, paths: MacPa
`${JSON.stringify(
{
auditReportPath: paths.webStandaloneHookAuditPath,
hyperframesRuntimeSourceRoot: paths.assembledAppRoot,
pruneCopiedSharp: true,
pruneRootNext: true,
pruneRootSharp: true,
+6 -2
View File
@@ -25,7 +25,9 @@ export const WIN_PREBUNDLE_ENTRYPOINTS_DIR_NAME = "prebundle-entrypoints";
export const WIN_PREBUNDLE_RUNTIME_DEPENDENCIES = {
"better-sqlite3": "12.10.0",
"blake3-wasm": "2.1.5",
"hyperframes": "0.8.1",
"node-pty": "1.1.0",
"sharp": "0.35.3",
} as const;
export const WIN_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [
@@ -52,12 +54,13 @@ export const WIN_PREBUNDLE_POLICIES = {
label: "packaged main",
},
daemonCli: {
externals: ["better-sqlite3", "blake3-wasm", "node-pty"],
externals: ["better-sqlite3", "blake3-wasm", "hyperframes", "node-pty"],
forbiddenInputs: [
"/node_modules/@open-design/daemon/",
"/node_modules/better-sqlite3/",
"/node_modules/blake3-wasm/",
"/node_modules/electron/",
"/node_modules/hyperframes/",
"/node_modules/next/",
"/node_modules/node-pty/",
"/node_modules/openai/",
@@ -67,12 +70,13 @@ export const WIN_PREBUNDLE_POLICIES = {
label: "daemon cli",
},
daemonSidecar: {
externals: ["better-sqlite3", "blake3-wasm", "node-pty"],
externals: ["better-sqlite3", "blake3-wasm", "hyperframes", "node-pty"],
forbiddenInputs: [
"/node_modules/@open-design/daemon/",
"/node_modules/better-sqlite3/",
"/node_modules/blake3-wasm/",
"/node_modules/electron/",
"/node_modules/hyperframes/",
"/node_modules/next/",
"/node_modules/node-pty/",
"/node_modules/openai/",
+3 -1
View File
@@ -419,6 +419,7 @@ export async function createWinPackagedAppCacheKey(
config: ToolPackConfig,
tarballsKey: string,
packedTarballs: PackedTarballInfo[],
runtimeDependencies: Readonly<Record<string, string>> = WIN_PREBUNDLE_RUNTIME_DEPENDENCIES,
): Promise<string> {
return hashJson({
arch: "x64",
@@ -428,7 +429,8 @@ export async function createWinPackagedAppCacheKey(
packedTarballs,
platform: "win32",
prebundle: shouldUseWinStandalonePrebundle(config.webOutputMode),
schemaVersion: 3,
runtimeDependencies: shouldUseWinStandalonePrebundle(config.webOutputMode) ? runtimeDependencies : null,
schemaVersion: 4,
tarballsKey,
webOutputMode: config.webOutputMode,
});
+7 -2
View File
@@ -102,7 +102,11 @@ async function assertWebStandaloneOutput(config: ToolPackConfig): Promise<void>
throw new Error("Next.js standalone server output was not produced under apps/web/.next/standalone");
}
async function writeWebStandaloneHookConfig(config: ToolPackConfig, paths: WinPaths): Promise<string> {
async function writeWebStandaloneHookConfig(
config: ToolPackConfig,
paths: WinPaths,
hyperframesRuntimeSourceRoot: string,
): Promise<string> {
const webRoot = join(config.workspaceRoot, "apps", "web");
await assertWebStandaloneOutput(config);
@@ -112,6 +116,7 @@ async function writeWebStandaloneHookConfig(config: ToolPackConfig, paths: WinPa
`${JSON.stringify(
{
auditReportPath: paths.webStandaloneHookAuditPath,
hyperframesRuntimeSourceRoot,
pruneCopiedSharp: true,
pruneRootNext: true,
pruneRootSharp: true,
@@ -167,7 +172,7 @@ async function runElectronBuilderRaw(
const packageVersion = electronBuilderVersionForAppVersion(packagedVersion);
const webStandaloneHookConfigPath = config.webOutputMode === "standalone"
? await runSegment("electron-builder-raw:write-web-standalone-hook-config", async () =>
writeWebStandaloneHookConfig(config, paths)
writeWebStandaloneHookConfig(config, paths, projectDir)
)
: null;
const builderConfig = {
+4
View File
@@ -80,12 +80,14 @@ describe("mac standalone prebundle policy", () => {
"better-sqlite3",
"blake3-wasm",
"fsevents",
"hyperframes",
"node-pty",
]);
expect(MAC_PREBUNDLE_POLICIES.daemonSidecar.externals).toEqual([
"better-sqlite3",
"blake3-wasm",
"fsevents",
"hyperframes",
"node-pty",
]);
expect(MAC_PREBUNDLE_POLICIES.webSidecar.externals).toEqual([]);
@@ -96,7 +98,9 @@ describe("mac standalone prebundle policy", () => {
expect(MAC_PREBUNDLE_RUNTIME_DEPENDENCIES).toEqual({
"better-sqlite3": "12.10.0",
"blake3-wasm": "2.1.5",
"hyperframes": "0.8.1",
"node-pty": "1.1.0",
"sharp": "0.35.3",
});
expect(MAC_PREBUNDLE_COPIED_RUNTIME_DEPENDENCIES).toEqual({ "fsevents": "2.3.3" });
expect(MAC_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH).toBe("app/prebundled/daemon/daemon-cli.mjs");
@@ -1,4 +1,4 @@
import { access, chmod, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
import { access, chmod, copyFile, link, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path, { join } from "node:path";
@@ -30,6 +30,85 @@ async function writePackage(packageRoot: string, packageName: string): Promise<v
await writeFile(join(packageRoot, "index.js"), "module.exports = {};\n", "utf8");
}
async function writeEsmPackage(packageRoot: string, packageName: string, source: string): Promise<void> {
await mkdir(packageRoot, { recursive: true });
await writeFile(
join(packageRoot, "package.json"),
`${JSON.stringify({ exports: "./index.js", name: packageName, type: "module", version: "0.0.0" }, null, 2)}\n`,
"utf8",
);
await writeFile(join(packageRoot, "index.js"), source, "utf8");
}
async function writeHyperframesRuntimeFixture(options: {
omitPackage?: string;
platformName: "darwin" | "win32";
resourcesRoot: string;
runtimeSourceRoot: string;
}): Promise<void> {
const arch = options.platformName === "win32" ? "x64" : process.arch;
const platform = options.platformName === "darwin" ? "darwin" : "win32";
const nativePackage = `@img/sharp-${platform}-${arch}`;
const libvipsPackage = `@img/sharp-libvips-${platform}-${arch}`;
const packages = ["@img/colour", nativePackage, libvipsPackage];
const sourceNodeModulesRoot = join(options.runtimeSourceRoot, "node_modules");
for (const packageName of packages) {
if (packageName === options.omitPackage) continue;
await writeEsmPackage(
join(sourceNodeModulesRoot, ...packageName.split("/")),
packageName,
`export default ${JSON.stringify(packageName)};\n`,
);
}
if (options.omitPackage !== "sharp") {
await writeEsmPackage(
join(sourceNodeModulesRoot, "sharp"),
"sharp",
[
'import colour from "@img/colour";',
`import native from ${JSON.stringify(nativePackage)};`,
`import libvips from ${JSON.stringify(libvipsPackage)};`,
"export default { colour, native, libvips };",
"",
].join("\n"),
);
}
const hyperframesRoot = join(options.resourcesRoot, "app", "node_modules", "hyperframes");
await mkdir(join(hyperframesRoot, "dist"), { recursive: true });
await writeFile(
join(hyperframesRoot, "package.json"),
`${JSON.stringify({ name: "hyperframes", type: "module", version: "0.8.1" }, null, 2)}\n`,
"utf8",
);
await writeFile(
join(hyperframesRoot, "dist", "cli.js"),
[
'import sharp from "sharp";',
'if (sharp == null) throw new Error("sharp failed to load");',
'console.log("hyperframes 0.8.1");',
"",
].join("\n"),
"utf8",
);
const appPath = options.platformName === "darwin"
? path.dirname(path.dirname(options.resourcesRoot))
: path.dirname(options.resourcesRoot);
const bundledNodePath = options.platformName === "win32"
? join(appPath, "Open Design.exe")
: join(appPath, "Contents", "MacOS", "Open Design");
await mkdir(path.dirname(bundledNodePath), { recursive: true });
try {
await link(process.execPath, bundledNodePath);
} catch {
await copyFile(process.execPath, bundledNodePath);
// A successful hard link may share a CI-owned inode that the test user cannot chmod.
await chmod(bundledNodePath, 0o755);
}
}
async function writePnpmLinkedPackage(standaloneRoot: string, packageName: string): Promise<string> {
const packageDirectoryName = `${packageName}@0.0.0`.split("/").join("+");
const packageRoot = join(standaloneRoot, "node_modules", ".pnpm", packageDirectoryName, "node_modules", packageName);
@@ -90,6 +169,7 @@ async function runFixture(options: {
includeHoistedNext?: boolean;
includeWebNext: boolean;
macAdhocBundleSign?: boolean;
omitHyperframesRuntimePackage?: string;
omitMacAdhocBundleSign?: boolean;
omitRootWebPackage?: boolean;
platformName?: "darwin" | "win32";
@@ -117,6 +197,7 @@ async function runFixture(options: {
const appPath = join(appOutDir, "Open Design.app");
const auditReportPath = join(root, "audit.json");
const configPath = join(root, "config.json");
const hyperframesRuntimeSourceRoot = join(workspaceRoot, "assembled", "app");
const oldConfigEnv = process.env[CONFIG_ENV];
await mkdir(resourcesRoot, { recursive: true });
@@ -137,15 +218,22 @@ async function runFixture(options: {
if (options.omitRootWebPackage !== true) {
await writeRootWebPackage(resourcesRoot);
}
await writeHyperframesRuntimeFixture({
omitPackage: options.omitHyperframesRuntimePackage,
platformName,
resourcesRoot,
runtimeSourceRoot: hyperframesRuntimeSourceRoot,
});
await writeFile(
configPath,
`${JSON.stringify(
{
auditReportPath,
hyperframesRuntimeSourceRoot,
...(options.omitMacAdhocBundleSign ? {} : { macAdhocBundleSign: options.macAdhocBundleSign ?? false }),
pruneCopiedSharp: false,
pruneRootNext: false,
pruneRootSharp: false,
pruneRootSharp: true,
...(options.requireRootWebPackageAudit == null
? {}
: { requireRootWebPackageAudit: options.requireRootWebPackageAudit }),
@@ -189,6 +277,40 @@ async function runFixture(options: {
}
describe("web standalone afterPack hook", () => {
it("restores HyperFrames' native sharp closure and executes its CLI from the final payload", async () => {
const fixture = await runFixture({ includeWebNext: true });
try {
const report = JSON.parse(await readFile(fixture.auditReportPath, "utf8")) as {
hyperframesCliSmoke: { cliPath: string; nodePath: string; stdout: string };
hyperframesRuntimeCopies: Array<{ bytes: number; packageName: string }>;
};
expect(report.hyperframesRuntimeCopies.map((entry) => entry.packageName)).toEqual([
"sharp",
"@img/colour",
"@img/sharp-win32-x64",
"@img/sharp-libvips-win32-x64",
]);
expect(report.hyperframesRuntimeCopies.every((entry) => entry.bytes > 0)).toBe(true);
expect(report.hyperframesCliSmoke.stdout).toBe("hyperframes 0.8.1");
expect(report.hyperframesCliSmoke.cliPath.split(path.sep).join("/")).toMatch(
/resources\/app\/node_modules\/hyperframes\/dist\/cli\.js$/,
);
expect(report.hyperframesCliSmoke.nodePath.split(path.sep).join("/")).toMatch(
/win-unpacked\/Open Design\.exe$/,
);
} finally {
await rm(fixture.root, { force: true, recursive: true });
}
});
it("fails packaging when HyperFrames' target libvips payload is absent", async () => {
await expect(runFixture({
includeWebNext: true,
omitHyperframesRuntimePackage: "@img/sharp-libvips-win32-x64",
})).rejects.toThrow(/required source missing.*sharp-libvips-win32-x64/);
});
it("deduplicates win32 copied standalone Next while retaining the app-local Next package", async () => {
const fixture = await runFixture({ includeWebNext: true });
+55 -1
View File
@@ -6,7 +6,8 @@ import { describe, expect, it } from "vitest";
import type { ToolPackConfig } from "../src/config.js";
import { INTERNAL_PACKAGES } from "../src/win/constants.js";
import { createWorkspaceTarballsCacheKey } from "../src/win/app.js";
import { createWinPackagedAppCacheKey, createWorkspaceTarballsCacheKey } from "../src/win/app.js";
import type { PackedTarballInfo } from "../src/win/types.js";
const PACKAGE_DIRS = INTERNAL_PACKAGES.map((packageInfo) => packageInfo.directory);
@@ -150,3 +151,56 @@ describe("createWorkspaceTarballsCacheKey", () => {
}
});
});
describe("createWinPackagedAppCacheKey", () => {
const packedTarballs = [
{ fileName: "contracts.tgz", packageName: "@open-design/contracts" },
] satisfies PackedTarballInfo[];
it("covers every mutable packaged-app input", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-win-packaged-app-key-"));
try {
const config = createConfig(root, "standalone");
const baseline = await createWinPackagedAppCacheKey(config, "tarballs-a", packedTarballs);
await expect(createWinPackagedAppCacheKey(
{ ...config, electronVersion: "41.4.0" },
"tarballs-a",
packedTarballs,
)).resolves.not.toBe(baseline);
await expect(createWinPackagedAppCacheKey(config, "tarballs-b", packedTarballs)).resolves.not.toBe(baseline);
await expect(createWinPackagedAppCacheKey(config, "tarballs-a", [
...packedTarballs,
{ fileName: "platform.tgz", packageName: "@open-design/platform" },
])).resolves.not.toBe(baseline);
await expect(createWinPackagedAppCacheKey(config, "tarballs-a", packedTarballs, {
"hyperframes": "0.8.1",
"sharp": "0.35.4",
})).resolves.not.toBe(baseline);
await expect(createWinPackagedAppCacheKey(
createConfig(root, "server"),
"tarballs-a",
packedTarballs,
)).resolves.not.toBe(baseline);
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("ignores namespace because it is applied after the cached app is materialized", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-win-packaged-app-key-"));
try {
const config = createConfig(root, "standalone");
const baseline = await createWinPackagedAppCacheKey(config, "tarballs-a", packedTarballs);
await expect(createWinPackagedAppCacheKey(
{ ...config, namespace: "another-namespace" },
"tarballs-a",
packedTarballs,
)).resolves.toBe(baseline);
} finally {
await rm(root, { force: true, recursive: true });
}
});
});
+4
View File
@@ -76,11 +76,13 @@ describe("win standalone prebundle policy", () => {
expect(WIN_PREBUNDLE_POLICIES.daemonCli.externals).toEqual([
"better-sqlite3",
"blake3-wasm",
"hyperframes",
"node-pty",
]);
expect(WIN_PREBUNDLE_POLICIES.daemonSidecar.externals).toEqual([
"better-sqlite3",
"blake3-wasm",
"hyperframes",
"node-pty",
]);
expect(WIN_PREBUNDLE_POLICIES.webSidecar.externals).toEqual([]);
@@ -91,7 +93,9 @@ describe("win standalone prebundle policy", () => {
expect(WIN_PREBUNDLE_RUNTIME_DEPENDENCIES).toEqual({
"better-sqlite3": "12.10.0",
"blake3-wasm": "2.1.5",
"hyperframes": "0.8.1",
"node-pty": "1.1.0",
"sharp": "0.35.3",
});
expect(WIN_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH).toBe("app/prebundled/daemon/daemon-cli.mjs");
expect(WIN_PREBUNDLED_DAEMON_SIDECAR_RELATIVE_PATH).toBe("app/prebundled/daemon/daemon-sidecar.mjs");