mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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": [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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=";
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild",
|
||||
"onnxruntime-node",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
]
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1163
-30
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user