Skip to content

Commit 0d53c96

Browse files
authored
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)
1 parent e711d11 commit 0d53c96

43 files changed

Lines changed: 2276 additions & 168 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/daemon/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"cheerio": "1.2.0",
5353
"chokidar": "3.6.0",
5454
"express": "5.2.1",
55+
"hyperframes": "0.8.1",
5556
"jszip": "3.10.1",
5657
"kiwi-schema": "0.5.0",
5758
"multer": "2.2.0",

apps/daemon/src/cli.ts

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ const MEDIA_GENERATE_BOOLEAN_FLAGS = new Set([
8888
'h',
8989
'loop',
9090
]);
91+
const MEDIA_SCAFFOLD_STRING_FLAGS = new Set([
92+
'project',
93+
'workspace',
94+
'workspace-member',
95+
'composition-dir',
96+
'daemon-url',
97+
]);
98+
const MEDIA_SCAFFOLD_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
9199

92100
const MCP_STRING_FLAGS = new Set([
93101
'daemon-url',
@@ -936,6 +944,10 @@ function printRootHelp() {
936944
Designed to be invoked by a code agent - picks up OD_DAEMON_URL
937945
and OD_PROJECT_ID from the env that the daemon injected on spawn.
938946
947+
od media scaffold --composition-dir .hyperframes-cache/<id>
948+
Create a deterministic HyperFrames composition without npx or global
949+
skill installation, before dispatching it through media generate.
950+
939951
od mcp [--daemon-url <url>]
940952
Run a stdio MCP server that proxies project tool calls to a
941953
running OpenDesign daemon. Wire it into a coding agent
@@ -954,8 +966,8 @@ What the daemon does:
954966
* scans PATH for installed code-agent CLIs (claude, codex, devin, opencode, cursor-agent, ...)
955967
* serves the chat UI at http://<host>:<port>
956968
* proxies messages (text + images) to the selected agent via child-process spawn
957-
* exposes /api/projects/:id/media/generate — the unified image/video/audio
958-
dispatcher that the agent calls via \`od media generate\`.`);
969+
* exposes project-scoped media scaffold/generate APIs — the unified path
970+
that the agent calls via \`od media scaffold\` and \`od media generate\`.`);
959971
}
960972

