wizards 0.2.20 → 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.
Files changed (62) hide show
  1. package/apps/runtime/dist/authoring/guide.js +32 -4
  2. package/apps/runtime/dist/engine/prepare.js +37 -2
  3. package/apps/runtime/dist/engine/requirements.js +22 -1
  4. package/apps/runtime/dist/engine/steps.js +8 -1
  5. package/apps/runtime/dist/engine/template.js +8 -0
  6. package/apps/runtime/dist/files/storage.js +8 -1
  7. package/apps/runtime/dist/film/project.js +195 -0
  8. package/apps/runtime/dist/film/skills.js +91 -0
  9. package/apps/runtime/dist/film/sounds.js +122 -0
  10. package/apps/runtime/dist/film/step.js +286 -0
  11. package/apps/runtime/dist/film/tools.js +99 -0
  12. package/apps/runtime/dist/media/transcribe.js +150 -0
  13. package/apps/runtime/dist/models.js +24 -0
  14. package/apps/runtime/dist/render/downloads.js +1 -1
  15. package/apps/runtime/dist/widgets/render.js +20 -3
  16. package/apps/runtime/dist/widgets/runtime.js +2 -1
  17. package/apps/runtime/dist/widgets/step.js +16 -0
  18. package/apps/web/dist/assets/{Account-0f8W571o.js → Account-BxpaLgg6.js} +1 -1
  19. package/apps/web/dist/assets/{ChangelogDialog-BBmJedTW.js → ChangelogDialog-Ds9XgxMP.js} +1 -1
  20. package/apps/web/dist/assets/{ChatPanel-Bi3tnxOD.js → ChatPanel-CGL-SaYv.js} +1 -1
  21. package/apps/web/dist/assets/{CreditsDialog-C_lMgenE.js → CreditsDialog-DZPOim-g.js} +1 -1
  22. package/apps/web/dist/assets/{EditorPage-2-DypFBB.js → EditorPage-DJDTvELx.js} +1 -1
  23. package/apps/web/dist/assets/{FlowPreview-DBroqouz.js → FlowPreview-DObLaWK0.js} +1 -1
  24. package/apps/web/dist/assets/{Harness-Dkw5Zldw.js → Harness-BEIJfBYu.js} +1 -1
  25. package/apps/web/dist/assets/{HomePage-D9f7DKEw.js → HomePage-BxTQiJog.js} +1 -1
  26. package/apps/web/dist/assets/{ImportWizard-Br-Ys-Np.js → ImportWizard-fp4Q1iZ6.js} +1 -1
  27. package/apps/web/dist/assets/{LocalRuntime-CbA3kdFH.js → LocalRuntime-C1izy1g5.js} +1 -1
  28. package/apps/web/dist/assets/NewWizardPage-BDOoKp6y.js +2 -0
  29. package/apps/web/dist/assets/{ProjectPage-CWn7fwSr.js → ProjectPage-Cs86dGVd.js} +1 -1
  30. package/apps/web/dist/assets/{PublicRunner-DFUM0aP5.js → PublicRunner-B53p-hWG.js} +1 -1
  31. package/apps/web/dist/assets/{RunnerView-BAmePlD3.js → RunnerView-CrgDUD9U.js} +2 -2
  32. package/apps/web/dist/assets/SettingsPage-07vGl-P6.js +1 -0
  33. package/apps/web/dist/assets/{SetupPage-GWL_IhbR.js → SetupPage-Ba0WirOM.js} +1 -1
  34. package/apps/web/dist/assets/{SharePage-DZzIMn0m.js → SharePage-J7eD3paN.js} +1 -1
  35. package/apps/web/dist/assets/{api-Byy4NRvQ.js → api-BkWIPEWf.js} +1 -1
  36. package/apps/web/dist/assets/changelog-D4wD3iMl.js +1 -0
  37. package/apps/web/dist/assets/definition-BznNdlhf.js +55 -0
  38. package/apps/web/dist/assets/{esm-WstF63Ly.js → esm-Bk4BRENl.js} +1 -1
  39. package/apps/web/dist/assets/image-CC_2mJd6.js +1 -0
  40. package/apps/web/dist/assets/{index-udMtduDM.js → index-CJuJLvrl.js} +3 -3
  41. package/apps/web/dist/assets/meta-DMOYx_c4.js +1 -0
  42. package/apps/web/dist/assets/{settings-menu-GUFlmH3n.js → settings-menu-BI5cH97k.js} +1 -1
  43. package/apps/web/dist/assets/ui-CiYJabAi.js +1 -0
  44. package/apps/web/dist/assets/volume-2-DE67_rue.js +1 -0
  45. package/apps/web/dist/index.html +3 -3
  46. package/apps/web/dist/mcp/flow.html +53 -53
  47. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +146 -0
  48. package/node_modules/@engenty-wizards/shared/dist/definition.js +101 -0
  49. package/node_modules/@engenty-wizards/shared/dist/marketplace-entry.js +3 -0
  50. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +6 -1
  51. package/node_modules/@engenty-wizards/shared/dist/marketplace.js +12 -0
  52. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +30 -0
  53. package/node_modules/@engenty-wizards/shared/dist/run.js +18 -0
  54. package/package.json +1 -1
  55. package/apps/web/dist/assets/NewWizardPage-Cvt9fKwq.js +0 -2
  56. package/apps/web/dist/assets/SettingsPage-CLLlnzeS.js +0 -1
  57. package/apps/web/dist/assets/changelog-DMAoeotb.js +0 -1
  58. package/apps/web/dist/assets/definition-Dp0DR2D6.js +0 -55
  59. package/apps/web/dist/assets/image-BShbErBm.js +0 -1
  60. package/apps/web/dist/assets/meta-CUWuppZ0.js +0 -1
  61. package/apps/web/dist/assets/ui-CwU0VR_B.js +0 -1
  62. package/apps/web/dist/assets/volume-2-Bp2tyaKE.js +0 -1
