@engenty/wizards-runtime 0.7.7 → 0.7.9
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/authoring/guide.js +8 -3
- package/apps/runtime/dist/browser/fast/classifier.js +9 -4
- package/apps/runtime/dist/cli/main.js +1 -1
- package/apps/runtime/dist/cli/menu.js +5 -1
- package/apps/runtime/dist/cli/ollama.js +302 -0
- package/apps/runtime/dist/cli/setup.js +102 -2
- package/apps/runtime/dist/clients.js +118 -0
- package/apps/runtime/dist/db/client.js +37 -3
- package/apps/runtime/dist/db/migrations/tenant/0017_run_client.sql +1 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0017_snapshot.json +3333 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +2 -0
- package/apps/runtime/dist/decision-model.js +33 -0
- package/apps/runtime/dist/docs/dev/api.md +39 -0
- package/apps/runtime/dist/docs/dev/index.md +1 -0
- package/apps/runtime/dist/docs/dev/integration/openui.md +5 -3
- package/apps/runtime/dist/docs/dev/meta.json +1 -1
- package/apps/runtime/dist/docs/user/ai-apps.md +14 -3
- package/apps/runtime/dist/docs/user/command-line.md +1 -1
- package/apps/runtime/dist/docs/user/install.md +1 -0
- package/apps/runtime/dist/engine/decide.js +3 -1
- package/apps/runtime/dist/engine/runner.js +1 -0
- package/apps/runtime/dist/film/project.js +221 -65
- package/apps/runtime/dist/film/step.js +16 -5
- package/apps/runtime/dist/index.js +13 -4
- package/apps/runtime/dist/models.js +43 -3
- package/apps/runtime/dist/plugin.js +24 -8
- package/apps/runtime/dist/routes/runs.js +9 -0
- package/apps/runtime/dist/tools/code.js +53 -13
- package/apps/web/dist/assets/AboutDialog-CShc0mRo.js +1 -0
- package/apps/web/dist/assets/ChangelogDialog-C4dVSwtN.js +1 -0
- package/apps/web/dist/assets/ChatView-KkOboyOo.js +10 -0
- package/apps/web/dist/assets/EditorPage-lQ3YgsFT.js +4 -0
- package/apps/web/dist/assets/{FlowPreview-Dj2gAfuf.js → FlowPreview-oX_Emm7u.js} +1 -1
- package/apps/web/dist/assets/{Harness-SLbsntQF.js → Harness-D6mgLWTb.js} +2 -2
- package/apps/web/dist/assets/{NewWizardChannels-CsDMlji7.js → NewWizardChannels-CPlh_37J.js} +1 -1
- package/apps/web/dist/assets/NewWizardPage-D0FVJtXK.js +7 -0
- package/apps/web/dist/assets/OnboardingPage-BXPOMrQR.js +1 -0
- package/apps/web/dist/assets/ProjectPage-DroTprqs.js +1 -0
- package/apps/web/dist/assets/PublicRunner-j3qoKjQQ.js +1 -0
- package/apps/web/dist/assets/{ReadOnly-CK0piLUV.js → ReadOnly-CKgSQ2z1.js} +1 -1
- package/apps/web/dist/assets/RunDrawer-CJZ_ISAd.js +4 -0
- package/apps/web/dist/assets/RunnerView-BW1h4x63.js +1 -0
- package/apps/web/dist/assets/SettingsPage-BHZ0Z-og.js +1 -0
- package/apps/web/dist/assets/SetupPage-CDkYkjJX.js +1 -0
- package/apps/web/dist/assets/{SharePage-Cc3Opj2F.js → SharePage-BlVjVjHG.js} +1 -1
- package/apps/web/dist/assets/changelog-Be0i5Jjw.js +1 -0
- package/apps/web/dist/assets/engenty-WpMi3xwq.js +1 -0
- package/apps/web/dist/assets/{esm-CfnFE_jE.js → esm-C-ZWeuhU.js} +1 -1
- package/apps/web/dist/assets/{fluffy-engenty-ClRou7yu.js → fluffy-engenty-DPNHytrz.js} +135 -16
- package/apps/web/dist/assets/goose-helmet-Cvc7KjUg.bin +0 -0
- package/apps/web/dist/assets/goose-helmet-livery-CZD9u0XS.webp +0 -0
- package/apps/web/dist/assets/index-C4aHMcg9.css +1 -0
- package/apps/web/dist/assets/index-C61HAltK.js +14 -0
- package/apps/web/dist/assets/oss-credits-qGhcbqiM.js +1 -0
- package/apps/web/dist/assets/{phone-BWOB67tX.js → phone-BjmhIKuT.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-CEQlVs1O.js → settings-menu-Dpkp3f56.js} +1 -1
- package/apps/web/dist/assets/ui-DMmT-cSO.js +137 -0
- package/apps/web/dist/assets/{upload-pBKn2EFp.js → upload-JX9BcMTq.js} +1 -1
- package/apps/web/dist/assets/{lock-DAvRJY4B.js → wrench-BSxnPs2_.js} +1 -1
- package/apps/web/dist/index.html +4 -4
- package/apps/web/dist/install.sh +2 -2
- package/apps/web/dist/mcp/flow.html +142 -23
- package/node_modules/@engenty/wizards/package.json +1 -4
- package/node_modules/@engenty-wizards/shared/dist/claude-plugin.d.ts +7 -0
- package/node_modules/@engenty-wizards/shared/dist/claude-plugin.js +10 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +3 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.js +15 -0
- package/node_modules/@engenty-wizards/shared/dist/run.d.ts +2 -0
- package/node_modules/@engenty-wizards/shared/dist/run.js +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +28 -6
- package/plugin/README.md +58 -0
- package/plugin/hooks/engenty.ts +140 -0
- package/plugin/hooks/hooks.json +1 -0
- package/plugin/hooks/pane.test.tsx +177 -0
- package/plugin/hooks/register.tsx +396 -0
- package/plugin/types/index.d.ts +44 -0
- package/apps/web/dist/assets/AboutDialog-BP8-Ugxf.js +0 -1
- package/apps/web/dist/assets/ChangelogDialog-CD6CQNW9.js +0 -1
- package/apps/web/dist/assets/ChatView-4T0QarEr.js +0 -10
- package/apps/web/dist/assets/EditorPage-CWdV7Xdn.js +0 -3
- package/apps/web/dist/assets/NewWizardPage-_bFYjlgh.js +0 -7
- package/apps/web/dist/assets/OnboardingPage-DObQeCDg.js +0 -1
- package/apps/web/dist/assets/ProjectPage-DX-oNLWy.js +0 -1
- package/apps/web/dist/assets/PublicRunner-Bn9rVNEb.js +0 -1
- package/apps/web/dist/assets/RunDrawer-CVMTLse_.js +0 -4
- package/apps/web/dist/assets/RunnerView-Cjhuu9ua.js +0 -1
- package/apps/web/dist/assets/SettingsPage-DTzuiSAj.js +0 -1
- package/apps/web/dist/assets/SetupPage-D9jlhgiP.js +0 -1
- package/apps/web/dist/assets/changelog-CJNkd1_L.js +0 -1
- package/apps/web/dist/assets/engenty-eXZ-EAwd.js +0 -1
- package/apps/web/dist/assets/index-AfUa8xKD.css +0 -1
- package/apps/web/dist/assets/index-B5F1_qA_.js +0 -14
- package/apps/web/dist/assets/oss-credits-X2JPiMOR.js +0 -1
- package/apps/web/dist/assets/ui-BmR_4eL2.js +0 -137
- package/node_modules/@engenty/wizards/dist/openui/Wizard.d.ts +0 -20
- package/node_modules/@engenty/wizards/dist/openui/Wizard.js +0 -62
- package/node_modules/@engenty/wizards/dist/openui/index.d.ts +0 -9
- package/node_modules/@engenty/wizards/dist/openui/index.js +0 -9
- package/node_modules/@engenty/wizards/dist/openui/message.d.ts +0 -11
- package/node_modules/@engenty/wizards/dist/openui/message.js +0 -69
- package/node_modules/@engenty/wizards/dist/openui/run.d.ts +0 -8
- package/node_modules/@engenty/wizards/dist/openui/run.js +0 -137
- package/node_modules/@engenty/wizards/dist/openui/server.d.ts +0 -37
- package/node_modules/@engenty/wizards/dist/openui/server.js +0 -77
- package/plugin/.mcp.json +0 -8
|
@@ -127,6 +127,8 @@ export const run = sqliteTable("run", {
|
|
|
127
127
|
* (`data-page` of the embed tag, else the page's own address); null on the wizard's link.
|
|
128
128
|
*/
|
|
129
129
|
origin: text("origin"),
|
|
130
|
+
/** The client that started it over the public API (`x-wizards-client`): web, app, cli, or a registered id. */
|
|
131
|
+
client: text("client"),
|
|
130
132
|
status: text("status", {
|
|
131
133
|
enum: ["waiting_input", "running", "done", "failed", "cancelled"],
|
|
132
134
|
}).notNull(),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A decision model on the person's own machine: Cloudflare's Clef-flash (open weights, Apache
|
|
3
|
+
* 2.0), which Ollama 0.35 and newer serves at /v1/systemone with the same questions and answers
|
|
4
|
+
* as TypeSafe's Jev. The setup offers to pull it; the runtime asks it where no hosted decision
|
|
5
|
+
* model is set up.
|
|
6
|
+
*/
|
|
7
|
+
/** The model the setup pulls. Clef, its larger sibling, is taken as well where it is there. */
|
|
8
|
+
export const LOCAL_DECISION_MODEL = "clef-flash";
|
|
9
|
+
/** Ollama's own address from the one the runtime uses for its OpenAI-style API (…/v1). */
|
|
10
|
+
export function ollamaRoot(url) {
|
|
11
|
+
return url.trim().replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
12
|
+
}
|
|
13
|
+
/** The decision model among the models Ollama lists: Clef-flash, else Clef. */
|
|
14
|
+
export function decisionModelIn(names) {
|
|
15
|
+
const named = (base) => names.find((n) => n === base || n.startsWith(`${base}:`));
|
|
16
|
+
return named(LOCAL_DECISION_MODEL) ?? named("clef") ?? null;
|
|
17
|
+
}
|
|
18
|
+
/** The decision model the Ollama at `url` holds; null where it does not run or has none. */
|
|
19
|
+
export async function ollamaDecisionModel(url, timeoutMs = 1500) {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(`${ollamaRoot(url)}/api/tags`, {
|
|
22
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const { models } = (await res.json());
|
|
28
|
+
return decisionModelIn((models ?? []).map((m) => m.name));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The API
|
|
3
|
+
description: What a program may rely on when it talks to a runtime, and what is private.
|
|
4
|
+
version: 0.7.7
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
A runtime answers HTTP under `/api`. Three parts are a contract: they are versioned, documented
|
|
8
|
+
and change with notice. Everything else is the studio’s own and changes without notice.
|
|
9
|
+
|
|
10
|
+
## What a program may rely on
|
|
11
|
+
|
|
12
|
+
| Part | For | Signed in as |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `/api/v1/…` | programs that build and publish wizards: the spaces of a local install, importing a wizard | an access token of the account, with scopes (`wizards:read`, `wizards:write`, `wizards:publish`, `runs:test`); see [the Manage-App contract](https://github.com/engenty/engenty-wizards/blob/main/docs/manage-contract.md) |
|
|
15
|
+
| `/api/mcp` | AI apps: every tool of [the MCP server](../user/ai-apps.md) | the same tokens, or the local ticket `wizards mcp` carries |
|
|
16
|
+
| The public run API: `/api/public/wizards/:token`, `/api/public/wizards/:token/runs`, `/api/public/codes/:code`, `/api/runs/:id/…` | running a published wizard by its link: what the runner in a browser does | nobody; a visitor cookie, or the `x-wizards-visitor` header where a cookie does not survive |
|
|
17
|
+
|
|
18
|
+
## Private
|
|
19
|
+
|
|
20
|
+
`/api/studio/…`, `/api/auth/…` and `/api/internal/…` belong to the studio, the sign-in and the
|
|
21
|
+
Manage-App. They change with every release and are not meant for other programs. A program that
|
|
22
|
+
needs something only they have asks for it on `/api/v1`.
|
|
23
|
+
|
|
24
|
+
## Who you are: `x-wizards-client`
|
|
25
|
+
|
|
26
|
+
Every start of a run over the public API names the client that starts it, in the
|
|
27
|
+
`x-wizards-client` header: `web` is the runner in a browser, `app` the mobile app, `cli` the
|
|
28
|
+
command line. A client of your own sends its own id: lower-case letters, digits and hyphens.
|
|
29
|
+
The run keeps the id.
|
|
30
|
+
|
|
31
|
+
The id is no secret and proves nothing. It carries the terms a client runs under:
|
|
32
|
+
|
|
33
|
+
| Runtime | Does with the id |
|
|
34
|
+
|---|---|
|
|
35
|
+
| One that runs alone (your computer, your server) | Keeps it on the run. Nothing is refused: the API is yours |
|
|
36
|
+
| engenty.ai | Asks its list of clients: a registered id runs under its limits, an unknown one under the public limits, a revoked one not at all. The [terms of the hosted API](https://engenty.ai/api-terms) say how a client registers and what it may do |
|
|
37
|
+
|
|
38
|
+
An answer of `403` with `code: "client_refused"` means the id may not start runs there;
|
|
39
|
+
`429` with `code: "client_limit"` means it started too many this hour.
|
|
@@ -15,6 +15,7 @@ code, and the runtime loads it from its files while it runs.
|
|
|
15
15
|
| [Quick start](./plugins/quick-start.md) | A tool for AI steps in one file that works in two minutes |
|
|
16
16
|
| [Reference](./plugins/reference.md) | Every call, type, address and setting |
|
|
17
17
|
| [Examples](./plugins/examples.md) | Two plugins to read and copy |
|
|
18
|
+
| [The API](./api.md) | What a program may rely on, what is private, and how a client names itself |
|
|
18
19
|
|
|
19
20
|
## Wizards as files
|
|
20
21
|
|
|
@@ -11,9 +11,11 @@ author defined. `@engenty/wizards/openui` puts the two together: the chat decide
|
|
|
11
11
|
fits and what it already knows, the wizard then runs inside the conversation, and the chat
|
|
12
12
|
hears the result.
|
|
13
13
|
|
|
14
|
-
It is part of `@engenty/wizards`, the package for code outside engenty wizards
|
|
15
|
-
(
|
|
16
|
-
|
|
14
|
+
It is part of `@engenty/wizards`, the package on npm for code outside engenty wizards
|
|
15
|
+
(`npm install @engenty/wizards`;
|
|
16
|
+
[`packages/wizards/src/openui`](https://github.com/engenty/wizards/tree/main/packages/wizards/src/openui)
|
|
17
|
+
in the repository, under FSL-1.1-MIT; the runner it bundles is MIT). A whole chat to try it with
|
|
18
|
+
is `examples/openui-chat`.
|
|
17
19
|
|
|
18
20
|
## What OpenUI is
|
|
19
21
|
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
"description": "Extend engenty wizards with plugins: tools for AI steps, routes with their own tables, pages in the studio.",
|
|
4
4
|
"root": true,
|
|
5
5
|
"icon": "SquareTerminal",
|
|
6
|
-
"pages": ["index", "wizard-format", "plugins", "integration"]
|
|
6
|
+
"pages": ["index", "wizard-format", "plugins", "integration", "api"]
|
|
7
7
|
}
|
|
@@ -103,10 +103,21 @@ a minute, even with a token it already holds.
|
|
|
103
103
|
|
|
104
104
|
## The Claude Code plugin
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
Claude Code can also install a plugin. It adds the `/wizard` command, the skills to build and
|
|
107
|
+
use wizards, and `/wizards`, a pane beside the chat that lists your wizards and opens one in the
|
|
108
|
+
app’s browser pane. From GitHub, for the `engenty-wizards` server your AI apps already have:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
claude plugin marketplace add engenty/wizards
|
|
112
|
+
claude plugin install wizards@engenty
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
If engenty wizards has a public https address, it serves the plugin with its server bundled:
|
|
108
116
|
|
|
109
117
|
```bash
|
|
110
118
|
claude plugin marketplace add <your address>/api/claude-plugin/marketplace.json
|
|
111
|
-
claude plugin install
|
|
119
|
+
claude plugin install wizards@<your-host-with-dashes> # wizards.example.com → wizards-example-com
|
|
112
120
|
```
|
|
121
|
+
|
|
122
|
+
`claude plugin update wizards` brings the next version. In Claude Code’s auto mode the pane’s own
|
|
123
|
+
calls need allow rules; the plugin’s README names them.
|
|
@@ -11,7 +11,7 @@ version: 0.5.0
|
|
|
11
11
|
| `wizards` | Shows what is running, and a menu. While it runs, you can open the studio, restart or stop it; while it doesn’t, “Start in the background” or “Start in this terminal”. Then “Update”, which restarts it into the new version, “Start at login”, “Connect AI apps”, “Account”, “Status”, “Setup”, “Uninstall” and “Help”. The first time, the setup runs first. It needs a terminal |
|
|
12
12
|
| `wizards start` | Starts it and opens the studio. It runs in that terminal, and Ctrl-C stops it. The first time, the setup runs first |
|
|
13
13
|
| `wizards studio` | Opens the studio in your browser. If it isn’t running, it starts in the background first |
|
|
14
|
-
| `wizards setup` | Runs the guided setup again: your account, an AI client, the browser and ffmpeg, start at login, your AI apps. `--local` leaves the account out ([Local only](./local-only.md)) |
|
|
14
|
+
| `wizards setup` | Runs the guided setup again: your account, an AI client, the browser and ffmpeg, a decision model, start at login, your AI apps. `--local` leaves the account out ([Local only](./local-only.md)) |
|
|
15
15
|
| `wizards account` | Shows the account this computer is signed in to: who, which team, the credits. `sign-in` shows a link and a code and opens your browser; `sign-out` disconnects this computer |
|
|
16
16
|
| `wizards open` | Signs this browser in to the running studio. `--print` only prints the link; it works once, within a minute |
|
|
17
17
|
| `wizards status` | Shows what is installed and running, with the browser it uses for PDF, PNG and websites |
|
|
@@ -32,6 +32,7 @@ have is left alone), installs engenty wizards into `~/.engenty/wizards` (about 6
|
|
|
32
32
|
| Your engenty account | Asks “Sign in or create your account now?”. If you say yes, it shows a link and a code and opens your browser for the sign-in at account.engenty.ai; check that the page shows the same code (if you have no account yet, the page creates one with your invitation code, or puts you on the waitlist). While it waits, Enter opens the page again and Esc goes on without an account. Once you are signed in, what you publish on this computer runs in the cloud too. If you say no, `wizards account sign-in` does it any time. Setting it up again with `--local` signs an account out. `--local` leaves the account out altogether: see [Local only](./local-only.md) |
|
|
33
33
|
| An AI client | Looks for Codex, Claude Code, Antigravity and Cursor Agent and offers to install the ones that are missing. It puts first the one whose app (ChatGPT, Claude, Antigravity, Cursor) is on your Mac, as you most likely have that subscription already. You can also use your account’s credits, an API key, [Ollama](https://ollama.com) or, on a Mac with Apple silicon and macOS 26, Apple Intelligence: the studio’s [first page](./first-start.md) offers them |
|
|
34
34
|
| A browser and ffmpeg | Names the browser for PDF and PNG exports and steps on websites: the first of Chrome, Edge and Chromium it finds (on a Mac also Brave). engenty wizards uses that one; [`CHROME_PATH`](./command-line.md#settings) picks another. It also says whether ffmpeg is there for MP4 videos of animated widgets, and with Homebrew it offers to install it |
|
|
35
|
+
| A decision model (optional) | Offers to install [Ollama](https://ollama.com) if it isn’t there and to download Cloudflare’s Clef-flash into it (about 11 GB). The wizards then make their decisions, such as which branch to take, on your computer, without a key or credits. On a Mac the Ollama app goes into Applications and, when it first starts, asks once for your Mac’s password to set up its `ollama` command (cancel works too); on Linux Ollama’s own installer runs, installs `zstd` if it is missing, and asks for your password |
|
|
35
36
|
| Start at login (optional) | engenty wizards starts in the background when you log in, so links and AI apps can always reach it. It is a LaunchAgent on a Mac and a systemd user service on Linux |
|
|
36
37
|
| Your AI apps (optional) | Offers to add your wizards to the [AI apps](./ai-apps.md) it finds, such as Claude Desktop, Cursor or Codex |
|
|
37
38
|
| The command | If `~/.local/bin` is not on your PATH, it offers to add it in your shell’s settings file |
|
|
@@ -32,7 +32,9 @@ async function askSystemOne(input, signal) {
|
|
|
32
32
|
return readSystemOne(input.questions, await res.json());
|
|
33
33
|
}
|
|
34
34
|
if (!(RETRYABLE.has(res.status) && attempt < 2)) {
|
|
35
|
-
|
|
35
|
+
const why = (await res.text().catch(() => "")).trim().slice(0, 300);
|
|
36
|
+
access.refused?.(res.status, why);
|
|
37
|
+
console.warn(`[decide] the decision model answered ${res.status}${why ? `: ${why}` : ""}`);
|
|
36
38
|
return {};
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -174,6 +174,7 @@ export async function createRun(input) {
|
|
|
174
174
|
origin: input.origin ?? null,
|
|
175
175
|
lang: input.lang ?? null,
|
|
176
176
|
unattended: input.unattended ?? false,
|
|
177
|
+
client: input.client ?? null,
|
|
177
178
|
status: first.type === "page" ? "waiting_input" : "running",
|
|
178
179
|
cursor: first.id,
|
|
179
180
|
state,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, normalize } from "node:path";
|
|
5
5
|
import { dataRef, FILM_SIZES } from "@engenty-wizards/shared/definition";
|
|
6
6
|
import { gradientCss, ruleLines, textStyleLine } from "@engenty-wizards/shared/projects";
|
|
@@ -52,21 +52,43 @@ async function put(root, rel, data) {
|
|
|
52
52
|
await writeFile(path, data);
|
|
53
53
|
}
|
|
54
54
|
const safeName = (name) => name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "file";
|
|
55
|
-
|
|
55
|
+
/** Where the client starts, by how film/ began. */
|
|
56
|
+
function startRules(start) {
|
|
57
|
+
if (start === "base") {
|
|
58
|
+
return `- **What to make:** BRIEF.md. **Start here:** film/ is already the wizard's base project in this
|
|
59
|
+
format — read film/PARTS.md first: its scene templates carry the look. Compose the film from them in
|
|
60
|
+
the one file PARTS.md names, timed to the voice; write a new scene only where no template fits, then
|
|
61
|
+
in the style of showcase/ (SHOWCASE.md says how each technique is made).
|
|
62
|
+
- **The brand:** film/brand/ holds brand.json and the logos. If film/brand/theme.json is missing, make
|
|
63
|
+
it first as film/BRAND.md says — it is kept for the next films of this space, so get it right.
|
|
64
|
+
Colours, logo and fonts come from there only.
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
if (start === "from") {
|
|
68
|
+
return `- **What to make:** BRIEF.md. **Start here:** film/ holds a finished film of this run in another
|
|
69
|
+
format. Lay it out for this one: scenes, timing, words and sound stay; only the layout changes
|
|
70
|
+
(film/PARTS.md says how when it exists). The look is showcase/'s.
|
|
71
|
+
- **The brand:** film/brand/ when it is there, else brand/brand.json and brand/logos/ — colours, logo
|
|
72
|
+
and fonts come from there only.
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
return `- **What to make:** BRIEF.md. **The bar:** showcase/ (read showcase/SHOWCASE.md and showcase/frame.md
|
|
76
|
+
first when it exists, look at its stills, reuse its helpers from showcase/assets/). **The brand:**
|
|
77
|
+
brand/brand.json and brand/logos/ — colours, logo and fonts come from there only.
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
function rules(step, skills, start) {
|
|
56
81
|
const size = FILM_SIZES[step.format];
|
|
57
82
|
return `# The film folder
|
|
58
83
|
|
|
59
84
|
You make one film here, autonomously, to the end. Nobody answers questions: where a skill asks the
|
|
60
85
|
person, decide yourself and note it in PROGRESS.md.
|
|
61
86
|
|
|
62
|
-
- **
|
|
63
|
-
first when it exists, look at its stills, reuse its helpers from showcase/assets/). **The brand:**
|
|
64
|
-
brand/brand.json and brand/logos/ — colours, logo and fonts come from there only.
|
|
65
|
-
- **Material:** input/ (see input/README.md). Use nothing from the web; you have no network.
|
|
87
|
+
${startRules(start)}- **Material:** input/ (see input/README.md). Use nothing from the web; you have no network.
|
|
66
88
|
- **Skills:** ${skills.join(", ")} in .claude/skills/<name>/SKILL.md. Start with \`hyperframes\` and
|
|
67
89
|
read the skill files directly; do not update or install skills.
|
|
68
|
-
- **The HyperFrames project is film/** (${size.width}×${size.height}
|
|
69
|
-
|
|
90
|
+
- **The HyperFrames project is film/** (${size.width}×${size.height}). Keep every file of the film
|
|
91
|
+
inside film/.
|
|
70
92
|
- **Running HyperFrames:** only through the \`hyperframes\` tool (MCP server \`film\`), e.g.
|
|
71
93
|
\`["lint","film"]\`, \`["check","film"]\`, \`["snapshot","film","--at","1,4,8"]\`,
|
|
72
94
|
\`["render","film","-q","draft"]\`, \`["transcribe","input/voice/l01.wav","--json","-d","input/voice"]\`.
|
|
@@ -86,57 +108,25 @@ person, decide yourself and note it in PROGRESS.md.
|
|
|
86
108
|
- Install nothing (no pip, brew, npm).
|
|
87
109
|
`;
|
|
88
110
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.update(JSON.stringify([
|
|
96
|
-
brief,
|
|
97
|
-
Object.values(step.inputs).map((raw) => {
|
|
98
|
-
const ref = dataRef(raw);
|
|
99
|
-
const [head, id] = ref.split(".");
|
|
100
|
-
return head === "steps" ? ctx.state.outputs[id]?.at : ctx.state.values[ref];
|
|
101
|
-
}),
|
|
102
|
-
]))
|
|
103
|
-
.digest("hex");
|
|
104
|
-
const stampFile = join(root, ".engenty", "inputs");
|
|
105
|
-
const same = existsSync(stampFile) && (await readFile(stampFile, "utf8")) === stamp;
|
|
106
|
-
if (same && existsSync(join(root, "film", "index.html")) && (await savedSession(root))) {
|
|
107
|
-
return { root, brief };
|
|
108
|
-
}
|
|
109
|
-
await rm(root, { recursive: true, force: true });
|
|
110
|
-
await mkdir(root, { recursive: true });
|
|
111
|
-
const skills = await placeSkills(step.skills, ctx.files, join(root, ".claude", "skills"));
|
|
112
|
-
const size = FILM_SIZES[step.format];
|
|
113
|
-
await put(root, "BRIEF.md", `# Brief\n\nFormat ${step.format}, ${size.width}×${size.height}${step.seconds ? `, about ${step.seconds} s` : ""}. Language of all text on screen: the brief's language.\n\n${brief}\n`);
|
|
114
|
-
const text = rules(step, skills);
|
|
115
|
-
await put(root, "CLAUDE.md", text);
|
|
116
|
-
await put(root, "AGENTS.md", text);
|
|
117
|
-
// The style kit.
|
|
118
|
-
if (step.kit) {
|
|
119
|
-
const file = ctx.files.find((f) => f.path === step.kit);
|
|
120
|
-
if (file) {
|
|
121
|
-
const entries = unzipSync(new Uint8Array(await getBlob(file.hash)));
|
|
122
|
-
for (const [name, data] of Object.entries(entries)) {
|
|
123
|
-
if (!name.endsWith("/")) {
|
|
124
|
-
await put(join(root, "showcase"), name.replace(/^showcase\//, ""), data);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
// The space's brand.
|
|
111
|
+
const BRAND_STAMP = join(".engenty", "brand");
|
|
112
|
+
/**
|
|
113
|
+
* Writes the space's brand into brand/ as it is now (brand.json, logos). True when it differs
|
|
114
|
+
* from what the folder had before — the film's code then still carries the old one.
|
|
115
|
+
*/
|
|
116
|
+
async function writeBrand(root, ctx) {
|
|
130
117
|
const b = ctx.scope.brand;
|
|
131
|
-
const
|
|
118
|
+
const hash = createHash("sha256");
|
|
119
|
+
const files = [];
|
|
120
|
+
const fileName = (dir, f) => `${dir}/${safeName(f.name.replace(/\.[^.]+$/, ""))}.${extFor(f.mime) === "bin" ? (f.name.split(".").pop() ?? "bin") : extFor(f.mime)}`;
|
|
132
121
|
const patterns = [];
|
|
133
122
|
for (const p of b.patterns ?? []) {
|
|
134
123
|
const f = ctx.projectFiles.find((x) => x.id === p.file);
|
|
135
124
|
try {
|
|
136
125
|
if (f) {
|
|
137
126
|
const { data } = await projectFileContent(f.projectId, f.id);
|
|
138
|
-
const name =
|
|
139
|
-
|
|
127
|
+
const name = fileName("patterns", f);
|
|
128
|
+
files.push({ name, data });
|
|
129
|
+
hash.update(name).update(data);
|
|
140
130
|
patterns.push({
|
|
141
131
|
file: name,
|
|
142
132
|
name: p.name,
|
|
@@ -149,11 +139,13 @@ export async function prepareFilmFolder(step, ctx) {
|
|
|
149
139
|
// a pattern less
|
|
150
140
|
}
|
|
151
141
|
}
|
|
142
|
+
const logos = [];
|
|
152
143
|
for (const f of ctx.projectFiles.filter((p) => p.kind === "logo")) {
|
|
153
144
|
try {
|
|
154
145
|
const { data } = await projectFileContent(f.projectId, f.id);
|
|
155
|
-
const name =
|
|
156
|
-
|
|
146
|
+
const name = fileName("logos", f);
|
|
147
|
+
files.push({ name, data });
|
|
148
|
+
hash.update(name).update(data);
|
|
157
149
|
const section = logoSectionLine(b.logoSections, f.id);
|
|
158
150
|
logos.push({
|
|
159
151
|
file: name,
|
|
@@ -165,7 +157,7 @@ export async function prepareFilmFolder(step, ctx) {
|
|
|
165
157
|
// a logo less
|
|
166
158
|
}
|
|
167
159
|
}
|
|
168
|
-
|
|
160
|
+
const json = JSON.stringify({
|
|
169
161
|
note: "The space's brand. Colours are named by what they are for; the first is the accent. A logo's section, a gradient's or a pattern's description says when to use it; colorRules, logoRules and typeRules, where there are any, say how colours, logos and type may be used; text is set in the fonts — a font with `uses` that leave out video is not used in a film: its text gets a system font.",
|
|
170
162
|
name: b.name ?? "",
|
|
171
163
|
about: b.about ?? "",
|
|
@@ -199,16 +191,149 @@ export async function prepareFilmFolder(step, ctx) {
|
|
|
199
191
|
: {}),
|
|
200
192
|
...(b.textStyles?.length
|
|
201
193
|
? {
|
|
202
|
-
textStyles: b.textStyles.map((
|
|
203
|
-
name:
|
|
204
|
-
style: textStyleLine(
|
|
194
|
+
textStyles: b.textStyles.map((t) => ({
|
|
195
|
+
name: t.name,
|
|
196
|
+
style: textStyleLine(t, b.fonts ?? []),
|
|
205
197
|
})),
|
|
206
198
|
}
|
|
207
199
|
: {}),
|
|
208
200
|
...(b.typeRules?.length ? { typeRules: ruleLines(b.typeRules).split("\n") } : {}),
|
|
209
201
|
...(b.logoRules?.length ? { logoRules: ruleLines(b.logoRules).split("\n") } : {}),
|
|
210
202
|
facts: (b.facts ?? []).map((f) => `${f.label}: ${f.value}`),
|
|
211
|
-
}, null, 1)
|
|
203
|
+
}, null, 1);
|
|
204
|
+
const stamp = hash.update(json).digest("hex");
|
|
205
|
+
let changed = false;
|
|
206
|
+
try {
|
|
207
|
+
changed = (await readFile(join(root, BRAND_STAMP), "utf8")) !== stamp;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// A folder from before the stamp: its brand.json tells; a new folder has none.
|
|
211
|
+
const old = await readFile(join(root, "brand", "brand.json"), "utf8").catch(() => null);
|
|
212
|
+
changed = old !== null && old !== json;
|
|
213
|
+
}
|
|
214
|
+
await rm(join(root, "brand"), { recursive: true, force: true });
|
|
215
|
+
for (const f of files) {
|
|
216
|
+
await put(join(root, "brand"), f.name, f.data);
|
|
217
|
+
}
|
|
218
|
+
await put(join(root, "brand"), "brand.json", json);
|
|
219
|
+
await put(root, BRAND_STAMP, stamp);
|
|
220
|
+
return { changed, stamp };
|
|
221
|
+
}
|
|
222
|
+
const START = join(".engenty", "start");
|
|
223
|
+
const BASE = join(".engenty", "base");
|
|
224
|
+
/**
|
|
225
|
+
* The brand base: what a film made once from the space's brand for the kit's base (the base's
|
|
226
|
+
* BRAND.md says what — theme.json at least), kept per space for the kit and the brand as they are.
|
|
227
|
+
* A changed brand or kit is another key, so it is made again.
|
|
228
|
+
*/
|
|
229
|
+
function brandBaseDir(ctx, kitHash, brandStamp) {
|
|
230
|
+
const key = createHash("sha256").update(`${kitHash}:${brandStamp}`).digest("hex").slice(0, 24);
|
|
231
|
+
return join(env.dataDir, "brand-bases", ctx.project.id, key);
|
|
232
|
+
}
|
|
233
|
+
/** What in film/brand/ is the brand base: everything but the brand itself. */
|
|
234
|
+
const isBrandItself = (name) => name === "brand.json" || name === "logos" || name === "patterns";
|
|
235
|
+
/**
|
|
236
|
+
* Brings the brand into a project that started from a base: brand.json and the logos as they are
|
|
237
|
+
* now, and the brand base kept for them when there is one. A base that no longer fits the brand
|
|
238
|
+
* is removed — the film makes it again, as BRAND.md says.
|
|
239
|
+
*/
|
|
240
|
+
async function brandIntoFilm(root, brandChanged) {
|
|
241
|
+
const base = await readFile(join(root, BASE), "utf8").catch(() => "");
|
|
242
|
+
if (!base) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const target = join(root, "film", "brand");
|
|
246
|
+
await mkdir(target, { recursive: true });
|
|
247
|
+
for (const name of await readdir(target)) {
|
|
248
|
+
if (isBrandItself(name) || (brandChanged && !existsSync(base))) {
|
|
249
|
+
await rm(join(target, name), { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
await cp(join(root, "brand"), target, { recursive: true });
|
|
253
|
+
if (existsSync(base) && (brandChanged || !existsSync(join(target, "theme.json")))) {
|
|
254
|
+
await cp(base, target, { recursive: true });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** A new brand is another brand base: the marker points at the one for the brand as it is now. */
|
|
258
|
+
async function refreshBrandBase(root, ctx, step) {
|
|
259
|
+
if (!existsSync(join(root, BASE))) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const kitHash = ctx.files.find((f) => f.path === step.kit)?.hash ?? "";
|
|
263
|
+
const stamp = await readFile(join(root, BRAND_STAMP), "utf8");
|
|
264
|
+
await put(root, BASE, brandBaseDir(ctx, kitHash, stamp));
|
|
265
|
+
}
|
|
266
|
+
/** After a film: keeps what it made from the brand for the next films in this space. */
|
|
267
|
+
export async function keepBrandBase(root) {
|
|
268
|
+
const base = await readFile(join(root, BASE), "utf8").catch(() => "");
|
|
269
|
+
const source = join(root, "film", "brand");
|
|
270
|
+
if (!base || !existsSync(join(source, "theme.json"))) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
await rm(base, { recursive: true, force: true });
|
|
274
|
+
await mkdir(base, { recursive: true });
|
|
275
|
+
for (const name of await readdir(source)) {
|
|
276
|
+
if (!isBrandItself(name)) {
|
|
277
|
+
await cp(join(source, name), join(base, name), { recursive: true });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Sets up the folder for a first attempt; a later attempt finds it as it was left. */
|
|
282
|
+
export async function prepareFilmFolder(step, ctx) {
|
|
283
|
+
const root = filmRoot(ctx.runId, step.id);
|
|
284
|
+
const brief = renderTemplate(step.brief, ctx.scope).trim();
|
|
285
|
+
// An earlier film to start from, when it ran.
|
|
286
|
+
const fromId = step.from ? dataRef(step.from).replace(/^steps\./, "") : null;
|
|
287
|
+
const fromDir = fromId ? join(filmRoot(ctx.runId, fromId), "film") : null;
|
|
288
|
+
const from = fromId && fromDir && existsSync(join(fromDir, "index.html")) ? fromId : null;
|
|
289
|
+
// The same brief and material as last time: the film is there to go on with or to change.
|
|
290
|
+
const stamp = createHash("sha256")
|
|
291
|
+
.update(JSON.stringify([
|
|
292
|
+
brief,
|
|
293
|
+
Object.values(step.inputs).map((raw) => {
|
|
294
|
+
const ref = dataRef(raw);
|
|
295
|
+
const [head, id] = ref.split(".");
|
|
296
|
+
return head === "steps" ? ctx.state.outputs[id]?.at : ctx.state.values[ref];
|
|
297
|
+
}),
|
|
298
|
+
from ? ctx.state.outputs[from]?.at : null,
|
|
299
|
+
]))
|
|
300
|
+
.digest("hex");
|
|
301
|
+
const stampFile = join(root, ".engenty", "inputs");
|
|
302
|
+
const same = existsSync(stampFile) && (await readFile(stampFile, "utf8")) === stamp;
|
|
303
|
+
if (same && existsSync(join(root, "film", "index.html")) && (await savedSession(root))) {
|
|
304
|
+
// The brand is the space's as it is now, also for a change: colours or a logo may be new.
|
|
305
|
+
const { changed } = await writeBrand(root, ctx);
|
|
306
|
+
const start = ((await readFile(join(root, START), "utf8").catch(() => "")) ||
|
|
307
|
+
"init");
|
|
308
|
+
if (changed) {
|
|
309
|
+
await refreshBrandBase(root, ctx, step);
|
|
310
|
+
}
|
|
311
|
+
await brandIntoFilm(root, changed);
|
|
312
|
+
return { root, brief, brandChanged: changed, start };
|
|
313
|
+
}
|
|
314
|
+
await rm(root, { recursive: true, force: true });
|
|
315
|
+
await mkdir(root, { recursive: true });
|
|
316
|
+
const skills = await placeSkills(step.skills, ctx.files, join(root, ".claude", "skills"));
|
|
317
|
+
const size = FILM_SIZES[step.format];
|
|
318
|
+
await put(root, "BRIEF.md", `# Brief\n\nFormat ${step.format}, ${size.width}×${size.height}${step.seconds ? `, about ${step.seconds} s` : ""}. Language of all text on screen: the brief's language.\n\n${brief}\n`);
|
|
319
|
+
// The style kit: the showcase to look at, and the base a film starts from when it has one.
|
|
320
|
+
const kitFile = step.kit ? ctx.files.find((f) => f.path === step.kit) : undefined;
|
|
321
|
+
const base = {};
|
|
322
|
+
if (kitFile) {
|
|
323
|
+
const entries = unzipSync(new Uint8Array(await getBlob(kitFile.hash)));
|
|
324
|
+
for (const [name, data] of Object.entries(entries)) {
|
|
325
|
+
if (name.endsWith("/")) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (name.startsWith("base/")) {
|
|
329
|
+
base[name.slice("base/".length)] = data;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
await put(join(root, "showcase"), name.replace(/^showcase\//, ""), data);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const { stamp: brandStamp } = await writeBrand(root, ctx);
|
|
212
337
|
// What the run made before.
|
|
213
338
|
const listed = [];
|
|
214
339
|
for (const [name, raw] of Object.entries(step.inputs)) {
|
|
@@ -246,12 +371,43 @@ export async function prepareFilmFolder(step, ctx) {
|
|
|
246
371
|
}
|
|
247
372
|
await put(join(root, "input"), "README.md", `# Material for this film\n\n${listed.join("\n") || "(none)"}\n\nVoice lines play one after another in their order; their lengths decide the timing.\n`);
|
|
248
373
|
await cp(await soundCatalog(), join(root, "audio-catalog"), { recursive: true });
|
|
249
|
-
// The HyperFrames project
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
374
|
+
// The HyperFrames project: the earlier film, the kit's base in this format, or a new one.
|
|
375
|
+
const index = `index.${step.format.replace(":", "x")}.html`;
|
|
376
|
+
let start;
|
|
377
|
+
if (from && fromDir) {
|
|
378
|
+
start = "from";
|
|
379
|
+
await cp(fromDir, join(root, "film"), {
|
|
380
|
+
recursive: true,
|
|
381
|
+
filter: (path) => !/\/(renders|snapshots)(\/|$)/.test(path.slice(fromDir.length)),
|
|
382
|
+
});
|
|
383
|
+
const fromBase = await readFile(join(filmRoot(ctx.runId, from), BASE), "utf8").catch(() => "");
|
|
384
|
+
if (fromBase) {
|
|
385
|
+
await put(root, BASE, fromBase);
|
|
386
|
+
}
|
|
254
387
|
}
|
|
388
|
+
else if (base[index]) {
|
|
389
|
+
start = "base";
|
|
390
|
+
for (const [name, data] of Object.entries(base)) {
|
|
391
|
+
if (!/^index\.\d+x\d+\.html$/.test(name)) {
|
|
392
|
+
await put(join(root, "film"), name, data);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
await put(join(root, "film"), "index.html", base[index]);
|
|
396
|
+
await put(root, BASE, brandBaseDir(ctx, kitFile?.hash ?? "", brandStamp));
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
start = "init";
|
|
400
|
+
const preset = { "16:9": "landscape", "9:16": "portrait", "1:1": "square" }[step.format];
|
|
401
|
+
const init = await runHyperframes(["init", "film", "--non-interactive", "--skip-transcribe", `--resolution=${preset}`], { cwd: root, signal: ctx.signal, timeoutMs: 5 * 60_000 });
|
|
402
|
+
if (init.code !== 0 || !existsSync(join(root, "film"))) {
|
|
403
|
+
throw new Error(`The film project could not be set up: ${init.out.slice(-300)}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
await brandIntoFilm(root, false);
|
|
407
|
+
const text = rules(step, skills, start);
|
|
408
|
+
await put(root, "CLAUDE.md", text);
|
|
409
|
+
await put(root, "AGENTS.md", text);
|
|
410
|
+
await put(root, START, start);
|
|
255
411
|
await put(root, ".engenty/inputs", stamp);
|
|
256
|
-
return { root, brief };
|
|
412
|
+
return { root, brief, brandChanged: false, start };
|
|
257
413
|
}
|
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { readFile, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { isAbsolute, join, normalize } from "node:path";
|
|
5
|
+
import { FILM_SIZES } from "@engenty-wizards/shared/definition";
|
|
5
6
|
import { note as runNote } from "@engenty-wizards/shared/run";
|
|
6
7
|
import { StepError } from "../engine/types.js";
|
|
7
8
|
import { CLAUDE_BIN, claudeEnv } from "../harness/claude.js";
|
|
@@ -9,7 +10,7 @@ import { failureOf } from "../harness/model.js";
|
|
|
9
10
|
import { openBridge } from "../mcp/bridge.js";
|
|
10
11
|
import { saidOf } from "../model-errors.js";
|
|
11
12
|
import { filmClient } from "../models.js";
|
|
12
|
-
import { prepareFilmFolder, savedSession, saveSession } from "./project.js";
|
|
13
|
+
import { keepBrandBase, prepareFilmFolder, savedSession, saveSession } from "./project.js";
|
|
13
14
|
import { runHyperframes } from "./tools.js";
|
|
14
15
|
/**
|
|
15
16
|
* A film step: the installed client (Claude Code) works in the film's folder with the skills,
|
|
@@ -99,7 +100,7 @@ export async function runFilmStep(step, ctx) {
|
|
|
99
100
|
}
|
|
100
101
|
const { client, alias } = found;
|
|
101
102
|
await ctx.emit("info", { code: "filmSetup" });
|
|
102
|
-
const { root, brief } = await prepareFilmFolder(step, ctx);
|
|
103
|
+
const { root, brief, brandChanged, start } = await prepareFilmFolder(step, ctx);
|
|
103
104
|
const resume = await savedSession(root);
|
|
104
105
|
await ctx.emit("info", resume ? { code: "filmResume" } : { code: "filmClient", params: { client: client.name } });
|
|
105
106
|
const limit = AbortSignal.any([ctx.signal, AbortSignal.timeout(LIMIT_MS)]);
|
|
@@ -244,11 +245,20 @@ export async function runFilmStep(step, ctx) {
|
|
|
244
245
|
try {
|
|
245
246
|
// A review's note asks for a change: the film's code is there, so it is edited, not redone.
|
|
246
247
|
const note = ctx.state.notes[step.id]?.trim();
|
|
248
|
+
// A brand changed in the space since the film was made: the film's code carries the old one.
|
|
249
|
+
const brandNote = !brandChanged
|
|
250
|
+
? ""
|
|
251
|
+
: start === "init"
|
|
252
|
+
? "\n\nThe space's brand has changed since the film was made: brand/brand.json and brand/logos/ are new. Carry them into the film wherever it uses the brand (its own copies included), so every colour, logo and name is the new one, and check the frames for contrast."
|
|
253
|
+
: "\n\nThe space's brand has changed since the film was made: film/brand/brand.json and the logos are new. If film/brand/theme.json is missing, make it again as film/BRAND.md says. Make sure every colour, logo and name in the film is the new one, and check the frames for contrast.";
|
|
254
|
+
const size = FILM_SIZES[step.format];
|
|
247
255
|
const prompt = resume && note
|
|
248
|
-
? `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.`
|
|
256
|
+
? `The person watched the film and asks for this change:\n\n${note}${brandNote}\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.`
|
|
249
257
|
: resume
|
|
250
|
-
?
|
|
251
|
-
:
|
|
258
|
+
? `Carry on with the film where you stopped. Read PROGRESS.md and the folder to see where that was, then finish it.${brandNote}`
|
|
259
|
+
: start === "from"
|
|
260
|
+
? `film/ holds this run's film in another format. Lay it out for ${step.format} (${size.width}×${size.height}), following CLAUDE.md: set the size of film/index.html, then make every scene fit — scenes, timing, words and sound stay. Check the frames, render a draft, and end with a short summary of what you changed.\n\nThe brief, for reference:\n\n${brief}`
|
|
261
|
+
: `Make the film BRIEF.md asks for, following CLAUDE.md.\n\n${brief}`;
|
|
252
262
|
const first = await turn(prompt, resume);
|
|
253
263
|
summary = typeof first.result === "string" ? first.result.trim() : "";
|
|
254
264
|
// The final render is ours: it does not depend on the client still running. What fails
|
|
@@ -277,6 +287,7 @@ export async function runFilmStep(step, ctx) {
|
|
|
277
287
|
finally {
|
|
278
288
|
bridge.close();
|
|
279
289
|
}
|
|
290
|
+
await keepBrandBase(root).catch(() => { });
|
|
280
291
|
const ref = await ctx.saveAsset({
|
|
281
292
|
stepId: step.id,
|
|
282
293
|
kind: "video",
|