@effectnode/media 0.5.0 → 0.6.0

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.
@@ -4,7 +4,7 @@ import { type Application } from "express";
4
4
  * Persists the result to studio/:projectId/data/*.json and returns the parsed
5
5
  * result. Used both by the HTTP route below and by the generation queue worker.
6
6
  */
7
- export declare function generateMovieStudioBible(projectId: string, idea: string, model?: string): Promise<{
7
+ export declare function generateMovieStudioBible(projectId: string, idea: string, model?: string, onProgress?: (statusText: string, current: number, total: number) => void): Promise<{
8
8
  characters: any[];
9
9
  places: any[];
10
10
  scenes: any[];
@@ -48,7 +48,7 @@ function toOpenAIMessages(messages) {
48
48
  function buildSystemPrompt() {
49
49
  const toolList = TOOLS.map((t) => `- ${t.name}: ${t.description}`).join("\n");
50
50
  return [
51
- "You are a media assistant that helps the user edit images or render videos in their workspace.",
51
+ "You are a media assistant that helps the user render videos in their workspace.",
52
52
  "",
53
53
  "Available tools:",
54
54
  toolList,
@@ -56,34 +56,16 @@ function buildSystemPrompt() {
56
56
  "Rules:",
57
57
  "- Call a tool only when you actually need its result. Once you have enough information, answer the user directly WITHOUT calling any tool.",
58
58
  "- Do not call the same tool twice with the same arguments.",
59
- "- ALWAYS confirm with the user before generating or editing. Never call `image_to_video_generation`, `text_to_video_generation`, or `edit_image` until the user has explicitly said yes.",
59
+ "- ALWAYS confirm with the user before generating. Never call `image_to_video_generation` or `text_to_video_generation` until the user has explicitly said yes.",
60
60
  "",
61
61
  "## Getting started",
62
62
  "",
63
63
  'When the user greets you (e.g. says "hi" or "hello") or asks for help, FIRST ask them what they want to do:',
64
- "1. Edit an image",
65
- "2. Render a video from text",
66
- "3. Render a video from an image",
64
+ "1. Render a video from text",
65
+ "2. Render a video from an image",
67
66
  "",
68
67
  "Then follow the matching workflow below.",
69
68
  "",
70
- "## Workflow: Edit an image",
71
- "",
72
- "### Step 1 — Image",
73
- "- Call `list_files` to see what is in the agent workspace.",
74
- '- List every image file (files whose `kind` is "image") to the user as a numbered list so they can pick one.',
75
- "- You may call `show_image` to preview an image before the user decides.",
76
- "- If there are no images, tell the user and ask them to upload one first.",
77
- "",
78
- "### Step 2 — Edit instruction",
79
- "- Plan the edit together with the user. Ask what they want to change: background, style, color, lighting, expression, object, and so on.",
80
- "- Confirm the wording of the edit prompt before moving on.",
81
- "",
82
- "### Step 3 — Summary and confirmation",
83
- "- Prepare a short summary containing the image and the edit instruction, and ask the user to confirm.",
84
- "- Do NOT start editing yet. Wait for the user to say yes.",
85
- "- Only after the user confirms, call `edit_image` with the image and prompt.",
86
- "",
87
69
  "## Workflow: Render a video",
88
70
  "",
89
71
  "To generate a video you need a motion PROMPT and a DURATION. An IMAGE is optional but recommended.",
@@ -151,53 +133,80 @@ function sanitizeTrace(messages) {
151
133
  });
152
134
  }
153
135
  // ========== Movie Studio ==========
154
- const MOVIE_STUDIO_CHARACTERS_PROMPT = [
136
+ const MOVIE_STUDIO_CHARACTERS_PLAN_PROMPT = [
155
137
  "You are a movie pre-production planner. Given a movie or story idea, identify the characters.",
156
138
  "",
157
139
  "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
158
140
  "",
159
- '{"artStyle":"string","characters":[{"slug":"string","name":"string","imagePrompt":"string"}]}',
141
+ '{"artStyle":"string","characters":[{"slug":"string","name":"string"}]}',
160
142
  "",
161
143
  "Rules:",
162
144
  '- "slug" is a short lowercase hyphenated identifier (e.g. "the-lamb").',
163
145
  '- "artStyle" is ONE consistent art style for the entire film (e.g. cinematic photorealistic, 3D animation, hand-painted watercolor, anime).',
164
146
  '- If the idea does not mention an art style, set "artStyle" to "photo realistic render".',
165
- "- A character's imagePrompt is a standalone text-to-image prompt that begins with the character's name and fully describes their appearance (age, face, build, outfit, distinctive features) together with their cultural background — the era/age, culture, ethnicity, and region the character belongs to — so the generated image is historically and culturally accurate and consistent.",
166
- "- Apply the same artStyle to every character imagePrompt.",
167
- "- Write prompts as natural-language English sentences, never comma-separated keyword tags.",
168
147
  ].join("\n");
169
- const MOVIE_STUDIO_PLACES_PROMPT = [
148
+ const CHARACTER_IMAGE_PROMPT = [
149
+ "You are a movie pre-production planner. Given a movie idea, its art style, and one character, write that character's standalone image prompt.",
150
+ "",
151
+ "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
152
+ "",
153
+ '{"imagePrompt":"string"}',
154
+ "",
155
+ "Rules:",
156
+ "- The imagePrompt begins with the character's name and fully describes their appearance (age, face, build, outfit, distinctive features) together with their cultural background — the era/age, culture, ethnicity, and region the character belongs to — so the generated image is historically and culturally accurate and consistent.",
157
+ "- Apply the given artStyle.",
158
+ "- Write the prompt as natural-language English sentences, never comma-separated keyword tags.",
159
+ ].join("\n");
160
+ const MOVIE_STUDIO_PLACES_PLAN_PROMPT = [
170
161
  "You are a movie pre-production planner. Given a movie idea, its art style, and its characters, identify the places/locations.",
171
162
  "",
172
163
  "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
173
164
  "",
174
- '{"places":[{"slug":"string","name":"string","imagePrompt":"string"}]}',
165
+ '{"places":[{"slug":"string","name":"string"}]}',
175
166
  "",
176
167
  "Rules:",
177
168
  '- "slug" is a short lowercase hyphenated identifier (e.g. "sunny-meadow").',
178
- "- A place's imagePrompt is a standalone text-to-image prompt that fully describes the location/environment (time of day, lighting, atmosphere, visual style).",
179
- "- Apply the given artStyle to every place imagePrompt so it matches the characters.",
180
- "- Write prompts as natural-language English sentences, never comma-separated keyword tags.",
181
169
  ].join("\n");
182
- const MOVIE_STUDIO_SCENES_PROMPT = [
170
+ const PLACE_IMAGE_PROMPT = [
171
+ "You are a movie pre-production planner. Given a movie idea, its art style, and one place, write that place's standalone image prompt.",
172
+ "",
173
+ "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
174
+ "",
175
+ '{"imagePrompt":"string"}',
176
+ "",
177
+ "Rules:",
178
+ "- The imagePrompt fully describes the location/environment (time of day, lighting, atmosphere, visual style).",
179
+ "- Apply the given artStyle.",
180
+ "- Write the prompt as natural-language English sentences, never comma-separated keyword tags.",
181
+ ].join("\n");
182
+ const MOVIE_STUDIO_SCENES_PLAN_PROMPT = [
183
183
  "You are a movie pre-production planner. Given a movie idea, its art style, its characters, and its places, break the story into scenes.",
184
184
  "",
185
185
  "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
186
186
  "",
187
- '{"scenes":[{"slug":"string","duration":number,"description":"string","characterSlugs":["string"],"placeSlug":"string","scriptLines":[{"characterSlug":"string","line":"string"}],"voiceOver":"string","imagePrompt":"string"}]}',
187
+ '{"scenes":[{"slug":"string","duration":number,"characterSlugs":["string"],"placeSlug":"string"}]}',
188
188
  "",
189
189
  "Rules:",
190
- "- Break the story into many small scenes: each scene is a single camera moment with one action beat.",
191
- "- Generate as many scenes as possible: maximize the scene count. Every distinct beat, camera change, or exchange of dialogue should become its own scene when in doubt, split into more scenes rather than fewer.",
192
- "- Never reduce or summarize the dialogue: distribute every single spoken line across the resulting small scenes so no line is lost.",
193
- "- Preserve as much detail as possible in every description and imagePrompt.",
190
+ '- "slug" is a short lowercase hyphenated identifier (e.g. "opening-meadow").',
191
+ "- Break the story into a natural number of scenes. Group related action beats and dialogue together do not inflate the scene count by splitting every moment into its own scene.",
192
+ "- Never reduce or summarize the dialogue: keep every spoken line, grouping multiple lines into a scene where it reads naturally.",
194
193
  "- Each scene references the characters and the place involved via their slugs (characterSlugs is a list; placeSlug is a single slug).",
195
- "- A scene's imagePrompt is ONE coherent shot that combines the referenced characters AND the place together, using the given artStyle.",
196
- "- scriptLines is one entry per spoken line, each with the speaking character's slug and the exact spoken line. Include every single line of dialogue in the scene.",
197
- "- voiceOver is the narration/voiceover for that scene (use an empty string when there is none).",
198
194
  "- Each scene has a duration in seconds (typically 3-20) matching how long the shot should last.",
199
- "- Apply the same artStyle to every scene imagePrompt.",
200
- "- Write prompts and dialogue as natural-language English sentences, never comma-separated keyword tags.",
195
+ ].join("\n");
196
+ const SCENE_DETAIL_PROMPT = [
197
+ "You are a movie pre-production planner. Given a movie idea, its art style, its characters, its places, and one scene, write that scene's full details.",
198
+ "",
199
+ "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
200
+ "",
201
+ '{"description":"string","scriptLines":[{"characterSlug":"string","line":"string"}],"voiceOver":"string","imagePrompt":"string"}',
202
+ "",
203
+ "Rules:",
204
+ "- description is a short description of the scene's action.",
205
+ "- scriptLines is one entry per spoken line, each with the speaking character's slug and the exact spoken line. Include every line of dialogue that belongs to this scene.",
206
+ "- voiceOver is the narration/voiceover for that scene (use an empty string when there is none).",
207
+ "- imagePrompt is ONE coherent shot that combines the referenced characters AND the place together, using the given artStyle.",
208
+ "- Apply the given artStyle to the imagePrompt.",
209
+ "- Write descriptions, prompts and dialogue as natural-language English sentences, never comma-separated keyword tags.",
201
210
  ].join("\n");
202
211
  /** Extract the first JSON object from a model response (handles code fences). */
203
212
  function extractJsonObject(text) {
@@ -217,7 +226,7 @@ function extractJsonObject(text) {
217
226
  * Persists the result to studio/:projectId/data/*.json and returns the parsed
218
227
  * result. Used both by the HTTP route below and by the generation queue worker.
219
228
  */
220
- export async function generateMovieStudioBible(projectId, idea, model) {
229
+ export async function generateMovieStudioBible(projectId, idea, model, onProgress) {
221
230
  const resolvedModel = typeof model === "string" && model.trim() ? model.trim() : DEFAULT_MODEL;
222
231
  const client = new OpenAI({
223
232
  baseURL: "http://localhost:8881/v1",
@@ -229,51 +238,127 @@ export async function generateMovieStudioBible(projectId, idea, model) {
229
238
  return Number.isFinite(n) ? n : 0;
230
239
  };
231
240
  const ask = async (systemPrompt, userContent) => {
232
- const completion = await client.chat.completions.create({
233
- model: resolvedModel,
234
- messages: [
235
- { role: "system", content: systemPrompt },
236
- { role: "user", content: userContent },
237
- ],
238
- temperature: 0.7,
239
- });
240
- const text = completion.choices?.[0]?.message?.content ?? "";
241
- return extractJsonObject(text);
241
+ let prompt = systemPrompt;
242
+ let lastError = null;
243
+ for (let attempt = 0; attempt < 3; attempt++) {
244
+ try {
245
+ const completion = await client.chat.completions.create({
246
+ model: resolvedModel,
247
+ messages: [
248
+ { role: "system", content: prompt },
249
+ { role: "user", content: userContent },
250
+ ],
251
+ temperature: 0.7,
252
+ max_tokens: 8192,
253
+ });
254
+ const text = completion.choices?.[0]?.message?.content ?? "";
255
+ return extractJsonObject(text);
256
+ }
257
+ catch (e) {
258
+ lastError = e;
259
+ // The model likely truncated its JSON — retry with an explicit
260
+ // instruction to emit one complete, valid JSON object.
261
+ prompt = `${systemPrompt}\n\nIMPORTANT: Return the complete JSON object in a single response. Do not truncate; close every string, object, and array.`;
262
+ }
263
+ }
264
+ throw lastError instanceof Error
265
+ ? lastError
266
+ : new Error("Failed to parse model JSON response");
267
+ };
268
+ // Generate one item through the model, falling back to `fallback` on failure
269
+ // (after the ask-level retries) so a single bad item never fails the whole
270
+ // production bible.
271
+ const generateItem = async (systemPrompt, userContent, fallback) => {
272
+ try {
273
+ return await ask(systemPrompt, userContent);
274
+ }
275
+ catch {
276
+ return fallback;
277
+ }
278
+ };
279
+ // Progress reporting (SSE-synced to the UI by the queue worker).
280
+ let current = 0;
281
+ let total = 0;
282
+ const addTotal = (n) => {
283
+ total += n;
242
284
  };
243
- // 1. Characters + art style.
244
- const charData = await ask(MOVIE_STUDIO_CHARACTERS_PROMPT, idea.trim());
245
- const artStyle = toStr(charData?.artStyle);
246
- const characters = (Array.isArray(charData.characters) ? charData.characters : []).map((c) => ({
247
- slug: toStr(c?.slug),
248
- name: toStr(c?.name),
249
- imagePrompt: toStr(c?.imagePrompt),
250
- }));
251
- // 2. Places (with art style + characters as context).
252
- const placeData = await ask(MOVIE_STUDIO_PLACES_PROMPT, `Art style: ${artStyle || "photo realistic render"}\n\nIdea: ${idea.trim()}\n\nCharacters:\n${JSON.stringify(characters)}`);
253
- const places = (Array.isArray(placeData.places) ? placeData.places : []).map((p) => ({
254
- slug: toStr(p?.slug),
255
- name: toStr(p?.name),
256
- imagePrompt: toStr(p?.imagePrompt),
257
- }));
258
- // 3. Scenes (with art style + characters + places as context).
259
- const sceneData = await ask(MOVIE_STUDIO_SCENES_PROMPT, `Art style: ${artStyle || "photo realistic render"}\n\nIdea: ${idea.trim()}\n\nCharacters:\n${JSON.stringify(characters)}\n\nPlaces:\n${JSON.stringify(places)}`);
260
- const scenes = (Array.isArray(sceneData.scenes) ? sceneData.scenes : []).map((s) => ({
285
+ const note = (statusText) => onProgress?.(statusText, current, total);
286
+ const advance = (statusText) => {
287
+ current += 1;
288
+ onProgress?.(statusText, current, total);
289
+ };
290
+ // 1. Character plan — a compact list of slugs/names + the film's art style.
291
+ note("Identifying characters…");
292
+ const charPlan = await ask(MOVIE_STUDIO_CHARACTERS_PLAN_PROMPT, idea.trim());
293
+ const artStyle = toStr(charPlan?.artStyle) || "photo realistic render";
294
+ const charList = (Array.isArray(charPlan.characters) ? charPlan.characters : [])
295
+ .map((c) => ({ slug: toStr(c?.slug), name: toStr(c?.name) }))
296
+ .filter((c) => c.slug);
297
+ addTotal(charList.length);
298
+ // 2. Character details — one image prompt per character (agentic loop).
299
+ const characters = [];
300
+ for (const c of charList) {
301
+ advance(`Generating character image: ${c.name || c.slug}`);
302
+ const detail = await generateItem(CHARACTER_IMAGE_PROMPT, `Art style: ${artStyle}\n\nIdea: ${idea.trim()}\n\nCharacter slug: ${c.slug}\nCharacter name: ${c.name}`, { imagePrompt: "" });
303
+ characters.push({
304
+ slug: c.slug,
305
+ name: c.name,
306
+ imagePrompt: toStr(detail?.imagePrompt),
307
+ });
308
+ }
309
+ // 3. Place plan + details.
310
+ note("Identifying places…");
311
+ const placePlan = await ask(MOVIE_STUDIO_PLACES_PLAN_PROMPT, `Art style: ${artStyle}\n\nIdea: ${idea.trim()}\n\nCharacters:\n${JSON.stringify(characters.map((c) => ({ slug: c.slug, name: c.name })))}`);
312
+ const placeList = (Array.isArray(placePlan.places) ? placePlan.places : [])
313
+ .map((p) => ({ slug: toStr(p?.slug), name: toStr(p?.name) }))
314
+ .filter((p) => p.slug);
315
+ addTotal(placeList.length);
316
+ const places = [];
317
+ for (const p of placeList) {
318
+ advance(`Generating place image: ${p.name || p.slug}`);
319
+ const detail = await generateItem(PLACE_IMAGE_PROMPT, `Art style: ${artStyle}\n\nIdea: ${idea.trim()}\n\nPlace slug: ${p.slug}\nPlace name: ${p.name}`, { imagePrompt: "" });
320
+ places.push({
321
+ slug: p.slug,
322
+ name: p.name,
323
+ imagePrompt: toStr(detail?.imagePrompt),
324
+ });
325
+ }
326
+ // 4. Scene plan + details — one scene's full details per request.
327
+ note("Planning scenes…");
328
+ const scenePlan = await ask(MOVIE_STUDIO_SCENES_PLAN_PROMPT, `Art style: ${artStyle}\n\nIdea: ${idea.trim()}\n\nCharacters:\n${JSON.stringify(characters.map((c) => ({ slug: c.slug, name: c.name })))}\n\nPlaces:\n${JSON.stringify(places.map((p) => ({ slug: p.slug, name: p.name })))}`);
329
+ const sceneList = (Array.isArray(scenePlan.scenes) ? scenePlan.scenes : [])
330
+ .map((s) => ({
261
331
  slug: toStr(s?.slug),
262
332
  duration: toNum(s?.duration),
263
- description: toStr(s?.description),
264
333
  characterSlugs: Array.isArray(s?.characterSlugs)
265
334
  ? s.characterSlugs.map(toStr).filter(Boolean)
266
335
  : [],
267
336
  placeSlug: toStr(s?.placeSlug),
268
- scriptLines: Array.isArray(s?.scriptLines)
269
- ? s.scriptLines.map((l) => ({
270
- characterSlug: toStr(l?.characterSlug),
271
- line: toStr(l?.line),
272
- }))
273
- : [],
274
- voiceOver: toStr(s?.voiceOver),
275
- imagePrompt: toStr(s?.imagePrompt),
276
- }));
337
+ }))
338
+ .filter((s) => s.slug);
339
+ addTotal(sceneList.length);
340
+ const scenes = [];
341
+ for (const s of sceneList) {
342
+ advance(`Writing scene: ${s.slug}`);
343
+ const detail = await generateItem(SCENE_DETAIL_PROMPT, `Art style: ${artStyle}\n\nIdea: ${idea.trim()}\n\nCharacters:\n${JSON.stringify(characters.map((c) => ({ slug: c.slug, name: c.name })))}\n\nPlaces:\n${JSON.stringify(places.map((p) => ({ slug: p.slug, name: p.name })))}\n\nScene slug: ${s.slug}\nDuration: ${s.duration}s\nCharacter slugs: ${s.characterSlugs.join(", ")}\nPlace slug: ${s.placeSlug}`, { description: "", scriptLines: [], voiceOver: "", imagePrompt: "" });
344
+ scenes.push({
345
+ slug: s.slug,
346
+ duration: s.duration,
347
+ description: toStr(detail?.description),
348
+ characterSlugs: s.characterSlugs,
349
+ placeSlug: s.placeSlug,
350
+ scriptLines: Array.isArray(detail?.scriptLines)
351
+ ? detail.scriptLines
352
+ .map((l) => ({
353
+ characterSlug: toStr(l?.characterSlug),
354
+ line: toStr(l?.line),
355
+ }))
356
+ .filter((l) => l.line)
357
+ : [],
358
+ voiceOver: toStr(detail?.voiceOver),
359
+ imagePrompt: toStr(detail?.imagePrompt),
360
+ });
361
+ }
277
362
  // Persist the generated production bible to studio/:projectId/data/*.json
278
363
  const dataDir = movieStudioDataDir(projectId);
279
364
  ensureDir(dataDir);
@@ -684,7 +769,7 @@ export async function agentBackend({ app, getUvPath: _getUvPath, backendPort, })
684
769
  }
685
770
  });
686
771
  app.post("/api/movie-studio/state", (req, res) => {
687
- const { projectId, idea, result, assets, videos, sceneImages } = req.body || {};
772
+ const { projectId, idea, result, assets, videos, sceneImages, renderedScenes, } = req.body || {};
688
773
  if (!projectId || !/^[a-zA-Z0-9_-]{1,64}$/.test(String(projectId))) {
689
774
  res.status(400).json({ error: "Invalid project ID" });
690
775
  return;
@@ -697,6 +782,7 @@ export async function agentBackend({ app, getUvPath: _getUvPath, backendPort, })
697
782
  assets: Array.isArray(assets) ? assets : [],
698
783
  videos: Array.isArray(videos) ? videos : [],
699
784
  sceneImages: Array.isArray(sceneImages) ? sceneImages : [],
785
+ renderedScenes: Array.isArray(renderedScenes) ? renderedScenes : [],
700
786
  }, null, 2), "utf-8");
701
787
  res.json({ ok: true });
702
788
  });
@@ -7,7 +7,6 @@ import removeFileTool from "./remove-file.js";
7
7
  import renameFileTool from "./rename-file.js";
8
8
  import grepFilesTool from "./grep-files.js";
9
9
  import showImageTool from "./show-image.js";
10
- import editImageTool from "./edit-image.js";
11
10
  import imageToVideoGenerationTool from "./image-to-video-generation.js";
12
11
  import textToVideoGenerationTool from "./text-to-video-generation.js";
13
12
  import stitchVideosTool from "./stitch-videos.js";
@@ -21,7 +20,6 @@ export const TOOLS = [
21
20
  renameFileTool,
22
21
  grepFilesTool,
23
22
  showImageTool,
24
- editImageTool,
25
23
  imageToVideoGenerationTool,
26
24
  textToVideoGenerationTool,
27
25
  stitchVideosTool,
@@ -866,7 +866,7 @@ async function streamProcessOutput(readable, prefix, send) {
866
866
  // "--model",
867
867
  // "dgrauet/ltx-2.3-mlx-q4",
868
868
  // "--prompt",
869
- // `${JSON.stringify("No text on screen. a 5 years old cute lamb wanting to have a hug, he says: Hi! Thank you for using Lambobo Studio!")}`,
869
+ // `${JSON.stringify("No text on screen. a 5 years old cute lamb wanting to have a hug, he says: Hi! Thank you for using EffectNode Media!")}`,
870
870
  // "--distilled",
871
871
  // "--low-ram",
872
872
  // "--frames",
@@ -379,7 +379,9 @@ const handlers = {
379
379
  throw new Error("Idea is required");
380
380
  }
381
381
  ctx.log(`Planning production bible (characters → places → scenes)…\n`);
382
- const result = await generateMovieStudioBible(ctx.projectId, idea.trim(), typeof model === "string" && model.trim() ? model.trim() : undefined);
382
+ const result = await generateMovieStudioBible(ctx.projectId, idea.trim(), typeof model === "string" && model.trim() ? model.trim() : undefined, (statusText, current, total) => {
383
+ ctx.update({ statusText, progress: { current, total } });
384
+ });
383
385
  ctx.log("Production bible ready.\n");
384
386
  return result;
385
387
  },
@@ -574,6 +576,11 @@ function syncToMovieStudioState(projectId, type, result) {
574
576
  if (type === "render-videos" || type === "render") {
575
577
  state.videos = Array.isArray(result?.videos) ? result.videos : state.videos ?? [];
576
578
  }
579
+ if (type === "render") {
580
+ state.renderedScenes = Array.isArray(result?.renderedScenes)
581
+ ? result.renderedScenes
582
+ : state.renderedScenes ?? [];
583
+ }
577
584
  if (type === "regenerate-asset" && result) {
578
585
  const key = `${result.kind}:${result.slug}`;
579
586
  const arr = Array.isArray(state.assets) ? state.assets : [];