@@ -67,6 +67,8 @@ type Field = {
67
67
  // every file costs something later (a video clip per photo)
68
68
  camera?: boolean // file: also offer the camera, for papers the person has not scanned (image always offers it)
69
69
  video?: boolean // file: the camera also records a short clip (up to 30 s); the clip is a file like any other
70
+ listen?: boolean // file: a recording (video/audio) is listened to before the next step: {{field.speech}} gives what is
71
+ // said piece by piece between pauses, with exact times "[12.40–15.10] text" — to cut slips and pauses
70
72
  scan?: boolean // text: a "scan" button fills it from a QR code or barcode (serial number, ticket, article number)
71
73
  connection?: connectionId // kind "connection": the page shows "connect your account"; with required the person must connect
72
74
  list?: listId // kind "list": the page shows that stored list for the person to check, correct and extend
@@ -114,7 +116,7 @@ type GenerateStep = {
114
116
  prompt: string // the brief, with {{templates}}; for "voice" the exact text that is read aloud
115
117
  options?: { aspectRatio?: "1:1"|"16:9"|"9:16"|"4:5"|"3:2"|"2:3", duration?: 4–10 (video seconds; 4, 6 or 8 are safe),
116
118
  resolution?: "720p"|"480p", // video: 720p by default and at most; 480p for drafts and many clips
117
- style?: string, // image/video: the look · voice: how to speak ("ruhig, warm")
119
+ style?: string, // image/video: the look · voice: how to speak ("ruhig, warm"; may name a field: "{{voiceStyle}}")
118
120
  template?: "invoice"|"offer"|"briefing"|"letter"|"report"|"free" } // template for documents
119
121
  referenceImage?: fieldId | stepId // the image the image/video starts from: an image field, or an earlier step that made images
120
122
  each?: fieldId | stepId | "steps.<id>.<key>"
@@ -136,17 +138,38 @@ type WidgetStep = {
136
138
  video?: boolean // a FILM: the timeline is rendered to an MP4 WITH SOUND when the step runs; the person sees and
137
139
  // downloads that video. data may then name generate steps: "steps.<id>" of an image / video / voice
138
140
  // step arrives as a URL (a list of URLs for a step with "each").
141
+ // "steps.<agentStepId>.assets" gives the pictures an agent step kept (browser screenshots
142
+ // with save, export_file, generate_image) as { assetId: URL }; the step's JSON names them by id.
139
143
  working?: string
140
144
  }
141
145
  // an interactive HTML app written ONCE into the workspace; every run only brings new data (no model call, no cost)
142
146
 
147
+ type FilmStep = {
148
+ type: "film"
149
+ brief: string // a template: what the film is — story, look, length, what to use. The kit carries the craft.
150
+ skills?: string[] // skill packages the client reads; default ["hyperframes"]. A workspace package: "skills/<name>" (has SKILL.md)
151
+ kit?: string // workspace path of a STYLE KIT (.zip): a showreel made once with its rules (frame.md),
152
+ // techniques (SHOWCASE.md), helpers and stills — the bar every film of this wizard reaches
153
+ inputs?: { [name]: string } // name (lowercase, -) → fieldId | steps.stepId: voice lines, pictures, uploads become files in
154
+ // input/<name>/, text in input/<name>.md, json in input/<name>.json
155
+ format?: "16:9" | "9:16" | "1:1"
156
+ seconds?: number // about how long
157
+ model?: "standard" | "high" // default standard
158
+ effort?: Effort
159
+ working?: string
160
+ }
161
+ // the AI client installed on the person's computer (Claude Code) writes the film as code in a folder of its own, with the
162
+ // skills, the kit, the space's brand (colours, logo, facts) and the inputs; it looks at its frames and fixes them; the
163
+ // runtime renders the MP4. It runs only where Claude Code is set up as the AI subscription — never on credits or in the
164
+ // cloud — and takes 10–40 minutes. Sounds come from a fixed catalog (CC0); voice comes from a generate voice step before it.
165
+
143
166
  type ReviewStep = { type: "review", show: (stepId | "lists.<listId>")[], edit?: boolean, regenerate?: boolean }
144
167
  // shows earlier outputs; the person accepts, edits text outputs (edit), or asks for a new version with a note (regenerate)
145
168
  // a shown list is edited in place by the person
146
169
 
147
170
  type ResultStep = { type: "result", message?: string, deliverables: { from: stepId | "lists.<listId>", label?, formats: Format[] }[] }
148
171
  // formats per source: image→png · video→mp4 (several results: zip) · voice→mp3 · document→pdf docx html md png · dashboard→html pdf png
149
- // film widget (video: true)→mp4 png
172
+ // film widget (video: true)→mp4 png · film→mp4
150
173
  // widget→html png pdf mp4 json (mp4 only when the widget registers a timeline)
151
174
  // agent text/markdown→md txt docx pdf html · agent json→json csv xlsx md (xlsx: one sheet per table)
152
175
  // agent, any format→zip: the FILES the step kept with mail_save / browser_download
@@ -182,6 +205,9 @@ ${sandboxGuideLine()}
182
205
  - Collecting documents (invoices from mail or portals): the step keeps each file with mail_save / browser_download under a dated path ("invoices/2026-09/2026-09-03_Notion_INV-123.pdf"); its deliverable offers "zip". Split a big job into steps (mail, then statements, then portals) — one step manages about 60 tool calls.
183
206
  - Going through documents one by one (receipts against payments): never one model step per document. One step reads them in batches (scan_documents takes 30 at once) and writes rows into a list with "check"; the review shows that list split-screen — the person answers each row beside its file, without any model call.
184
207
  - Video that convinces is built in stages, each with its own review: idea and script (agent, json with a table of shots) → one still per shot (generate image with "each", starting from the person's product photo) → one clip per still (generate video with "each" and referenceImage = the stills step) → a voice-over (generate voice) → a film widget (video: true) that cuts clips, captions, voice and a closing card with the call to action. The starters "facebook-video-ad" and "property-film" carry such a widget (film/film.js) to copy. Offer a cheaper path without clips: the film animates the stills.
208
+ - Motion-graphics films (explainers, product spots, shorts) are a "film" step after the preparation, each with a review:
209
+ research and script (agent, high) → the voice-over (one generate voice step for the whole script; the film step finds the
210
+ word timings itself) → film (inputs: the voice, pictures, the script). The look comes from a style kit in the workspace (kit), so the film step stays on "standard".
185
211
  - Prices come from a file the admin keeps in the workspace (a CSV price list): the agent step looks them up with read_workspace_file and proposes positions as a table; the next page shows them in an "items" field with "prefill" for the person to correct; the runner computes the totals.
186
212
  - AI media is marked for you (EU AI Act, Art. 50): images, clips, voice-overs and films carry the marking in their file metadata, and the pages lay the EU's "AI" label over them. Never ask an image step to draw such a label into the picture. A film is different — it leaves the page as a video file, so its widget shows the EU label in the picture: wizard.ai tells a widget whether the run's media in its data is "generated" or "edited" (film/film.js draws the matching icon).
187
213
  - Every step a person sees later (review/result) must come from an earlier step id.
@@ -207,8 +233,10 @@ one-off pages stay "generate" steps. A widget is code in the wizard's WORKSPACE
207
233
  export steps through it frame by frame. In mode "export" hide all controls and do not autoplay.
208
234
  - A film (step.video): seek(t) may return a promise that resolves once the frame is drawn (set a clip's
209
235
  currentTime, wait for "seeked", draw it to a canvas). Name the sound in the timeline:
210
- audio: [{ src, start, duration?, volume? }] with the URLs from wizard.data (the voice-over, the clips
211
- themselves for their own sound). Clips and images of the run arrive as URLs, not inlined.
236
+ audio: [{ src, start, duration?, from?, volume? }] with the URLs from wizard.data (the voice-over, the
237
+ clips themselves for their own sound; up to 48 tracks). "from" is the second of the source a track
238
+ plays from — a recording cut into pieces names one track per piece. Clips and images of the run
239
+ arrive as URLs, not inlined.
212
240
  - Call wizard.ready() once the first frame is drawn. Lay out for the step's size and scale to fit
213
241
  the window on every "resize" event — the window can be 0×0 when your script first runs.
214
242
  - Plain HTML/CSS/JS, SVG or canvas. Calm design, one accent colour (brand.accent), legible labels.
@@ -1,6 +1,6 @@
1
1
  import { allFields, isAudioValue } from "@engenty-wizards/shared/definition";
2
- import { loadAsset } from "../files/storage.js";
3
- import { transcribeAudio } from "../media/transcribe.js";
2
+ import { extFor, loadAsset } from "../files/storage.js";
3
+ import { listenPieces, transcribeAudio } from "../media/transcribe.js";
4
4
  /**
5
5
  * Gets the person's answers ready for a step that works on its own: a voice note is listened to
6
6
  * once and kept as text with the answer, and a recording or signature that is not this run's own
@@ -17,6 +17,10 @@ export async function prepareInputs(ctx) {
17
17
  }
18
18
  continue;
19
19
  }
20
+ if (field.kind === "file" && field.listen) {
21
+ await listenTo(ctx, field.id, field.label, value);
22
+ continue;
23
+ }
20
24
  if (field.kind !== "audio" || !isAudioValue(value)) {
21
25
  continue;
22
26
  }
@@ -52,3 +56,34 @@ export async function prepareInputs(ctx) {
52
56
  }
53
57
  }
54
58
  }
59
+ /** A recording in a file field with `listen`: written down once, piece by piece with its times. */
60
+ async function listenTo(ctx, id, label, value) {
61
+ if (ctx.state.heard?.[id]) {
62
+ return;
63
+ }
64
+ // Several recordings: the first is the one that is cut.
65
+ const first = Array.isArray(value) ? value[0] : value;
66
+ const found = typeof first === "string" && first ? await loadAsset(first) : null;
67
+ if (!found || found.row.runId !== ctx.runId || !/^(audio|video)\//.test(found.row.mime)) {
68
+ return;
69
+ }
70
+ await ctx.emit("info", `Hört „${label}“ an …`);
71
+ try {
72
+ const { pieces, costUsd } = await listenPieces({
73
+ bytes: new Uint8Array(found.data),
74
+ ext: extFor(found.row.mime),
75
+ abortSignal: ctx.signal,
76
+ call: ctx.call,
77
+ });
78
+ await ctx.chargeUsd(costUsd);
79
+ ctx.state.heard = { ...ctx.state.heard, [id]: pieces };
80
+ }
81
+ catch (err) {
82
+ if (ctx.signal.aborted) {
83
+ throw err;
84
+ }
85
+ // The step goes on without the words; the next one tries again.
86
+ console.error(`[run ${ctx.runId}] listen ${id}`, err);
87
+ await ctx.emit("info", `„${label}“ konnte nicht verschriftlicht werden.`);
88
+ }
89
+ }
@@ -1,5 +1,5 @@
1
1
  import { isDecisionStep, MODEL_CLASSES, ruleMatches, } from "@engenty-wizards/shared/definition";
2
- import { classProblem } from "../models.js";
2
+ import { classProblem, filmClient } from "../models.js";
3
3
  /**
4
4
  * What a wizard needs of the models before a run starts: each class its steps call, and whether
5
5
  * this runtime can serve it. A step every run passes through blocks the start when its class is
@@ -32,6 +32,8 @@ export function stepClasses(step) {
32
32
  default:
33
33
  return [step.model ?? "high"];
34
34
  }
35
+ case "film":
36
+ return [step.model ?? "standard"];
35
37
  case "page":
36
38
  // A voice note the page asks for is listened to before the next step reads it.
37
39
  return step.fields.some((f) => f.kind === "audio" && f.required) ? ["audio"] : [];
@@ -221,6 +223,22 @@ export async function missingModels(def) {
221
223
  }
222
224
  }
223
225
  const missing = [];
226
+ // A film runs on an installed client only, whatever serves its class otherwise.
227
+ for (const step of def.steps) {
228
+ if (step.type !== "film") {
229
+ continue;
230
+ }
231
+ const cls = step.model ?? "standard";
232
+ const found = await filmClient(cls);
233
+ if ("problem" in found) {
234
+ missing.push({
235
+ cls,
236
+ problem: found.problem,
237
+ steps: [{ id: step.id, title: step.title }],
238
+ blocking: unavoidable.has(step.id),
239
+ });
240
+ }
241
+ }
224
242
  for (const [cls, uses] of needs) {
225
243
  const problem = await classProblem(cls);
226
244
  if (problem) {
@@ -261,6 +279,9 @@ export async function missingCapabilities() {
261
279
  missing.add(capability);
262
280
  }
263
281
  }
282
+ if ("problem" in (await filmClient("standard"))) {
283
+ missing.add("film");
284
+ }
264
285
  return missing;
265
286
  }
266
287
  /** The classes this runtime cannot serve. */
@@ -3,6 +3,7 @@ import { Agent } from "@mastra/core/agent";
3
3
  import { generateText, Output } from "ai";
4
4
  import { z } from "zod";
5
5
  import { extFor, loadAsset, loadAssetText } from "../files/storage.js";
6
+ import { runFilmStep } from "../film/step.js";
6
7
  import { generateImageMedia, generateSpeechMedia, generateVideoMedia, } from "../media/generate.js";
7
8
  import { markMedia } from "../media/marking.js";
8
9
  import { attachTools, costOf, isHarnessVendor, textModel } from "../models.js";
@@ -558,7 +559,10 @@ export async function runGenerateStep(step, ctx) {
558
559
  const media = await generateSpeechMedia({
559
560
  call: ctx.call,
560
561
  text,
561
- style: [step.options?.style, note].filter(Boolean).join(". ") || undefined,
562
+ // The style may name the person's answer: "{{voiceStyle}}, natural pace".
563
+ style: [step.options?.style && renderTemplate(step.options.style, ctx.scope).trim(), note]
564
+ .filter(Boolean)
565
+ .join(". ") || undefined,
562
566
  abortSignal: ctx.signal,
563
567
  });
564
568
  await ctx.chargeUsd(media.costUsd);
@@ -597,5 +601,8 @@ export async function runAutomaticStep(step, ctx) {
597
601
  if (step.type === "widget") {
598
602
  return runWidgetStep(step, ctx);
599
603
  }
604
+ if (step.type === "film") {
605
+ return runFilmStep(step, ctx);
606
+ }
600
607
  throw new StepError(`Step ${step.id} is not automatic.`);
601
608
  }
@@ -126,6 +126,14 @@ export function resolveRef(ref, scope) {
126
126
  const totals = itemsTotals(field, value, state.values);
127
127
  return totals[rest[0]];
128
128
  }
129
+ if (field?.kind === "file" && rest[0] === "speech") {
130
+ const pieces = state.heard?.[head];
131
+ // The lines an agent reads and cuts by: "[12.40–15.10] what is said".
132
+ return pieces
133
+ ? pieces.map((p) => `[${p.start.toFixed(2)}–${p.end.toFixed(2)}] ${p.text}`).join("\n") ||
134
+ "(no intelligible speech)"
135
+ : "";
136
+ }
129
137
  if (field?.kind === "image" || field?.kind === "file") {
130
138
  // The files themselves are read with read_document / scan_documents by these names.
131
139
  const ids = (Array.isArray(value) ? value : [value]).filter((v) => typeof v === "string" && v.length > 0);
@@ -1,6 +1,9 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { join } from "node:path";
1
3
  import { eq } from "drizzle-orm";
2
4
  import { nanoid } from "nanoid";
3
5
  import { db, schema } from "../db/client.js";
6
+ import { env } from "../env.js";
4
7
  import { currentTenant } from "../tenants/tenant.js";
5
8
  import { objects } from "./objects.js";
6
9
  const assetKey = (file) => `assets/${file}`;
@@ -100,11 +103,15 @@ export async function removeAsset(id) {
100
103
  await db.delete(schema.asset).where(eq(schema.asset.id, id));
101
104
  }
102
105
  }
103
- /** Deletes every file a run made (uploads, results, renders) and their rows. */
106
+ /** Deletes every file a run made (uploads, results, renders, film folders) and their rows. */
104
107
  export async function removeAssetFiles(runId) {
105
108
  const rows = await db.query.asset.findMany({ where: eq(schema.asset.runId, runId) });
106
109
  for (const row of rows) {
107
110
  await objects.remove(assetKey(row.path));
108
111
  }
109
112
  await db.delete(schema.asset).where(eq(schema.asset.runId, runId));
113
+ // The folders its films were made in (../film/project.ts).
114
+ if (/^[A-Za-z0-9_-]+$/.test(runId)) {
115
+ await rm(join(env.dataDir, "films", runId), { recursive: true, force: true });
116
+ }
110
117
  }
@@ -0,0 +1,195 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, join, normalize } from "node:path";
5
+ import { dataRef, FILM_SIZES } from "@engenty-wizards/shared/definition";
6
+ import { unzipSync } from "fflate";
7
+ import { renderTemplate, resolveRef } from "../engine/template.js";
8
+ import { env } from "../env.js";
9
+ import { getBlob } from "../files/blobs.js";
10
+ import { extFor, loadAsset } from "../files/storage.js";
11
+ import { projectFileContent } from "../services/project-files.js";
12
+ import { placeSkills } from "./skills.js";
13
+ import { soundCatalog } from "./sounds.js";
14
+ import { runHyperframes } from "./tools.js";
15
+ /**
16
+ * The folder a film is made in: one per run and step, kept after the run so a later change edits
17
+ * the code instead of starting over. Laid out for the client:
18
+ *
19
+ * BRIEF.md what to make (the step's brief, the format, the length)
20
+ * CLAUDE.md, AGENTS.md the rules of the folder
21
+ * .claude/skills/ the skill packages
22
+ * showcase/ the wizard's style kit, the bar to reach
23
+ * brand/ the space's brand: brand.json, logos
24
+ * input/ what this run made before: voice lines, pictures, texts, uploads
25
+ * audio-catalog/ the only sounds the film may use
26
+ * film/ the HyperFrames project; renders/film.mp4 is the result
27
+ */
28
+ export function filmRoot(runId, stepId) {
29
+ return join(env.dataDir, "films", runId, stepId);
30
+ }
31
+ const SESSION = join(".engenty", "session");
32
+ export async function savedSession(root) {
33
+ try {
34
+ return (await readFile(join(root, SESSION), "utf8")).trim() || null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ export async function saveSession(root, id) {
41
+ await mkdir(join(root, ".engenty"), { recursive: true });
42
+ await writeFile(join(root, SESSION), id);
43
+ }
44
+ /** Writes bytes to a path inside `root`; a name that would leave it is refused. */
45
+ async function put(root, rel, data) {
46
+ const path = normalize(join(root, rel));
47
+ if (!path.startsWith(`${root}/`)) {
48
+ return;
49
+ }
50
+ await mkdir(dirname(path), { recursive: true });
51
+ await writeFile(path, data);
52
+ }
53
+ const safeName = (name) => name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "file";
54
+ function rules(step, skills) {
55
+ const size = FILM_SIZES[step.format];
56
+ return `# The film folder
57
+
58
+ You make one film here, autonomously, to the end. Nobody answers questions: where a skill asks the
59
+ person, decide yourself and note it in PROGRESS.md.
60
+
61
+ - **What to make:** BRIEF.md. **The bar:** showcase/ (read showcase/SHOWCASE.md and showcase/frame.md
62
+ first when it exists, look at its stills, reuse its helpers from showcase/assets/). **The brand:**
63
+ brand/brand.json and brand/logos/ — colours, logo and fonts come from there only.
64
+ - **Material:** input/ (see input/README.md). Use nothing from the web; you have no network.
65
+ - **Skills:** ${skills.join(", ")} in .claude/skills/<name>/SKILL.md. Start with \`hyperframes\` and
66
+ read the skill files directly; do not update or install skills.
67
+ - **The HyperFrames project is film/** (${size.width}×${size.height}, already set up). Keep every file
68
+ of the film inside film/.
69
+ - **Running HyperFrames:** only through the \`hyperframes\` tool (MCP server \`film\`), e.g.
70
+ \`["lint","film"]\`, \`["check","film"]\`, \`["snapshot","film","--at","1,4,8"]\`,
71
+ \`["render","film","-q","draft"]\`, \`["transcribe","input/voice/l01.wav","--json","-d","input/voice"]\`.
72
+ \`npx\`, \`npm\` and network do not work in the shell.
73
+ - **Look at your work:** snapshot the frames and read the PNGs, fix what is wrong. Before you finish,
74
+ render a draft and look at a frame every second of the whole film: empty areas, cut-off text,
75
+ overlaps, jumps.
76
+ - **Finish** only when \`["check","film"]\` passes and \`["render","film","-q","draft"]\` succeeds, with \`film/index.html\` complete. The runtime renders the final MP4 itself.
77
+ End with a short summary: what the film shows, what you decided.
78
+
79
+ ## Sound (hard rules)
80
+
81
+ - Sounds come only from audio-catalog/ (catalog.json lists each file with tags, keywords, licence).
82
+ Copy the picked files into film/assets/ and list them in film/AUDIO.md (file, source, licence, use).
83
+ - Never generate, synthesize or download audio: no ffmpeg tone or noise, no TTS, no music models. Voice
84
+ lines come only from input/. Mixing is HyperFrames' job (\`<audio>\` with start, duration, volume).
85
+ - Install nothing (no pip, brew, npm).
86
+ `;
87
+ }
88
+ /** Sets up the folder for a first attempt; a later attempt finds it as it was left. */
89
+ export async function prepareFilmFolder(step, ctx) {
90
+ const root = filmRoot(ctx.runId, step.id);
91
+ const brief = renderTemplate(step.brief, ctx.scope).trim();
92
+ // The same brief and material as last time: the film is there to go on with or to change.
93
+ const stamp = createHash("sha256")
94
+ .update(JSON.stringify([
95
+ brief,
96
+ Object.values(step.inputs).map((raw) => {
97
+ const ref = dataRef(raw);
98
+ const [head, id] = ref.split(".");
99
+ return head === "steps" ? ctx.state.outputs[id]?.at : ctx.state.values[ref];
100
+ }),
101
+ ]))
102
+ .digest("hex");
103
+ const stampFile = join(root, ".engenty", "inputs");
104
+ const same = existsSync(stampFile) && (await readFile(stampFile, "utf8")) === stamp;
105
+ if (same && existsSync(join(root, "film", "index.html")) && (await savedSession(root))) {
106
+ return { root, brief };
107
+ }
108
+ await rm(root, { recursive: true, force: true });
109
+ await mkdir(root, { recursive: true });
110
+ const skills = await placeSkills(step.skills, ctx.files, join(root, ".claude", "skills"));
111
+ const size = FILM_SIZES[step.format];
112
+ 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`);
113
+ const text = rules(step, skills);
114
+ await put(root, "CLAUDE.md", text);
115
+ await put(root, "AGENTS.md", text);
116
+ // The style kit.
117
+ if (step.kit) {
118
+ const file = ctx.files.find((f) => f.path === step.kit);
119
+ if (file) {
120
+ const entries = unzipSync(new Uint8Array(await getBlob(file.hash)));
121
+ for (const [name, data] of Object.entries(entries)) {
122
+ if (!name.endsWith("/")) {
123
+ await put(join(root, "showcase"), name.replace(/^showcase\//, ""), data);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ // The space's brand.
129
+ const b = ctx.scope.brand;
130
+ const logos = [];
131
+ for (const f of ctx.projectFiles.filter((p) => p.kind === "logo")) {
132
+ try {
133
+ const { data } = await projectFileContent(f.projectId, f.id);
134
+ const name = `logos/${safeName(f.name.replace(/\.[^.]+$/, ""))}.${extFor(f.mime) === "bin" ? (f.name.split(".").pop() ?? "bin") : extFor(f.mime)}`;
135
+ await put(join(root, "brand"), name, data);
136
+ logos.push(name);
137
+ }
138
+ catch {
139
+ // a logo less
140
+ }
141
+ }
142
+ await put(join(root, "brand"), "brand.json", JSON.stringify({
143
+ note: "The space's brand. Colours are named by what they are for; the first is the accent.",
144
+ name: b.name ?? "",
145
+ about: b.about ?? "",
146
+ colors: (b.colors ?? []).map((c) => ({ name: c.name, value: c.value })),
147
+ logos,
148
+ facts: (b.facts ?? []).map((f) => `${f.label}: ${f.value}`),
149
+ }, null, 1));
150
+ // What the run made before.
151
+ const listed = [];
152
+ for (const [name, raw] of Object.entries(step.inputs)) {
153
+ const ref = dataRef(raw);
154
+ const [head, id] = ref.split(".");
155
+ const output = head === "steps" ? ctx.state.outputs[id] : undefined;
156
+ const assetIds = head === "steps"
157
+ ? (output?.assets ?? []).map((a) => a.id)
158
+ : [ctx.state.values[ref]].flat().filter((v) => typeof v === "string");
159
+ let n = 0;
160
+ for (const assetId of assetIds) {
161
+ const found = await loadAsset(assetId);
162
+ if (found && found.row.runId === ctx.runId) {
163
+ const file = `${name}/${safeName(found.row.name)}`;
164
+ await put(join(root, "input"), file, found.data);
165
+ listed.push(`- \`input/${file}\` (${found.row.mime})`);
166
+ n++;
167
+ }
168
+ }
169
+ if (head === "steps" && output?.json !== undefined) {
170
+ await put(join(root, "input"), `${name}.json`, JSON.stringify(output.json, null, 1));
171
+ listed.push(`- \`input/${name}.json\``);
172
+ }
173
+ else if (head === "steps" && output?.text) {
174
+ await put(join(root, "input"), `${name}.md`, output.text);
175
+ listed.push(`- \`input/${name}.md\``);
176
+ }
177
+ else if (!n) {
178
+ const value = resolveRef(ref, ctx.scope);
179
+ if (value !== undefined && value !== null && value !== "") {
180
+ await put(join(root, "input"), `${name}.md`, typeof value === "string" ? value : JSON.stringify(value, null, 1));
181
+ listed.push(`- \`input/${name}.md\``);
182
+ }
183
+ }
184
+ }
185
+ 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`);
186
+ await cp(await soundCatalog(), join(root, "audio-catalog"), { recursive: true });
187
+ // The HyperFrames project, set up for the format.
188
+ const preset = { "16:9": "landscape", "9:16": "portrait", "1:1": "square" }[step.format];
189
+ const init = await runHyperframes(["init", "film", "--non-interactive", "--skip-transcribe", `--resolution=${preset}`], { cwd: root, signal: ctx.signal, timeoutMs: 5 * 60_000 });
190
+ if (init.code !== 0 || !existsSync(join(root, "film"))) {
191
+ throw new Error(`Das Filmprojekt konnte nicht angelegt werden: ${init.out.slice(-300)}`);
192
+ }
193
+ await put(root, ".engenty/inputs", stamp);
194
+ return { root, brief };
195
+ }
@@ -0,0 +1,91 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { cp, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { env } from "../env.js";
7
+ import { getBlob } from "../files/blobs.js";
8
+ const exec = promisify(execFile);
9
+ export const PLATFORM_SKILLS = {
10
+ hyperframes: {
11
+ name: "hyperframes",
12
+ source: "https://github.com/heygen-com/hyperframes",
13
+ version: "6308727b85726433e6a1e148b608d8cb11085114",
14
+ licence: "Apache-2.0",
15
+ tarball: "https://codeload.github.com/heygen-com/hyperframes/tar.gz/6308727b85726433e6a1e148b608d8cb11085114",
16
+ folder: "skills",
17
+ },
18
+ };
19
+ const fetching = new Map();
20
+ /** The folder holding a platform package's skills; fetched on first use. */
21
+ export function platformSkills(name) {
22
+ const pkg = PLATFORM_SKILLS[name];
23
+ if (!pkg) {
24
+ return Promise.reject(new Error(`Das Skill-Paket „${name}“ gibt es nicht.`));
25
+ }
26
+ const dir = join(env.dataDir, "skills", `${pkg.name}@${pkg.version.slice(0, 12)}`);
27
+ if (existsSync(join(dir, ".complete"))) {
28
+ return Promise.resolve(dir);
29
+ }
30
+ let pending = fetching.get(name);
31
+ if (!pending) {
32
+ pending = fetchPackage(pkg, dir).finally(() => fetching.delete(name));
33
+ fetching.set(name, pending);
34
+ }
35
+ return pending;
36
+ }
37
+ async function fetchPackage(pkg, dir) {
38
+ const res = await fetch(pkg.tarball);
39
+ if (!res.ok) {
40
+ throw new Error(`Das Skill-Paket „${pkg.name}“ konnte nicht geladen werden (${res.status}).`);
41
+ }
42
+ const tmp = `${dir}.part`;
43
+ await rm(tmp, { recursive: true, force: true });
44
+ await mkdir(tmp, { recursive: true });
45
+ const archive = join(tmp, "package.tar.gz");
46
+ await writeFile(archive, Buffer.from(await res.arrayBuffer()));
47
+ // GitHub's tarball has one top folder (<repo>-<sha>); only its skills folder is kept.
48
+ await exec("tar", ["-xzf", archive, "-C", tmp, "--strip-components=1", `*/${pkg.folder}/*`]);
49
+ const skills = join(tmp, pkg.folder);
50
+ for (const entry of await readdir(skills, { withFileTypes: true })) {
51
+ if (!entry.isDirectory() || !existsSync(join(skills, entry.name, "SKILL.md"))) {
52
+ await rm(join(skills, entry.name), { recursive: true, force: true });
53
+ }
54
+ }
55
+ await writeFile(join(skills, "PACKAGE.json"), JSON.stringify({ name: pkg.name, source: pkg.source, version: pkg.version, licence: pkg.licence }, null, 1));
56
+ await writeFile(join(skills, ".complete"), new Date().toISOString());
57
+ await rm(dir, { recursive: true, force: true });
58
+ await rename(skills, dir);
59
+ await rm(tmp, { recursive: true, force: true });
60
+ return dir;
61
+ }
62
+ /**
63
+ * Puts the step's skill packages into `target` (one folder per skill): platform packages by
64
+ * name, workspace packages (`skills/<name>`) from the wizard's files. Returns the skill names.
65
+ */
66
+ export async function placeSkills(names, files, target) {
67
+ await mkdir(target, { recursive: true });
68
+ const placed = [];
69
+ for (const name of names) {
70
+ if (name.startsWith("skills/")) {
71
+ const prefix = `${name.replace(/\/+$/, "")}/`;
72
+ const own = files.filter((f) => f.path.startsWith(prefix));
73
+ const skill = prefix.split("/")[1];
74
+ for (const f of own) {
75
+ const dest = join(target, skill, f.path.slice(prefix.length));
76
+ await mkdir(join(dest, ".."), { recursive: true });
77
+ await writeFile(dest, await getBlob(f.hash));
78
+ }
79
+ placed.push(skill);
80
+ continue;
81
+ }
82
+ const dir = await platformSkills(name);
83
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
84
+ if (entry.isDirectory()) {
85
+ await cp(join(dir, entry.name), join(target, entry.name), { recursive: true });
86
+ placed.push(entry.name);
87
+ }
88
+ }
89
+ }
90
+ return placed;
91
+ }