diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 1de038c99d..68a69db702 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -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", diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index a72d3993fe..af27cf5b7d 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -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/ + Create a deterministic HyperFrames composition without npx or global + skill installation, before dispatching it through media generate. + od mcp [--daemon-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://: * 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 or set OD_PROJECT_ID.'); + process.exit(2); + } + const compositionDir = flags['composition-dir']; + if (!compositionDir) { + console.error('--composition-dir required (expected .hyperframes-cache/)'); + 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 --model [opts] + console.log(`Usage: od media scaffold --composition-dir .hyperframes-cache/ [opts] + od media generate --surface --model [opts] "$OD_NODE_BIN" "$OD_BIN" media generate --surface --model [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 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 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 diff --git a/apps/daemon/src/media/hyperframes-runtime.ts b/apps/daemon/src/media/hyperframes-runtime.ts new file mode 100644 index 0000000000..8af736f298 --- /dev/null +++ b/apps/daemon/src/media/hyperframes-runtime.ts @@ -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; + 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 = process.env, + execPath: string = process.execPath, +): string { + return env.OD_NODE_BIN?.trim() || execPath; +} diff --git a/apps/daemon/src/media/hyperframes-scaffold.ts b/apps/daemon/src/media/hyperframes-scaffold.ts new file mode 100644 index 0000000000..8bd0a3543b --- /dev/null +++ b/apps/daemon/src/media/hyperframes-scaffold.ts @@ -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 = ` + + + + + + + + +
+ + + +`; + +export interface HyperFramesScaffoldResult { + compositionDir: string; + files: ['hyperframes.json', 'meta.json', 'index.html']; +} + +export async function scaffoldHyperFramesComposition(input: { + projectDir: string; + compositionDir: string; + now?: Date; +}): Promise { + 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/'); + } + 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], + }; +} diff --git a/apps/daemon/src/media/index.ts b/apps/daemon/src/media/index.ts index 7858c1ed22..22a2a959f2 100644 --- a/apps/daemon/src/media/index.ts +++ b/apps/daemon/src/media/index.ts @@ -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 `. // -// We run `npx hyperframes render --output /render.mp4` +// We run the pinned HyperFrames CLI with the daemon's Node-compatible runtime: +// ` render --output /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 { return new Promise((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'], }, ); diff --git a/apps/daemon/src/prompts/media-contract.ts b/apps/daemon/src/prompts/media-contract.ts index 873e6b0b52..6fa5818fed 100644 --- a/apps/daemon/src/prompts/media-contract.ts +++ b/apps/daemon/src/prompts/media-contract.ts @@ -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
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 \`. - 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 diff --git a/apps/daemon/src/prompts/system.ts b/apps/daemon/src/prompts/system.ts index 2151124d61..92e864ba82 100644 --- a/apps/daemon/src/prompts/system.ts +++ b/apps/daemon/src/prompts/system.ts @@ -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 `. 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 ` under `.hyperframes-cache/`, edit `index.html`, and dispatch via `"$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model hyperframes-html --composition-dir `. Do not run HyperFrames `init` or `render` yourself.', ); } } diff --git a/apps/daemon/src/routes/media.ts b/apps/daemon/src/routes/media.ts index 80466a2212..0a4ce27183 100644 --- a/apps/daemon/src/routes/media.ts +++ b/apps/daemon/src/routes/media.ts @@ -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; + 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({ diff --git a/apps/daemon/src/runtimes/defs/codex.ts b/apps/daemon/src/runtimes/defs/codex.ts index 389772ff57..36ed205ec4 100644 --- a/apps/daemon/src/runtimes/defs/codex.ts +++ b/apps/daemon/src/runtimes/defs/codex.ts @@ -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', diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 9b080be238..cc3dc3d7d5 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -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 diff --git a/apps/daemon/src/tool-tokens.ts b/apps/daemon/src/tool-tokens.ts index 23ae08a023..0121f59ec0 100644 --- a/apps/daemon/src/tool-tokens.ts +++ b/apps/daemon/src/tool-tokens.ts @@ -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', diff --git a/apps/daemon/tests/media-scaffold-cli.test.ts b/apps/daemon/tests/media-scaffold-cli.test.ts new file mode 100644 index 0000000000..1c409827c9 --- /dev/null +++ b/apps/daemon/tests/media-scaffold-cli.test.ts @@ -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((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((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', + }); + }); +}); diff --git a/apps/daemon/tests/media/hyperframes-scaffold-routes.test.ts b/apps/daemon/tests/media/hyperframes-scaffold-routes.test.ts new file mode 100644 index 0000000000..1d2eea79b5 --- /dev/null +++ b/apps/daemon/tests/media/hyperframes-scaffold-routes.test.ts @@ -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((resolve) => { + server.close(() => resolve()); + }))); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +function noop() {} + +function functionProxy(overrides: Record = {}) { + 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[1]; + + const app = express(); + app.use(express.json()); + registerMediaRoutes(app, deps); + const server = app.listen(0); + servers.push(server); + await new Promise((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'); + }); +}); diff --git a/apps/daemon/tests/media/hyperframes-scaffold.test.ts b/apps/daemon/tests/media/hyperframes-scaffold.test.ts new file mode 100644 index 0000000000..321ea05714 --- /dev/null +++ b/apps/daemon/tests/media/hyperframes-scaffold.test.ts @@ -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/); + }); +}); diff --git a/apps/daemon/tests/media/hyperframes.test.ts b/apps/daemon/tests/media/hyperframes.test.ts index 16927ef77d..2526bb17c9 100644 --- a/apps/daemon/tests/media/hyperframes.test.ts +++ b/apps/daemon/tests/media/hyperframes.test.ts @@ -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'), '
', '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'); + }); }); diff --git a/apps/daemon/tests/prompts/__snapshots__/system-prompt-matrix.test.ts.snap b/apps/daemon/tests/prompts/__snapshots__/system-prompt-matrix.test.ts.snap index a17f83f6a9..4c909f6c3c 100644 --- a/apps/daemon/tests/prompts/__snapshots__/system-prompt-matrix.test.ts.snap +++ b/apps/daemon/tests/prompts/__snapshots__/system-prompt-matrix.test.ts.snap @@ -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": [ diff --git a/apps/daemon/tests/prompts/system.test.ts b/apps/daemon/tests/prompts/system.test.ts index 78d923526b..91d01198e5 100644 --- a/apps/daemon/tests/prompts/system.test.ts +++ b/apps/daemon/tests/prompts/system.test.ts @@ -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 '); - 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 '); - 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'); diff --git a/apps/daemon/tests/runtimes/agent-runtime-env.test.ts b/apps/daemon/tests/runtimes/agent-runtime-env.test.ts index 1b37fc4a7a..d354e96617 100644 --- a/apps/daemon/tests/runtimes/agent-runtime-env.test.ts +++ b/apps/daemon/tests/runtimes/agent-runtime-env.test.ts @@ -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'); diff --git a/apps/daemon/tests/runtimes/codex-resume-args.test.ts b/apps/daemon/tests/runtimes/codex-resume-args.test.ts index 5ebc37f72d..4f64796f0e 100644 --- a/apps/daemon/tests/runtimes/codex-resume-args.test.ts +++ b/apps/daemon/tests/runtimes/codex-resume-args.test.ts @@ -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"'); diff --git a/apps/daemon/tests/server-bootstrap-regression.test.ts b/apps/daemon/tests/server-bootstrap-regression.test.ts index 0cb49ddf81..3cb3bc4287 100644 --- a/apps/daemon/tests/server-bootstrap-regression.test.ts +++ b/apps/daemon/tests/server-bootstrap-regression.test.ts @@ -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', diff --git a/design-templates/hyperframes/SKILL.md b/design-templates/hyperframes/SKILL.md index e853d8d34d..84bf8b8159 100644 --- a/design-templates/hyperframes/SKILL.md +++ b/design-templates/hyperframes/SKILL.md @@ -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