961973
// ---------------------------------------------------------------------------
@@ -1626,7 +1638,7 @@ async function runMedia(args) {
16261638
printMediaHelp();
16271639
return;
16281640
}
1629-
if (sub !== 'generate' && sub !== 'wait') {
1641+
if (sub !== 'generate' && sub !== 'wait' && sub !== 'scaffold') {
16301642
console.error(`unknown subcommand: od media ${sub}`);
16311643
printMediaHelp();
16321644
process.exit(1);
@@ -1635,9 +1647,66 @@ async function runMedia(args) {
16351647
const idx = args.indexOf(sub);
16361648
const subArgs = [...args.slice(0, idx), ...args.slice(idx + 1)];
16371649
if (sub === 'wait') return runMediaWait(subArgs);
1650+
if (sub === 'scaffold') return runMediaScaffold(subArgs);
16381651
return runMediaGenerate(subArgs);
16391652
}
16401653

1654+
async function runMediaScaffold(rawArgs) {
1655+
let flags;
1656+
try {
1657+
flags = parseFlags(rawArgs, {
1658+
string: MEDIA_SCAFFOLD_STRING_FLAGS,
1659+
boolean: MEDIA_SCAFFOLD_BOOLEAN_FLAGS,
1660+
});
1661+
} catch (err) {
1662+
console.error(err.message);
1663+
printMediaHelp();
1664+
process.exit(2);
1665+
}
1666+
if (flags.help || flags.h) {
1667+
printMediaHelp();
1668+
return;
1669+
}
1670+
1671+
const daemonUrl = await cliDaemonUrl(flags);
1672+
const projectId = flags.project || process.env.OD_PROJECT_ID;
1673+
const token = process.env.OD_TOOL_TOKEN;
1674+
if (!projectId && !token) {
1675+
console.error('project id required. Pass --project <id> or set OD_PROJECT_ID.');
1676+
process.exit(2);
1677+
}
1678+
const compositionDir = flags['composition-dir'];
1679+
if (!compositionDir) {
1680+
console.error('--composition-dir required (expected .hyperframes-cache/<id>)');
1681+
process.exit(2);
1682+
}
1683+
1684+
const url = token
1685+
? `${daemonUrl.replace(/\/$/, '')}/api/tools/media/hyperframes/scaffold`
1686+
: `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/hyperframes/scaffold`;
1687+
let resp;
1688+
try {
1689+
resp = await fetch(url, {
1690+
method: 'POST',
1691+
headers: {
1692+
'content-type': 'application/json',
1693+
...(token ? { authorization: `Bearer ${token}` } : {}),
1694+
...(token ? {} : workspaceHeadersFromExplicitFlags(flags) ?? {}),
1695+
},
1696+
body: JSON.stringify({ compositionDir }),
1697+
});
1698+
} catch (err) {
1699+
surfaceFetchError(err, daemonUrl);
1700+
process.exit(3);
1701+
}
1702+
if (!resp.ok) {
1703+
const responseText = await resp.text();
1704+
console.error(`daemon ${resp.status}: ${responseText}`);
1705+
process.exit(4);
1706+
}
1707+
process.stdout.write(`${JSON.stringify(await resp.json())}\n`);
1708+
}
1709+
16411710
async function runMediaGenerate(rawArgs) {
16421711
let flags;
16431712
try {
@@ -2029,10 +2098,17 @@ async function cliDaemonBaseUrl(flags) {
20292098
}
20302099

20312100
function printMediaHelp() {
2032-
console.log(`Usage: od media generate --surface <image|video|audio> --model <id> [opts]
2101+
console.log(`Usage: od media scaffold --composition-dir .hyperframes-cache/<id> [opts]
2102+
od media generate --surface <image|video|audio> --model <id> [opts]
20332103
"$OD_NODE_BIN" "$OD_BIN" media generate --surface <image|video|audio> --model <id> [opts]
20342104
2035-
Required:
2105+
Scaffold:
2106+
Creates hyperframes.json, meta.json, and index.html without running
2107+
HyperFrames init or installing global skills. The target must be new and
2108+
live directly under .hyperframes-cache.
2109+
--json is accepted for consistency; scaffold output is always one JSON line.
2110+
2111+
Generate required:
20362112
--surface image | video | audio
20372113
--model Model id from /api/media/models (e.g. gpt-image-2, seedance-2, suno-v5).
20382114
--project Project id. Auto-resolved from OD_PROJECT_ID when invoked by the daemon.
@@ -2060,8 +2136,9 @@ Common options:
20602136
--audio-kind music|speech|sfx
20612137
--composition-dir <path> hyperframes-html only — project-relative path
20622138
to the dir containing hyperframes.json /
2063-
meta.json / index.html. The daemon runs
2064-
\`npx hyperframes render\` against it.
2139+
meta.json / index.html. Use \`media scaffold\` to
2140+
create it; the daemon renders it with its pinned
2141+
HyperFrames runtime.
20652142
--image <path> Project-relative reference image; repeat up to 5
20662143
times for Vela image editing or video references.
20672144
The first video image is the first frame; the rest
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { createRequire } from 'node:module';
2+
import path from 'node:path';
3+
4+
const require = createRequire(import.meta.url);
5+
6+
export const HYPERFRAMES_CLI_ENV = 'OD_HYPERFRAMES_BIN';
7+
8+
export function resolveHyperFramesCliPath({
9+
env = process.env,
10+
resolvePackage = require.resolve,
11+
}: {
12+
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
13+
resolvePackage?: (id: string) => string;
14+
} = {}): string {
15+
const configured = env[HYPERFRAMES_CLI_ENV]?.trim();
16+
if (configured) return configured;
17+
18+
try {
19+
const manifestPath = resolvePackage('hyperframes/package.json');
20+
return path.join(path.dirname(manifestPath), 'bin', 'hyperframes.mjs');
21+
} catch (error) {
22+
const detail = error instanceof Error ? error.message : String(error);
23+
throw new Error(
24+
`Bundled HyperFrames CLI is unavailable. Reinstall Open Design so its pinned ` +
25+
`HyperFrames runtime and native dependencies match this platform. ${detail}`,
26+
);
27+
}
28+
}
29+
30+
export function resolveHyperFramesNodeBin(
31+
env: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env,
32+
execPath: string = process.execPath,
33+
): string {
34+
return env.OD_NODE_BIN?.trim() || execPath;
35+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { lstat, mkdir, rm, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
const HYPERFRAMES_CACHE_DIR = '.hyperframes-cache';
5+
const COMPOSITION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
6+
7+
const HYPERFRAMES_CONFIG = `${JSON.stringify({
8+
$schema: 'https://hyperframes.heygen.com/schema/hyperframes.json',
9+
registry: 'https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry',
10+
paths: {
11+
blocks: 'compositions',
12+
components: 'compositions/components',
13+
assets: 'assets',
14+
},
15+
media: { autoProxy: true },
16+
}, null, 2)}\n`;
17+
18+
const BLANK_COMPOSITION_HTML = `<!doctype html>
19+
<html lang="en">
20+
<head>
21+
<meta charset="UTF-8" />
22+
<meta name="viewport" content="width=1920, height=1080" />
23+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
24+
<style>
25+
* { margin: 0; padding: 0; box-sizing: border-box; }
26+
html, body {
27+
width: 1920px;
28+
height: 1080px;
29+
overflow: hidden;
30+
background: #000;
31+
}
32+
</style>
33+
</head>
34+
<body>
35+
<div
36+
id="root"
37+
data-composition-id="main"
38+
data-start="0"
39+
data-duration="10"
40+
data-width="1920"
41+
data-height="1080"
42+
></div>
43+
<script>
44+
window.__timelines = window.__timelines || {};
45+
const tl = gsap.timeline({ paused: true });
46+
window.__timelines["main"] = tl;
47+
</script>
48+
</body>
49+
</html>
50+
`;
51+
52+
export interface HyperFramesScaffoldResult {
53+
compositionDir: string;
54+
files: ['hyperframes.json', 'meta.json', 'index.html'];
55+
}
56+
57+
export async function scaffoldHyperFramesComposition(input: {
58+
projectDir: string;
59+
compositionDir: string;
60+
now?: Date;
61+
}): Promise<HyperFramesScaffoldResult> {
62+
if (!path.isAbsolute(input.projectDir)) {
63+
throw new Error('projectDir must be absolute');
64+
}
65+
const requested = input.compositionDir.trim();
66+
const normalized = path.normalize(requested);
67+
const parts = normalized.split(path.sep);
68+
const compositionId = parts[1] ?? '';
69+
if (
70+
parts.length !== 2
71+
|| parts[0] !== HYPERFRAMES_CACHE_DIR
72+
|| !COMPOSITION_ID_RE.test(compositionId)
73+
) {
74+
throw new Error('compositionDir must be inside .hyperframes-cache as .hyperframes-cache/<id>');
75+
}
76+
const cacheDir = path.join(input.projectDir, HYPERFRAMES_CACHE_DIR);
77+
await mkdir(cacheDir, { recursive: true });
78+
const cacheStat = await lstat(cacheDir);
79+
if (!cacheStat.isDirectory() || cacheStat.isSymbolicLink()) {
80+
throw new Error('.hyperframes-cache must be a real directory inside the project');
81+
}
82+
83+
const targetDir = path.join(cacheDir, compositionId);
84+
try {
85+
await lstat(targetDir);
86+
throw new Error(`composition already exists: ${HYPERFRAMES_CACHE_DIR}/${compositionId}`);
87+
} catch (error: any) {
88+
if (error?.code !== 'ENOENT') throw error;
89+
}
90+
91+
await mkdir(targetDir);
92+
const files = ['hyperframes.json', 'meta.json', 'index.html'] as const;
93+
try {
94+
const createdAt = (input.now ?? new Date()).toISOString();
95+
const metadata = `${JSON.stringify({
96+
id: compositionId,
97+
name: compositionId,
98+
createdAt,
99+
}, null, 2)}\n`;
100+
await Promise.all([
101+
writeFile(path.join(targetDir, files[0]), HYPERFRAMES_CONFIG, { encoding: 'utf8', flag: 'wx' }),
102+
writeFile(path.join(targetDir, files[1]), metadata, { encoding: 'utf8', flag: 'wx' }),
103+
writeFile(path.join(targetDir, files[2]), BLANK_COMPOSITION_HTML, { encoding: 'utf8', flag: 'wx' }),
104+
]);
105+
} catch (error) {
106+
await rm(targetDir, { recursive: true, force: true });
107+
throw error;
108+
}
109+
110+
return {
111+
compositionDir: `${HYPERFRAMES_CACHE_DIR}/${compositionId}`,
112+
files: [...files],
113+
};
114+
}

apps/daemon/src/media/index.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ import {
7777
fetchImageGenerationWithResponseRetry,
7878
type ImageGenerationRequestSummary,
7979
} from './image-generation-retry.js';
80+
import {
81+
resolveHyperFramesCliPath,
82+
resolveHyperFramesNodeBin,
83+
} from './hyperframes-runtime.js';
8084
import { renderVelaImage, renderVelaVideo } from './vela.js';
8185
import {
8286
ensureProject,
@@ -3803,7 +3807,8 @@ async function renderFalVideo(ctx: MediaContext, credentials: ProviderConfig, on
38033807
// with a GSAP timeline) into a hidden cache dir under the project, then
38043808
// dispatches here with `--composition-dir <relative-path>`.
38053809
//
3806-
// We run `npx hyperframes render <absolutePath> --output <tmp>/render.mp4`
3810+
// We run the pinned HyperFrames CLI with the daemon's Node-compatible runtime:
3811+
// `<node> <hyperframes-cli> render <absolutePath> --output <tmp>/render.mp4`
38073812
// from the daemon process (NOT the agent's shell) for two reasons:
38083813
// 1. HyperFrames spawns a puppeteer-controlled Chrome to capture frames.
38093814
// Claude Code's Bash tool wraps subprocesses in macOS sandbox-exec,
@@ -3860,13 +3865,13 @@ async function renderHyperFramesViaCli(ctx: MediaContext, projectDir: string, on
38603865
compAbs,
38613866
compRel,
38623867
'hyperframes.json',
3863-
'Run `npx hyperframes init "$OD_PROJECT_DIR/$COMP_REL" --example blank --skip-skills --non-interactive` before editing the composition.',
3868+
'Run `"$OD_NODE_BIN" "$OD_BIN" media scaffold --project "$OD_PROJECT_ID" --composition-dir "$COMP_REL"` before editing the composition.',
38643869
);
38653870
await assertHyperFramesCompositionFile(
38663871
compAbs,
38673872
compRel,
38683873
'meta.json',
3869-
'Run `npx hyperframes init` so the renderer has duration/scene metadata before dispatch.',
3874+
'Run `"$OD_NODE_BIN" "$OD_BIN" media scaffold --composition-dir "$COMP_REL"` so the renderer has duration/scene metadata before dispatch.',
38703875
);
38713876
await assertHyperFramesCompositionFile(
38723877
compAbs,
@@ -3915,7 +3920,7 @@ async function assertHyperFramesCompositionFile(
39153920
}
39163921

39173922
/**
3918-
* Run `npx hyperframes render` and stream every line of stdout/stderr
3923+
* Run the pinned HyperFrames CLI and stream every line of stdout/stderr
39193924
* through `onProgress`. Resolves on a clean exit, rejects on non-zero
39203925
* exit (with the stderr tail attached so the dispatcher can surface it).
39213926
*
@@ -3927,11 +3932,11 @@ async function assertHyperFramesCompositionFile(
39273932
*/
39283933
function runHyperFramesRender(compAbs: string, tmpOutput: string, onProgress?: ProgressFn): Promise<void> {
39293934
return new Promise<void>((resolve, reject) => {
3935+
const hyperFramesCli = resolveHyperFramesCliPath();
39303936
const child = spawn(
3931-
'npx',
3937+
resolveHyperFramesNodeBin(),
39323938
[
3933-
'-y',
3934-
'hyperframes',
3939+
hyperFramesCli,
39353940
'render',
39363941
compAbs,
39373942
'--output',
@@ -3940,10 +3945,13 @@ function runHyperFramesRender(compAbs: string, tmpOutput: string, onProgress?: P
39403945
'1',
39413946
],
39423947
{
3943-
// Inherit env so npx can find the cached hyperframes install
3944-
// and any user-level node config. stdin closed (HF doesn't
3945-
// read from it), stdout/stderr piped so we can stream.
3946-
env: process.env,
3948+
// Use the same Node-compatible runtime that owns the daemon and a
3949+
// pinned HyperFrames CLI shipped with Open Design. Do not delegate
3950+
// native dependency selection to a user-level npx cache.
3951+
env: {
3952+
...process.env,
3953+
OD_HYPERFRAMES_BIN: hyperFramesCli,
3954+
},
39473955
stdio: ['ignore', 'pipe', 'pipe'],
39483956
},
39493957
);

0 commit comments

Comments
 (0)