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
package/apps/runtime/dist/app.js
CHANGED
|
@@ -25,6 +25,7 @@ import { connectCallback, publicRoutes, runRoutes, shareRoutes } from "./routes/
|
|
|
25
25
|
import { studio } from "./routes/studio.js";
|
|
26
26
|
import { wizardStream } from "./routes/wizard-stream.js";
|
|
27
27
|
import { runTicketValid } from "./secrets/signing.js";
|
|
28
|
+
import { asRole } from "./services/access.js";
|
|
28
29
|
import { ServiceError } from "./services/errors.js";
|
|
29
30
|
import { tenantOfLink, tenantStatus } from "./tenants/control.js";
|
|
30
31
|
const app = new Hono();
|
|
@@ -158,7 +159,7 @@ app.use("/api/studio/*", async (c, next) => {
|
|
|
158
159
|
return c.json({ error: "Dieses Konto ist gesperrt.", code: "suspended" }, 403);
|
|
159
160
|
}
|
|
160
161
|
c.set("user", user);
|
|
161
|
-
return withTenant(user.tenantId, next);
|
|
162
|
+
return withTenant(user.tenantId, () => asRole(user.role, next));
|
|
162
163
|
});
|
|
163
164
|
const mcp = mcpHandler();
|
|
164
165
|
app.on(["GET", "POST", "DELETE"], "/api/mcp", (c) => mcp(c.req.raw));
|
|
@@ -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
|
|
211
|
-
themselves for their own sound).
|
|
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.
|
|
@@ -9,7 +9,7 @@ import { discoverImportableSources, discoverOAuthFacts, registryDiscover, regist
|
|
|
9
9
|
import { resolveRegistrySource } from "../engenty/connections-external/registry-source.js";
|
|
10
10
|
import { encryptToken } from "../engenty/shims/connections-sdk.js";
|
|
11
11
|
import { env } from "../env.js";
|
|
12
|
-
import { requireWritableProject } from "../services/access.js";
|
|
12
|
+
import { requireAdmin, requireWritableProject } from "../services/access.js";
|
|
13
13
|
import { ServiceError } from "../services/errors.js";
|
|
14
14
|
import { BUILTIN_CONNECTORS, builtinConnector, missingSetup, usable } from "./builtin.js";
|
|
15
15
|
/**
|
|
@@ -272,6 +272,7 @@ export async function resolveConnector(projectId, id) {
|
|
|
272
272
|
}
|
|
273
273
|
export async function importConnector(userId, projectId, input) {
|
|
274
274
|
await requireWritableProject(projectId);
|
|
275
|
+
await requireAdmin();
|
|
275
276
|
if (await load(projectId, input.id)) {
|
|
276
277
|
throw new ServiceError("invalid", `Connector "${input.id}" already exists in this project.`);
|
|
277
278
|
}
|
|
@@ -314,6 +315,7 @@ export async function importConnector(userId, projectId, input) {
|
|
|
314
315
|
*/
|
|
315
316
|
export async function importFromRegistry(userId, projectId, input) {
|
|
316
317
|
await requireWritableProject(projectId);
|
|
318
|
+
await requireAdmin();
|
|
317
319
|
const service = await registryService(input.domain);
|
|
318
320
|
const order = input.kind ? [input.kind] : ["mcp", "openapi"];
|
|
319
321
|
const source = order
|
|
@@ -338,6 +340,7 @@ export async function importFromRegistry(userId, projectId, input) {
|
|
|
338
340
|
}
|
|
339
341
|
export async function removeConnector(projectId, id) {
|
|
340
342
|
await requireWritableProject(projectId);
|
|
343
|
+
await requireAdmin();
|
|
341
344
|
await db.delete(schema.projectConnector).where(where(projectId, id));
|
|
342
345
|
}
|
|
343
346
|
/**
|
|
@@ -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:
|
|
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
|
+
}
|