wizards 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/runtime/dist/app.js +2 -1
- package/apps/runtime/dist/authoring/guide.js +32 -4
- package/apps/runtime/dist/connectors/external.js +4 -1
- package/apps/runtime/dist/engine/prepare.js +37 -2
- package/apps/runtime/dist/engine/requirements.js +22 -1
- package/apps/runtime/dist/engine/steps.js +8 -1
- package/apps/runtime/dist/engine/template.js +8 -0
- package/apps/runtime/dist/files/storage.js +8 -1
- package/apps/runtime/dist/film/project.js +195 -0
- package/apps/runtime/dist/film/skills.js +91 -0
- package/apps/runtime/dist/film/sounds.js +122 -0
- package/apps/runtime/dist/film/step.js +286 -0
- package/apps/runtime/dist/film/tools.js +99 -0
- package/apps/runtime/dist/manage.js +4 -0
- package/apps/runtime/dist/mcp/auth.js +4 -0
- package/apps/runtime/dist/mcp/handler.js +2 -1
- package/apps/runtime/dist/media/transcribe.js +150 -0
- package/apps/runtime/dist/models.js +24 -0
- package/apps/runtime/dist/render/downloads.js +1 -1
- package/apps/runtime/dist/routes/api.js +12 -9
- package/apps/runtime/dist/routes/internal.js +17 -1
- package/apps/runtime/dist/routes/studio.js +55 -7
- package/apps/runtime/dist/services/access.js +25 -0
- package/apps/runtime/dist/services/errors.js +1 -0
- package/apps/runtime/dist/services/package.js +2 -0
- package/apps/runtime/dist/services/projects.js +15 -2
- package/apps/runtime/dist/services/wizards.js +4 -1
- package/apps/runtime/dist/widgets/render.js +20 -3
- package/apps/runtime/dist/widgets/runtime.js +2 -1
- package/apps/runtime/dist/widgets/step.js +16 -0
- package/apps/web/dist/assets/{Account-O6OTAY88.js → Account-BxpaLgg6.js} +1 -1
- package/apps/web/dist/assets/{ChangelogDialog-DLrXuyLL.js → ChangelogDialog-Ds9XgxMP.js} +1 -1
- package/apps/web/dist/assets/{ChatPanel-BPD-iQK0.js → ChatPanel-CGL-SaYv.js} +1 -1
- package/apps/web/dist/assets/{CreditsDialog-Ckema8Ss.js → CreditsDialog-DZPOim-g.js} +1 -1
- package/apps/web/dist/assets/EditorPage-DJDTvELx.js +2 -0
- package/apps/web/dist/assets/{FlowPreview-DB-86F8z.js → FlowPreview-DObLaWK0.js} +1 -1
- package/apps/web/dist/assets/{Harness-D-DSa4ip.js → Harness-BEIJfBYu.js} +1 -1
- package/apps/web/dist/assets/HomePage-BxTQiJog.js +1 -0
- package/apps/web/dist/assets/{ImportWizard-DtZH-PgS.js → ImportWizard-fp4Q1iZ6.js} +1 -1
- package/apps/web/dist/assets/{LocalRuntime-o1OAvp7c.js → LocalRuntime-C1izy1g5.js} +1 -1
- package/apps/web/dist/assets/NewWizardPage-BDOoKp6y.js +2 -0
- package/apps/web/dist/assets/ProjectPage-Cs86dGVd.js +5 -0
- package/apps/web/dist/assets/{PublicRunner-CYo2F8H4.js → PublicRunner-B53p-hWG.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-Bqp0paLi.js → RunnerView-CrgDUD9U.js} +2 -2
- package/apps/web/dist/assets/SettingsPage-07vGl-P6.js +1 -0
- package/apps/web/dist/assets/{SetupPage-CdBYFmh1.js → SetupPage-Ba0WirOM.js} +1 -1
- package/apps/web/dist/assets/{SharePage-B7ON0pe1.js → SharePage-J7eD3paN.js} +1 -1
- package/apps/web/dist/assets/{api-CVG-xDEn.js → api-BkWIPEWf.js} +1 -1
- package/apps/web/dist/assets/changelog-D4wD3iMl.js +1 -0
- package/apps/web/dist/assets/definition-BznNdlhf.js +55 -0
- package/apps/web/dist/assets/{esm-DutMJAod.js → esm-Bk4BRENl.js} +1 -1
- package/apps/web/dist/assets/image-CC_2mJd6.js +1 -0
- package/apps/web/dist/assets/index-CJuJLvrl.js +10 -0
- package/apps/web/dist/assets/meta-DMOYx_c4.js +1 -0
- package/apps/web/dist/assets/{settings-menu-BXpT5jOB.js → settings-menu-BI5cH97k.js} +1 -1
- package/apps/web/dist/assets/ui-CiYJabAi.js +1 -0
- package/apps/web/dist/assets/volume-2-DE67_rue.js +1 -0
- package/apps/web/dist/index.html +3 -3
- package/apps/web/dist/mcp/flow.html +53 -53
- package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +146 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.js +101 -0
- package/node_modules/@engenty-wizards/shared/dist/marketplace-entry.js +3 -0
- package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +6 -1
- package/node_modules/@engenty-wizards/shared/dist/marketplace.js +12 -0
- package/node_modules/@engenty-wizards/shared/dist/run.d.ts +30 -0
- package/node_modules/@engenty-wizards/shared/dist/run.js +18 -0
- package/package.json +1 -1
- package/apps/web/dist/assets/EditorPage-Dtu9j41h.js +0 -2
- package/apps/web/dist/assets/HomePage-ZrCxIiJT.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-Bbt-w8tE.js +0 -2
- package/apps/web/dist/assets/ProjectPage-MHxFqA98.js +0 -5
- package/apps/web/dist/assets/SettingsPage-XyRrZeMr.js +0 -1
- package/apps/web/dist/assets/changelog-B2U80Wtl.js +0 -1
- package/apps/web/dist/assets/definition-Dp0DR2D6.js +0 -55
- package/apps/web/dist/assets/image-BShbErBm.js +0 -1
- package/apps/web/dist/assets/index-hpNaR92X.js +0 -10
- package/apps/web/dist/assets/meta--kpiTOS-.js +0 -1
- package/apps/web/dist/assets/ui-BPY2pIh0.js +0 -1
- package/apps/web/dist/assets/volume-2-Bp2tyaKE.js +0 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { env } from "../env.js";
|
|
5
|
+
import { platformSkills } from "./skills.js";
|
|
6
|
+
/**
|
|
7
|
+
* The sounds a film may use: nothing is generated, synthesized or downloaded by the client.
|
|
8
|
+
* soundcn (soundcn.xyz, MIT; the sounds by Kenney, CC0) for clicks, pops, impacts, jingles,
|
|
9
|
+
* switches, pages and paper; the effects HyperFrames ships with (whooshes, typing, risers).
|
|
10
|
+
* Fetched once into the data folder; `catalog.json` lists every file with tags and licence.
|
|
11
|
+
*/
|
|
12
|
+
const REGISTRY = "https://soundcn.xyz/r";
|
|
13
|
+
/** soundcn categories that suit motion design; game noises (warcraft, laser, combat …) stay out. */
|
|
14
|
+
const KEEP = new Set([
|
|
15
|
+
"ui",
|
|
16
|
+
"click",
|
|
17
|
+
"switch",
|
|
18
|
+
"select",
|
|
19
|
+
"maximize",
|
|
20
|
+
"minimize",
|
|
21
|
+
"book",
|
|
22
|
+
"card",
|
|
23
|
+
"cloth",
|
|
24
|
+
"scratch",
|
|
25
|
+
"scroll",
|
|
26
|
+
"drop",
|
|
27
|
+
"glass",
|
|
28
|
+
"jingles",
|
|
29
|
+
"pep",
|
|
30
|
+
"power",
|
|
31
|
+
"impact",
|
|
32
|
+
"chips",
|
|
33
|
+
"dice",
|
|
34
|
+
"error",
|
|
35
|
+
"round",
|
|
36
|
+
"zap",
|
|
37
|
+
"phase",
|
|
38
|
+
"low",
|
|
39
|
+
"metal",
|
|
40
|
+
"crafting",
|
|
41
|
+
]);
|
|
42
|
+
const CATALOG_VERSION = 1;
|
|
43
|
+
const dir = join(env.dataDir, "catalogs", `sounds-v${CATALOG_VERSION}`);
|
|
44
|
+
let building = null;
|
|
45
|
+
/** The catalog folder (catalog.json + sfx/); built on first use. */
|
|
46
|
+
export function soundCatalog() {
|
|
47
|
+
if (existsSync(join(dir, "catalog.json"))) {
|
|
48
|
+
return Promise.resolve(dir);
|
|
49
|
+
}
|
|
50
|
+
building ??= build().finally(() => {
|
|
51
|
+
building = null;
|
|
52
|
+
});
|
|
53
|
+
return building;
|
|
54
|
+
}
|
|
55
|
+
async function build() {
|
|
56
|
+
const tmp = `${dir}.part`;
|
|
57
|
+
await rm(tmp, { recursive: true, force: true });
|
|
58
|
+
await mkdir(join(tmp, "sfx"), { recursive: true });
|
|
59
|
+
const sounds = [];
|
|
60
|
+
const index = (await (await fetch(`${REGISTRY}/registry.json`)).json());
|
|
61
|
+
const picked = index.items.filter((i) => i.categories?.some((c) => KEEP.has(c)));
|
|
62
|
+
let next = 0;
|
|
63
|
+
const worker = async () => {
|
|
64
|
+
while (next < picked.length) {
|
|
65
|
+
const item = picked[next++];
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`${REGISTRY}/${item.name}.json`);
|
|
68
|
+
const json = (await res.json());
|
|
69
|
+
const m = json.files?.[0]?.content?.match(/data:audio\/(\w+);base64,([A-Za-z0-9+/=]+)/);
|
|
70
|
+
if (!m) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const ext = m[1] === "mpeg" ? "mp3" : m[1];
|
|
74
|
+
await writeFile(join(tmp, "sfx", `${item.name}.${ext}`), Buffer.from(m[2], "base64"));
|
|
75
|
+
sounds.push({
|
|
76
|
+
file: `sfx/${item.name}.${ext}`,
|
|
77
|
+
source: "soundcn",
|
|
78
|
+
author: json.author ?? "Kenney",
|
|
79
|
+
licence: item.meta?.license ?? "CC0",
|
|
80
|
+
duration: item.meta?.duration,
|
|
81
|
+
description: json.description,
|
|
82
|
+
tags: item.categories?.concat(item.meta?.tags ?? []),
|
|
83
|
+
keywords: item.meta?.keywords,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// one sound less
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
await Promise.all(Array.from({ length: 8 }, worker));
|
|
92
|
+
// The effects HyperFrames ships with its media skill.
|
|
93
|
+
const hf = join(await platformSkills("hyperframes"), "media-use", "audio", "assets", "sfx");
|
|
94
|
+
if (existsSync(hf)) {
|
|
95
|
+
for (const f of await readdir(hf)) {
|
|
96
|
+
if (f.endsWith(".mp3")) {
|
|
97
|
+
await cp(join(hf, f), join(tmp, "sfx", `hf-${f}`));
|
|
98
|
+
sounds.push({
|
|
99
|
+
file: `sfx/hf-${f}`,
|
|
100
|
+
source: "hyperframes",
|
|
101
|
+
licence: "see CREDITS-hyperframes.md",
|
|
102
|
+
description: f.replace(/\.mp3$/, "").replace(/-/g, " "),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (existsSync(join(hf, "CREDITS.md"))) {
|
|
107
|
+
await writeFile(join(tmp, "CREDITS-hyperframes.md"), await readFile(join(hf, "CREDITS.md")));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!sounds.length) {
|
|
111
|
+
throw new Error("Die Geräusche für Filme konnten nicht geladen werden.");
|
|
112
|
+
}
|
|
113
|
+
sounds.sort((a, b) => a.file.localeCompare(b.file));
|
|
114
|
+
await writeFile(join(tmp, "catalog.json"), JSON.stringify({
|
|
115
|
+
note: "The only sounds this film may use. Never generate, synthesize or download audio.",
|
|
116
|
+
music: [],
|
|
117
|
+
sounds,
|
|
118
|
+
}, null, 1));
|
|
119
|
+
await rm(dir, { recursive: true, force: true });
|
|
120
|
+
await rename(tmp, dir);
|
|
121
|
+
return dir;
|
|
122
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
5
|
+
import { StepError } from "../engine/types.js";
|
|
6
|
+
import { CLAUDE_BIN, claudeEnv } from "../harness/claude.js";
|
|
7
|
+
import { failureOf } from "../harness/model.js";
|
|
8
|
+
import { openBridge } from "../mcp/bridge.js";
|
|
9
|
+
import { filmClient } from "../models.js";
|
|
10
|
+
import { prepareFilmFolder, savedSession, saveSession } from "./project.js";
|
|
11
|
+
import { runHyperframes } from "./tools.js";
|
|
12
|
+
/**
|
|
13
|
+
* A film step: the installed client (Claude Code) works in the film's folder with the skills,
|
|
14
|
+
* the style kit, the brand and the run's material, and writes the film as code. Its shell runs
|
|
15
|
+
* in the client's sandbox — writes only inside the folder, no network; HyperFrames (render,
|
|
16
|
+
* snapshots, checks) runs outside it, through the `hyperframes` tool of this step, limited to
|
|
17
|
+
* the folder. The final render is the runtime's, not the client's. A run that stops (a limit,
|
|
18
|
+
* a crash) keeps the folder and the client's session: the next attempt picks up from there.
|
|
19
|
+
*/
|
|
20
|
+
/** What the client may run through the tool; everything that reaches the network stays out. */
|
|
21
|
+
const ALLOWED = new Set([
|
|
22
|
+
"lint",
|
|
23
|
+
"check",
|
|
24
|
+
"validate",
|
|
25
|
+
"inspect",
|
|
26
|
+
"snapshot",
|
|
27
|
+
"render",
|
|
28
|
+
"compositions",
|
|
29
|
+
"timeline",
|
|
30
|
+
"info",
|
|
31
|
+
"keyframes",
|
|
32
|
+
"compare",
|
|
33
|
+
"grade-compare",
|
|
34
|
+
"beats",
|
|
35
|
+
"normalize-audio",
|
|
36
|
+
"media-treatment",
|
|
37
|
+
"transcribe",
|
|
38
|
+
"docs",
|
|
39
|
+
]);
|
|
40
|
+
/** The longest a film may take the client before the step gives up. */
|
|
41
|
+
const LIMIT_MS = 3 * 60 * 60_000;
|
|
42
|
+
function hyperframesTool(root, signal) {
|
|
43
|
+
return {
|
|
44
|
+
name: "hyperframes",
|
|
45
|
+
description: "Runs the HyperFrames CLI in the film folder: lint, check, snapshot, render, inspect, timeline, transcribe, keyframes, compare, docs and the like. Paths are relative to the folder. Returns what it printed.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
args: {
|
|
50
|
+
type: "array",
|
|
51
|
+
items: { type: "string" },
|
|
52
|
+
description: 'The arguments, e.g. ["snapshot", "film", "--at", "1,4,8"].',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
required: ["args"],
|
|
56
|
+
},
|
|
57
|
+
async execute(input) {
|
|
58
|
+
const args = (input.args ?? []);
|
|
59
|
+
if (!args.length || !args.every((a) => typeof a === "string")) {
|
|
60
|
+
return "args: a list of strings, the subcommand first.";
|
|
61
|
+
}
|
|
62
|
+
const [cmd] = args;
|
|
63
|
+
if (!ALLOWED.has(cmd)) {
|
|
64
|
+
return `"${cmd}" is not available here. Available: ${[...ALLOWED].join(", ")}.`;
|
|
65
|
+
}
|
|
66
|
+
// Every path stays inside the folder.
|
|
67
|
+
for (const a of args) {
|
|
68
|
+
const value = a.includes("=") && a.startsWith("-") ? a.slice(a.indexOf("=") + 1) : a;
|
|
69
|
+
if (isAbsolute(value) || normalize(value).startsWith("..")) {
|
|
70
|
+
return `"${a}" leaves the film folder.`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const extra = cmd === "transcribe" ? ["--no-runtime-install"] : [];
|
|
74
|
+
const { code, out } = await runHyperframes([...args, ...extra], {
|
|
75
|
+
cwd: root,
|
|
76
|
+
signal,
|
|
77
|
+
timeoutMs: 20 * 60_000,
|
|
78
|
+
});
|
|
79
|
+
return `exit ${code}\n${out.slice(-6000)}`;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Lines of a failed render worth reading: what failed and why, not the progress bars. */
|
|
84
|
+
function renderProblem(out) {
|
|
85
|
+
const lines = out
|
|
86
|
+
.split("\n")
|
|
87
|
+
.map((l) => l.trim())
|
|
88
|
+
.filter((l) => /✗|fail|error|blocked|warning|cannot|undefined/i.test(l) && l.length < 600);
|
|
89
|
+
return (lines.join("\n") || out.slice(-800)).slice(0, 2500);
|
|
90
|
+
}
|
|
91
|
+
/** How many times a failed final render goes back to the client to be fixed. */
|
|
92
|
+
const RENDER_FIXES = 2;
|
|
93
|
+
export async function runFilmStep(step, ctx) {
|
|
94
|
+
const found = await filmClient(step.model ?? "standard");
|
|
95
|
+
if ("problem" in found) {
|
|
96
|
+
throw new StepError(found.problem);
|
|
97
|
+
}
|
|
98
|
+
const { client, alias } = found;
|
|
99
|
+
await ctx.emit("info", { code: "filmSetup" });
|
|
100
|
+
const { root, brief } = await prepareFilmFolder(step, ctx);
|
|
101
|
+
const resume = await savedSession(root);
|
|
102
|
+
await ctx.emit("info", resume ? { code: "filmResume" } : { code: "filmClient", params: { client: client.name } });
|
|
103
|
+
const limit = AbortSignal.any([ctx.signal, AbortSignal.timeout(LIMIT_MS)]);
|
|
104
|
+
const bridge = openBridge([hyperframesTool(root, limit)]);
|
|
105
|
+
/** One turn of the client in the folder; returns its final event. */
|
|
106
|
+
const turn = async (prompt, session) => {
|
|
107
|
+
const args = [
|
|
108
|
+
"-p",
|
|
109
|
+
"--output-format",
|
|
110
|
+
"stream-json",
|
|
111
|
+
"--verbose",
|
|
112
|
+
"--model",
|
|
113
|
+
alias,
|
|
114
|
+
"--setting-sources",
|
|
115
|
+
"project",
|
|
116
|
+
"--strict-mcp-config",
|
|
117
|
+
"--mcp-config",
|
|
118
|
+
JSON.stringify({ mcpServers: { film: { type: "http", url: bridge.url } } }),
|
|
119
|
+
// The shell writes only inside the folder and has no network.
|
|
120
|
+
"--settings",
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
sandbox: { enabled: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false },
|
|
123
|
+
}),
|
|
124
|
+
"--allowedTools",
|
|
125
|
+
[
|
|
126
|
+
"Bash",
|
|
127
|
+
"Read(./**)",
|
|
128
|
+
"Edit(./**)",
|
|
129
|
+
"Write(./**)",
|
|
130
|
+
"Glob",
|
|
131
|
+
"Grep",
|
|
132
|
+
"Task",
|
|
133
|
+
"TodoWrite",
|
|
134
|
+
"TaskCreate",
|
|
135
|
+
"TaskUpdate",
|
|
136
|
+
"TaskList",
|
|
137
|
+
"TaskGet",
|
|
138
|
+
"Skill",
|
|
139
|
+
"mcp__film",
|
|
140
|
+
].join(","),
|
|
141
|
+
"--disallowedTools",
|
|
142
|
+
"WebFetch,WebSearch",
|
|
143
|
+
];
|
|
144
|
+
if (step.effort) {
|
|
145
|
+
args.push("--effort", step.effort);
|
|
146
|
+
}
|
|
147
|
+
if (session) {
|
|
148
|
+
args.push("--resume", session);
|
|
149
|
+
}
|
|
150
|
+
let last = null;
|
|
151
|
+
let stderr = "";
|
|
152
|
+
await new Promise((resolve, reject) => {
|
|
153
|
+
void (async () => {
|
|
154
|
+
const child = spawn(CLAUDE_BIN, args, {
|
|
155
|
+
cwd: root,
|
|
156
|
+
env: await claudeEnv(),
|
|
157
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
158
|
+
signal: limit,
|
|
159
|
+
});
|
|
160
|
+
let buffer = "";
|
|
161
|
+
let lastLook = 0;
|
|
162
|
+
let lastTask = "";
|
|
163
|
+
const subjects = [];
|
|
164
|
+
const task = async (now) => {
|
|
165
|
+
if (now && now !== lastTask) {
|
|
166
|
+
lastTask = now;
|
|
167
|
+
await ctx.emit("tool", { code: "filmTask", params: { task: now.slice(0, 90) } });
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const onEvent = async (event) => {
|
|
171
|
+
if (event.session_id && event.type === "system") {
|
|
172
|
+
await saveSession(root, event.session_id);
|
|
173
|
+
}
|
|
174
|
+
if (event.type === "result") {
|
|
175
|
+
last = event;
|
|
176
|
+
}
|
|
177
|
+
for (const part of event.message?.content ?? []) {
|
|
178
|
+
if (part.type !== "tool_use") {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const input = part.input ?? {};
|
|
182
|
+
// What the client is on, as its own task list says; else a look at its frames.
|
|
183
|
+
if (part.name === "TodoWrite") {
|
|
184
|
+
const todos = (input.todos ?? []);
|
|
185
|
+
await task(todos.find((t) => t.status === "in_progress")?.content);
|
|
186
|
+
}
|
|
187
|
+
else if (part.name === "TaskCreate") {
|
|
188
|
+
subjects.push(String(input.subject ?? ""));
|
|
189
|
+
}
|
|
190
|
+
else if (part.name === "TaskUpdate" && input.status === "in_progress") {
|
|
191
|
+
const id = Number(input.taskId);
|
|
192
|
+
await task(typeof input.subject === "string" ? input.subject : subjects[id - 1] || undefined);
|
|
193
|
+
}
|
|
194
|
+
else if (part.name === "mcp__film__hyperframes" &&
|
|
195
|
+
input.args?.[0] === "snapshot" &&
|
|
196
|
+
Date.now() - lastLook > 60_000) {
|
|
197
|
+
lastLook = Date.now();
|
|
198
|
+
await ctx.emit("tool", { code: "filmLooks" });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
child.stdout.on("data", (chunk) => {
|
|
203
|
+
buffer += String(chunk);
|
|
204
|
+
let nl = buffer.indexOf("\n");
|
|
205
|
+
while (nl >= 0) {
|
|
206
|
+
const line = buffer.slice(0, nl).trim();
|
|
207
|
+
buffer = buffer.slice(nl + 1);
|
|
208
|
+
nl = buffer.indexOf("\n");
|
|
209
|
+
if (line.startsWith("{")) {
|
|
210
|
+
try {
|
|
211
|
+
void onEvent(JSON.parse(line)).catch(() => { });
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// not a line of the stream
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
child.stderr.on("data", (chunk) => {
|
|
220
|
+
stderr = (stderr + String(chunk)).slice(-4000);
|
|
221
|
+
});
|
|
222
|
+
child.on("error", reject);
|
|
223
|
+
child.on("close", () => resolve());
|
|
224
|
+
child.stdin.end(prompt);
|
|
225
|
+
})().catch(reject);
|
|
226
|
+
}).catch((err) => {
|
|
227
|
+
if (ctx.signal.aborted) {
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
throw new StepError(`${client.name} hat beim Film aufgehört: ${err.message}. Die Arbeit ist gespeichert; ein neuer Versuch macht dort weiter.`);
|
|
231
|
+
});
|
|
232
|
+
const result = last;
|
|
233
|
+
if (!result || result.is_error) {
|
|
234
|
+
const detail = String(result?.result ?? (stderr.trim() || "kein Ergebnis")).slice(0, 400);
|
|
235
|
+
throw new StepError(`${failureOf(client, detail).message} Die Arbeit ist gespeichert; ein neuer Versuch macht dort weiter.`);
|
|
236
|
+
}
|
|
237
|
+
return result;
|
|
238
|
+
};
|
|
239
|
+
const out = join(root, "film", "renders", "film.mp4");
|
|
240
|
+
let summary = "";
|
|
241
|
+
try {
|
|
242
|
+
// A review's note asks for a change: the film's code is there, so it is edited, not redone.
|
|
243
|
+
const note = ctx.state.notes[step.id]?.trim();
|
|
244
|
+
const prompt = resume && note
|
|
245
|
+
? `The person watched the film and asks for this change:\n\n${note}\n\nChange the film in film/ accordingly — keep what they did not mention — check the frames again, and end with a short summary of what you changed.`
|
|
246
|
+
: resume
|
|
247
|
+
? "Carry on with the film where you stopped. Read PROGRESS.md and the folder to see where that was, then finish it."
|
|
248
|
+
: `Make the film BRIEF.md asks for, following CLAUDE.md.\n\n${brief}`;
|
|
249
|
+
const first = await turn(prompt, resume);
|
|
250
|
+
summary = typeof first.result === "string" ? first.result.trim() : "";
|
|
251
|
+
// The final render is ours: it does not depend on the client still running. What fails
|
|
252
|
+
// goes back to the client, which fixes its code.
|
|
253
|
+
for (let attempt = 0;; attempt++) {
|
|
254
|
+
if (!existsSync(join(root, "film", "index.html"))) {
|
|
255
|
+
throw new StepError(`${client.name} hat keinen Film hinterlassen.`);
|
|
256
|
+
}
|
|
257
|
+
await ctx.emit("info", { code: "videoRendering" });
|
|
258
|
+
await rm(out, { force: true });
|
|
259
|
+
const render = await runHyperframes(["render", "film", "-o", "film/renders/film.mp4", "--crf", "23", "--quiet"], { cwd: root, signal: ctx.signal, timeoutMs: 30 * 60_000 });
|
|
260
|
+
if (render.code === 0 && existsSync(out) && (await stat(out)).size > 0) {
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
const problem = renderProblem(render.out);
|
|
264
|
+
if (attempt >= RENDER_FIXES) {
|
|
265
|
+
throw new StepError(`Der Film ließ sich nicht rendern: ${problem.slice(0, 400)}`);
|
|
266
|
+
}
|
|
267
|
+
await ctx.emit("tool", { code: "filmFix" });
|
|
268
|
+
const fixed = await turn(`The final render of film/ failed:\n\n${problem}\n\nFind the cause in the composition's code, fix it, run \`check\` and a draft render through the hyperframes tool until both pass, and end with one line on what you fixed.`, await savedSession(root));
|
|
269
|
+
if (typeof fixed.result === "string" && fixed.result.trim()) {
|
|
270
|
+
summary = `${summary}\n\n${fixed.result.trim()}`.trim();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
bridge.close();
|
|
276
|
+
}
|
|
277
|
+
const ref = await ctx.saveAsset({
|
|
278
|
+
stepId: step.id,
|
|
279
|
+
kind: "video",
|
|
280
|
+
mime: "video/mp4",
|
|
281
|
+
name: `${step.id}.mp4`,
|
|
282
|
+
data: new Uint8Array(await readFile(out)),
|
|
283
|
+
ai: "generated",
|
|
284
|
+
});
|
|
285
|
+
return { text: summary.slice(0, 3000), assets: [ref], at: new Date().toISOString() };
|
|
286
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { env } from "../env.js";
|
|
6
|
+
/**
|
|
7
|
+
* The HyperFrames CLI (github.com/heygen-com/hyperframes, Apache 2.0): renders a composition of
|
|
8
|
+
* HTML, CSS and JS to MP4 frame by frame in headless Chrome. Installed once into the data folder
|
|
9
|
+
* on the first film, pinned to a version; Chrome comes with it on its first render.
|
|
10
|
+
*/
|
|
11
|
+
export const HYPERFRAMES_VERSION = "0.8.137";
|
|
12
|
+
const home = join(env.dataDir, "tools", `hyperframes-${HYPERFRAMES_VERSION}`);
|
|
13
|
+
const entry = join(home, "node_modules", "hyperframes", "bin", "hyperframes.mjs");
|
|
14
|
+
let installing = null;
|
|
15
|
+
/** Terminal colour codes. */
|
|
16
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
17
|
+
/** The CLI's entry script; installs it on first use. */
|
|
18
|
+
export function hyperframesEntry() {
|
|
19
|
+
if (existsSync(entry)) {
|
|
20
|
+
return Promise.resolve(entry);
|
|
21
|
+
}
|
|
22
|
+
installing ??= install().finally(() => {
|
|
23
|
+
installing = null;
|
|
24
|
+
});
|
|
25
|
+
return installing;
|
|
26
|
+
}
|
|
27
|
+
async function install() {
|
|
28
|
+
await mkdir(home, { recursive: true });
|
|
29
|
+
// npm sits next to the node that runs us; else the one on PATH.
|
|
30
|
+
const local = join(dirname(process.execPath), "npm");
|
|
31
|
+
const npm = existsSync(local) ? local : "npm";
|
|
32
|
+
const { code, out } = await spawnText(npm, [
|
|
33
|
+
"install",
|
|
34
|
+
"--prefix",
|
|
35
|
+
home,
|
|
36
|
+
"--no-audit",
|
|
37
|
+
"--no-fund",
|
|
38
|
+
"--loglevel",
|
|
39
|
+
"error",
|
|
40
|
+
`hyperframes@${HYPERFRAMES_VERSION}`,
|
|
41
|
+
], { cwd: home, timeoutMs: 10 * 60_000 });
|
|
42
|
+
if (code !== 0 || !existsSync(entry)) {
|
|
43
|
+
throw new Error(`HyperFrames konnte nicht installiert werden: ${out.slice(-400)}`);
|
|
44
|
+
}
|
|
45
|
+
return entry;
|
|
46
|
+
}
|
|
47
|
+
/** What the CLI and the renderer see: no keys of ours, no telemetry. */
|
|
48
|
+
function cliEnv() {
|
|
49
|
+
const pass = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR", "SHELL"];
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const k of pass) {
|
|
52
|
+
if (process.env[k]) {
|
|
53
|
+
out[k] = process.env[k];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// ffmpeg as the runtime finds it, first on the PATH.
|
|
57
|
+
if (env.ffmpegPath.includes("/")) {
|
|
58
|
+
out.PATH = `${dirname(env.ffmpegPath)}:${out.PATH ?? ""}`;
|
|
59
|
+
}
|
|
60
|
+
out.HYPERFRAMES_NO_TELEMETRY = "1";
|
|
61
|
+
out.HYPERFRAMES_SKIP_SKILLS = "1";
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Runs `hyperframes <args>` in a project folder and returns what it printed (the tail). */
|
|
65
|
+
export async function runHyperframes(args, opts) {
|
|
66
|
+
const cli = await hyperframesEntry();
|
|
67
|
+
return spawnText(process.execPath, [cli, ...args], {
|
|
68
|
+
cwd: opts.cwd,
|
|
69
|
+
signal: opts.signal,
|
|
70
|
+
timeoutMs: opts.timeoutMs ?? 20 * 60_000,
|
|
71
|
+
env: cliEnv(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function spawnText(bin, args, opts) {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
let out = "";
|
|
77
|
+
const child = spawn(bin, args, {
|
|
78
|
+
cwd: opts.cwd,
|
|
79
|
+
env: opts.env ?? cliEnv(),
|
|
80
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
+
signal: opts.signal,
|
|
82
|
+
});
|
|
83
|
+
const keep = (d) => {
|
|
84
|
+
out = (out + String(d)).slice(-30_000);
|
|
85
|
+
};
|
|
86
|
+
child.stdout.on("data", keep);
|
|
87
|
+
child.stderr.on("data", keep);
|
|
88
|
+
const timer = setTimeout(() => child.kill("SIGTERM"), opts.timeoutMs);
|
|
89
|
+
child.on("error", (err) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
resolve({ code: 1, out: `${out}\n${err.message}` });
|
|
92
|
+
});
|
|
93
|
+
child.on("close", (code) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
// Colours and progress bars mean nothing to a model reading this.
|
|
96
|
+
resolve({ code: code ?? 1, out: out.replace(ANSI, "") });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -100,6 +100,10 @@ async function call(method, path, body) {
|
|
|
100
100
|
}
|
|
101
101
|
return data;
|
|
102
102
|
}
|
|
103
|
+
/** The people of a tenant, as the Manage-App keeps them; they are managed there. */
|
|
104
|
+
export function tenantMembers(id) {
|
|
105
|
+
return call("GET", `/v1/tenants/${encodeURIComponent(id)}/members`);
|
|
106
|
+
}
|
|
103
107
|
const tenants = new Map();
|
|
104
108
|
/** Plan, limits and balance of a tenant, cached for a minute. */
|
|
105
109
|
export async function tenantInfo(id, fresh = false) {
|
|
@@ -47,6 +47,7 @@ async function apiKeyAuth(key) {
|
|
|
47
47
|
const who = {
|
|
48
48
|
userId: "local",
|
|
49
49
|
tenantId: LOCAL_TENANT,
|
|
50
|
+
role: "owner",
|
|
50
51
|
client: found.name || "MCP",
|
|
51
52
|
scopes: [...SCOPES],
|
|
52
53
|
};
|
|
@@ -64,6 +65,7 @@ async function apiKeyAuth(key) {
|
|
|
64
65
|
const who = {
|
|
65
66
|
userId: result.userId,
|
|
66
67
|
tenantId: result.tenantId,
|
|
68
|
+
role: result.role ?? "member",
|
|
67
69
|
client: result.name || "MCP",
|
|
68
70
|
scopes: [...SCOPES],
|
|
69
71
|
};
|
|
@@ -83,6 +85,7 @@ async function tokenAuth(token) {
|
|
|
83
85
|
const who = {
|
|
84
86
|
userId: claims.userId,
|
|
85
87
|
tenantId: claims.tenantId,
|
|
88
|
+
role: claims.role,
|
|
86
89
|
client: claims.clientId || "MCP",
|
|
87
90
|
scopes: SCOPES.filter((s) => granted.has(s)),
|
|
88
91
|
};
|
|
@@ -104,6 +107,7 @@ function localAuth(request, ticket) {
|
|
|
104
107
|
const who = {
|
|
105
108
|
userId: "local",
|
|
106
109
|
tenantId: LOCAL_TENANT,
|
|
110
|
+
role: "owner",
|
|
107
111
|
client,
|
|
108
112
|
scopes: [...SCOPES],
|
|
109
113
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createMcpHandler, McpServer, originValidationResponse, } from "@modelcontextprotocol/server";
|
|
2
2
|
import { withTenant } from "../db/client.js";
|
|
3
3
|
import { env } from "../env.js";
|
|
4
|
+
import { asRole } from "../services/access.js";
|
|
4
5
|
import { tenantStatus } from "../tenants/control.js";
|
|
5
6
|
import { authenticate, principalOf } from "./auth.js";
|
|
6
7
|
import { registerFlowApp } from "./flow-app.js";
|
|
@@ -47,6 +48,6 @@ export function mcpHandler() {
|
|
|
47
48
|
.catch(() => null);
|
|
48
49
|
noteRequest(who.tenantId, authInfo.clientId, who.client, body);
|
|
49
50
|
}
|
|
50
|
-
return withTenant(who.tenantId, () => handler.fetch(request, { authInfo }));
|
|
51
|
+
return withTenant(who.tenantId, () => asRole(who.role, () => handler.fetch(request, { authInfo })));
|
|
51
52
|
};
|
|
52
53
|
}
|