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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 84 additions & 7 deletions apps/daemon/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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\`.`);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions apps/daemon/src/media/hyperframes-runtime.ts
Original file line number Diff line number Diff line change
@@ -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;
}
114 changes: 114 additions & 0 deletions apps/daemon/src/media/hyperframes-scaffold.ts
Original file line number Diff line number Diff line change
@@ -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],
};
}
30 changes: 19 additions & 11 deletions apps/daemon/src/media/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
*
Expand All @@ -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',
Expand All @@ -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'],
},
);
Expand Down
Loading
Loading