@pixeldojo/mcp 0.18.0 → 0.20.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.
package/dist/index.cjs CHANGED
@@ -460,14 +460,6 @@ function buildPayload(apiId, input, src, slot, audioSlot) {
460
460
  async function run(input, ctx) {
461
461
  const paramsGate = paramsWithoutModel(input.params, input.model);
462
462
  if (paramsGate) return { kind: "text", text: paramsGate };
463
- if (input.upgrade_draft) return upgradeDraft(input, ctx);
464
- if (input.draft && input.model && input.model !== DRAFT_MODEL) {
465
- return {
466
- kind: "text",
467
- text: `Draft mode runs on ${DRAFT_MODEL} only. Drop model (or set it to ${DRAFT_MODEL}) to render a 480p draft.`
468
- };
469
- }
470
- if (input.draft) input = { ...input, model: DRAFT_MODEL, resolution: DRAFT_RESOLUTION };
471
463
  let resolved = input;
472
464
  let referenceUrls = [];
473
465
  try {
@@ -529,8 +521,6 @@ async function run(input, ctx) {
529
521
  }
530
522
  const body = buildPayload(apiId, resolved, src, refField, audioSlot);
531
523
  const paramsNote = applyParams(body, input.params, input.model, apiId);
532
- const draftDuration = input.draft ? Number(body.duration ?? DRAFT_DEFAULT_DURATION) : void 0;
533
- if (input.draft) Object.assign(body, { draft: true, resolution: DRAFT_RESOLUTION, duration: draftDuration });
534
524
  const note = [routeNote, paramsNote].filter(Boolean).join(" ");
535
525
  const submitted = await ctx.client.run(apiId, body);
536
526
  const result = await pollJobUntilDoneOrHandoff(
@@ -542,45 +532,15 @@ async function run(input, ctx) {
542
532
  sleep: ctx.pollOverrides?.sleep
543
533
  }
544
534
  );
545
- const out = prefixNote(
535
+ return prefixNote(
546
536
  pollResultToSkillResult(
547
537
  result,
548
538
  (urls) => urls.length === 0 ? `Job ${submitted.jobId} completed but returned no assets.` : urls.length === 1 ? `Generated. Asset: ${urls[0]}` : `Generated ${urls.length} assets. URLs: ${urls.join(", ")}`
549
539
  ),
550
540
  note
551
541
  );
552
- return draftDuration === void 0 ? out : { ...out, text: `${out.text}
553
-
554
- ${draftLine(submitted.jobId, draftDuration)}` };
555
- }
556
- function draftLine(jobId, duration) {
557
- return `This is a 480p draft (draft_job_id: ${jobId}, duration: ${duration}). When the user likes it, call pixeldojo_generate with {"upgrade_draft": "${jobId}", "duration": ${duration}} to render the same take at ${DRAFT_FINAL_RESOLUTION} (billed as a ${DRAFT_FINAL_RESOLUTION} run; upgrade it within a day).`;
558
- }
559
- async function upgradeDraft(input, ctx) {
560
- if (input.duration === void 0) {
561
- return {
562
- kind: "text",
563
- text: `upgrade_draft also needs duration: the draft's own length (its reply says "duration: N"; ${DRAFT_DEFAULT_DURATION} when it was made without one).`
564
- };
565
- }
566
- const submitted = await ctx.client.run(DRAFT_MODEL, {
567
- draft_job_id: input.upgrade_draft,
568
- resolution: DRAFT_FINAL_RESOLUTION,
569
- duration: input.duration
570
- });
571
- const result = await pollJobUntilDoneOrHandoff(submitted.jobId, (id) => ctx.client.getJob(id), ctx.buildStatusUrl, {
572
- now: ctx.pollOverrides?.now,
573
- sleep: ctx.pollOverrides?.sleep
574
- });
575
- return prefixNote(
576
- pollResultToSkillResult(
577
- result,
578
- (urls) => urls.length === 0 ? `Job ${submitted.jobId} completed but returned no assets.` : `Upgraded to ${DRAFT_FINAL_RESOLUTION}. Asset: ${urls[0]}`
579
- ),
580
- `Rendering draft ${input.upgrade_draft} at ${DRAFT_FINAL_RESOLUTION}.`
581
- );
582
542
  }
583
- var DRAFT_MODEL, DRAFT_RESOLUTION, DRAFT_FINAL_RESOLUTION, DRAFT_DEFAULT_DURATION, DRAFT_DESCRIPTION, UPGRADE_DESCRIPTION, InputSchema, DEFAULT_MODEL, VIDEO_REFERENCE_MODEL, VIDEO_REFERENCE_SLOTS, VIDEO_INPUT_MODELS, InputJsonSchema, generateSkill;
543
+ var InputSchema, DEFAULT_MODEL, VIDEO_REFERENCE_MODEL, VIDEO_REFERENCE_SLOTS, VIDEO_INPUT_MODELS, InputJsonSchema, generateSkill;
584
544
  var init_generate = __esm({
585
545
  "src/skills/generate.ts"() {
586
546
  init_poll();
@@ -588,14 +548,8 @@ var init_generate = __esm({
588
548
  init_upload_helpers();
589
549
  init_schema_snap();
590
550
  init_params();
591
- DRAFT_MODEL = "seedance-2-5";
592
- DRAFT_RESOLUTION = "480p";
593
- DRAFT_FINAL_RESOLUTION = "1080p";
594
- DRAFT_DEFAULT_DURATION = 5;
595
- DRAFT_DESCRIPTION = "Seedance 2.5 draft mode: render a cheap 480p draft (runs on seedance-2-5) so the user can try takes before paying for full resolution. The reply gives the draft's job id and duration; pass them to upgrade_draft to render that exact take at 1080p.";
596
- UPGRADE_DESCRIPTION = "Job id of a finished Seedance 2.5 draft to render at 1080p, the same take with full detail. Also pass `duration` equal to the draft's (the draft reply states it). The draft's prompt and media are reused, so prompt and references are ignored. Billed as a 1080p run.";
597
551
  InputSchema = zod.z.object({
598
- prompt: zod.z.string().min(1).max(8e3).describe("Prompt describing what to generate."),
552
+ prompt: zod.z.string().min(1).max(8e3).describe('Prompt describing what to generate. Write @Name to use a character or element the user saved in Character Studio ("@Mara on a beach"): its photos are attached automatically on models that take reference images. pixeldojo_library source=characters lists them.'),
599
553
  model: zod.z.string().optional().describe(
600
554
  `Model apiId. Recommended (the models the PixelDojo studios use): "seedream-5" for images (the default), "seedance-2-5" for video (prompt only or a starting image), "wan-3-0-video" for video the others refuse. Use another model only when the user names one. Full catalog: https://pixeldojo.ai/api/v1/models; one model's fields: https://pixeldojo.ai/llm/{apiId}.txt. Omit for seedream-5, which makes still images only; name a video model to get a video.`
601
555
  ),
@@ -612,8 +566,6 @@ var init_generate = __esm({
612
566
  "Source video clip to animate / restyle / extend. Use this for videos; image_url is for still frames. With no model, a clip runs on seedance-2-reference; a named model with no clip input is swapped for it and the result says so."
613
567
  ),
614
568
  params: ParamsInput,
615
- draft: zod.z.boolean().optional().describe(DRAFT_DESCRIPTION),
616
- upgrade_draft: zod.z.string().regex(/^[A-Za-z0-9_-]{1,128}$/).optional().describe(UPGRADE_DESCRIPTION),
617
569
  image_path: zod.z.string().min(1).optional().describe(
618
570
  "Absolute local file path to a reference image/video. The skill uploads it to PixelDojo first (24h temp storage) and then dispatches. Prefer this over image_url when the user has the file on disk."
619
571
  ),
@@ -635,7 +587,7 @@ var init_generate = __esm({
635
587
  InputJsonSchema = {
636
588
  type: "object",
637
589
  properties: {
638
- prompt: { type: "string", description: "Prompt describing what to generate." },
590
+ prompt: { type: "string", description: 'Prompt describing what to generate. Write @Name to use a character or element the user saved in Character Studio ("@Mara on a beach"): its photos are attached automatically on models that take reference images. pixeldojo_library source=characters lists them.' },
639
591
  model: {
640
592
  type: "string",
641
593
  description: `Model apiId. Recommended (the models the PixelDojo studios use): "seedream-5" for images (the default), "seedance-2-5" for video (prompt only or a starting image), "wan-3-0-video" for video the others refuse. Use another model only when the user names one. Full catalog: https://pixeldojo.ai/api/v1/models; one model's fields: https://pixeldojo.ai/llm/{apiId}.txt. Omit for seedream-5, which makes still images only; name a video model to get a video.`
@@ -667,8 +619,6 @@ var init_generate = __esm({
667
619
  description: "Source video clip to animate / restyle / extend. Use this for videos; image_url is for still frames. With no model, a clip runs on seedance-2-reference; a named model with no clip input is swapped for it and the result says so."
668
620
  },
669
621
  params: PARAMS_JSON_SCHEMA,
670
- draft: { type: "boolean", description: DRAFT_DESCRIPTION },
671
- upgrade_draft: { type: "string", description: UPGRADE_DESCRIPTION },
672
622
  image_urls: {
673
623
  type: "array",
674
624
  items: { type: "string", format: "uri" },
@@ -688,8 +638,8 @@ var init_generate = __esm({
688
638
  };
689
639
  generateSkill = {
690
640
  name: "pixeldojo_generate",
691
- description: "Generate an image or video from a prompt with any model in the PixelDojo catalog. With no `model`, a prompt runs on seedream-5, which makes still images only. For a video, name seedance-2-5, which works from a prompt alone or a starting image. To try video takes cheaply, set draft: true (a 480p Seedance 2.5 draft), then upgrade_draft the one the user picks to 1080p. If a provider refuses the content, retry once on wan-3-0-video (video) or seedream-5 (images), the least-restricted models. For a multi-shot film or a music video synced to a song use pixeldojo_film; to change, move or extend an existing clip use pixeldojo_video. These are the models the PixelDojo studios use; name another only when the user asks for it. References: `image_url`/`image_urls` for stills, `video_url` for a source clip, `image_path`/`image_paths` for absolute local files (uploaded to 24h temp storage first). A clip or an audio track URL with no `model` runs on seedance-2-reference; a named model that cannot read a clip is swapped for it and the result says so, while a named model with no audio input drops the track. Model-specific settings go in `params` alongside an explicit `model`. Returns asset URLs, or a jobId for pixeldojo_status when the job outlasts the 30s wait.",
692
- hostedDescription: "Generate an image or video from a prompt with any model in the PixelDojo catalog. With no `model`, a prompt runs on seedream-5, which makes still images only. For a video, name seedance-2-5, which works from a prompt alone or a starting image. To try video takes cheaply, set draft: true (a 480p Seedance 2.5 draft), then upgrade_draft the one the user picks to 1080p. If a provider refuses the content, retry once on wan-3-0-video (video) or seedream-5 (images), the least-restricted models. For a multi-shot film or a music video synced to a song use pixeldojo_film; to change, move or extend an existing clip use pixeldojo_video. These are the models the PixelDojo studios use; name another only when the user asks for it. References: `image_url`/`image_urls` for stills and `video_url` for a source clip, all public http(s) URLs. A clip or an audio track URL with no `model` runs on seedance-2-reference; a named model that cannot read a clip is swapped for it and the result says so, while a named model with no audio input drops the track. Model-specific settings go in `params` alongside an explicit `model`. Returns asset URLs, or a jobId for pixeldojo_status when the job outlasts the 30s wait.",
641
+ description: "Generate an image or video from a prompt with any model in the PixelDojo catalog. With no `model`, a prompt runs on seedream-5, which makes still images only. For a video, name seedance-2-5, which works from a prompt alone or a starting image. If a provider refuses the content, retry once on wan-3-0-video (video) or seedream-5 (images), the least-restricted models. For a multi-shot film or a music video synced to a song use pixeldojo_film; to change, move or extend an existing clip use pixeldojo_video. These are the models the PixelDojo studios use; name another only when the user asks for it. References: `image_url`/`image_urls` for stills, `video_url` for a source clip, `image_path`/`image_paths` for absolute local files (uploaded to 24h temp storage first). A clip or an audio track URL with no `model` runs on seedance-2-reference; a named model that cannot read a clip is swapped for it and the result says so, while a named model with no audio input drops the track. Model-specific settings go in `params` alongside an explicit `model`. Returns asset URLs, or a jobId for pixeldojo_status when the job outlasts the 30s wait.",
642
+ hostedDescription: "Generate an image or video from a prompt with any model in the PixelDojo catalog. With no `model`, a prompt runs on seedream-5, which makes still images only. For a video, name seedance-2-5, which works from a prompt alone or a starting image. If a provider refuses the content, retry once on wan-3-0-video (video) or seedream-5 (images), the least-restricted models. For a multi-shot film or a music video synced to a song use pixeldojo_film; to change, move or extend an existing clip use pixeldojo_video. These are the models the PixelDojo studios use; name another only when the user asks for it. References: `image_url`/`image_urls` for stills and `video_url` for a source clip, all public http(s) URLs. A clip or an audio track URL with no `model` runs on seedance-2-reference; a named model that cannot read a clip is swapped for it and the result says so, while a named model with no audio input drops the track. Model-specific settings go in `params` alongside an explicit `model`. Returns asset URLs, or a jobId for pixeldojo_status when the job outlasts the 30s wait.",
693
643
  inputZod: InputSchema,
694
644
  inputJsonSchema: InputJsonSchema,
695
645
  allowedModels: null,
@@ -849,7 +799,7 @@ var init_edit = __esm({
849
799
  InputJsonSchema2 = {
850
800
  type: "object",
851
801
  properties: {
852
- prompt: { type: "string", description: "Edit instruction." },
802
+ prompt: { type: "string", description: 'Edit instruction. Write @Name to use a character or element the user saved in Character Studio ("@Mara on a beach"): its photos are attached automatically on models that take reference images. pixeldojo_library source=characters lists them.' },
853
803
  image_url: {
854
804
  type: "string",
855
805
  format: "uri",
@@ -1455,6 +1405,54 @@ async function filmApi(ctx, method, path, body) {
1455
1405
  const data = await res.json().catch(() => ({}));
1456
1406
  return { status: res.status, data };
1457
1407
  }
1408
+ function noFilm(status2, data, filmId, retry) {
1409
+ if (status2 === 404) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1410
+ const input = { film_id: filmId, ...Object.fromEntries(Object.entries(retry).filter(([, v]) => v !== void 0)) };
1411
+ return {
1412
+ kind: "text",
1413
+ text: `Could not reach film ${filmId} just now (${status2 ? `HTTP ${status2}` : "no response"}). The film is safe and nothing changed. Call pixeldojo_film with ${JSON.stringify(input)} again in about 30 seconds. Do not plan or start a new film.`,
1414
+ ...input.action === "status" ? { followUp: { input, afterSec: 30 } } : {}
1415
+ };
1416
+ }
1417
+ function keepGoing(filmId, afterSec, extra = {}) {
1418
+ const set = Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== void 0));
1419
+ return { input: { film_id: filmId, action: "status", ...set }, afterSec };
1420
+ }
1421
+ function flaggedClip(sh) {
1422
+ const qa = sh.clipQa;
1423
+ const file = (u) => u?.split("/").pop();
1424
+ return Boolean(qa && qa.ok === false && sh.videoUrl && file(qa.url) === file(sh.videoUrl));
1425
+ }
1426
+ function editReviewing(sh, nowMs) {
1427
+ const e = sh.editSuggestion;
1428
+ const file = (u) => u?.split("/").pop();
1429
+ return Boolean(e?.pending && sh.videoUrl && file(e.url) === file(sh.videoUrl) && nowMs - Date.parse(e.checkedAt) < 4 * 6e4);
1430
+ }
1431
+ function openEdit(sh) {
1432
+ const e = sh.editSuggestion;
1433
+ const file = (u) => u?.split("/").pop();
1434
+ return Boolean(e && !e.pending && !e.appliedAt && !e.skipped && e.inSec !== void 0 && sh.videoUrl && file(e.url) === file(sh.videoUrl));
1435
+ }
1436
+ function cutSec(sh) {
1437
+ if (sh.leftOut) return 0;
1438
+ const speed = sh.speed && sh.speed > 1 ? sh.speed : 1;
1439
+ return Math.max(1, (sh.videoDurationSec ?? sh.durationSec ?? 0) - (sh.trimStartSec ?? 0) - (sh.trimEndSec ?? 0)) / speed;
1440
+ }
1441
+ function editorCutting(f, nowMs) {
1442
+ const c = f.editorCut;
1443
+ const fresh = (iso) => Boolean(iso && nowMs - Date.parse(iso) < 4 * 6e4);
1444
+ if (c?.pending) return fresh(c.startedAt);
1445
+ const lastReview = shots(f).map((sh) => sh.editSuggestion?.checkedAt).filter((t) => Boolean(t)).sort().pop();
1446
+ if (!lastReview || !fresh(lastReview)) return false;
1447
+ return !(c?.builtAt && c.builtAt >= lastReview);
1448
+ }
1449
+ function runtimeText(f) {
1450
+ const sec = Math.round(shots(f).reduce((n, sh) => n + cutSec(sh), 0));
1451
+ return sec < 90 ? `${sec}s` : `${Math.floor(sec / 60)}m ${sec % 60}s`;
1452
+ }
1453
+ function shotLabels(f) {
1454
+ return new Map(f.scenes.flatMap((s, si) => s.shots.map((sh, i) => [sh.id, `${si + 1}.${i + 1}`])));
1455
+ }
1458
1456
  async function applyCast(ctx, filmId, cast) {
1459
1457
  let charged = false;
1460
1458
  const lines = await Promise.all(cast.map(async (c) => {
@@ -1559,6 +1557,7 @@ async function plan(input, ctx) {
1559
1557
  aspect: input.aspect,
1560
1558
  look: input.look ?? (music ? MUSIC_VIDEO_LOOKS[0] : "live-action"),
1561
1559
  lengthTarget: input.length,
1560
+ ...!music && input.pacing === "trailer" ? { pacing: "trailer" } : {},
1562
1561
  assetIds: [],
1563
1562
  ...music ? {
1564
1563
  // No script step names a music video, so it would stay "Untitled film".
@@ -1574,12 +1573,13 @@ async function plan(input, ctx) {
1574
1573
  const castStarted = Date.now();
1575
1574
  const cast = input.cast?.length ? await applyCast(ctx, id, input.cast) : { lines: [], charged: false };
1576
1575
  const pollBudget = Math.max(0, PLANNER_MAX_POLLS - Math.ceil((Date.now() - castStarted) / PLANNER_POLL_MS));
1577
- const reply = (text) => ({
1576
+ const reply = (text, followUp) => ({
1578
1577
  kind: "text",
1579
1578
  text: [
1580
1579
  ...cast.lines,
1581
1580
  cast.charged ? text.replace(/Nothing has been charged( yet)?\./g, "Only the cast portraits above have been charged.") : text
1582
- ].join("\n")
1581
+ ].join("\n"),
1582
+ ...followUp ? { followUp } : {}
1583
1583
  });
1584
1584
  let planned;
1585
1585
  if (music) {
@@ -1590,14 +1590,14 @@ ${input.lyrics.trim()}` : ""].filter(Boolean).join("\n\n");
1590
1590
  let script = input.script?.trim() ?? "";
1591
1591
  if (!script) {
1592
1592
  const wrote = await runPlanner(ctx, id, `/api/films/${id}/script`, { idea: input.idea.trim() }, pollBudget);
1593
- if ("pending" in wrote) return reply(stillPlanningText(id, "script"));
1593
+ if ("pending" in wrote) return reply(stillPlanningText(id, "script"), keepGoing(id, 30, { soundtrack: input.soundtrack }));
1594
1594
  if ("error" in wrote) return reply(`Script writing failed: ${wrote.error}`);
1595
1595
  script = wrote.film.script ?? "";
1596
1596
  if (!script) return reply("Script writing failed: the studio returned an empty script.");
1597
1597
  }
1598
1598
  planned = await runPlanner(ctx, id, `/api/films/${id}/plan`, { script });
1599
1599
  }
1600
- if ("pending" in planned) return reply(stillPlanningText(id));
1600
+ if ("pending" in planned) return reply(stillPlanningText(id), keepGoing(id, 30, { soundtrack: input.soundtrack }));
1601
1601
  if ("error" in planned) return reply(`Shot planning failed: ${planned.error}`);
1602
1602
  const film = planned.film;
1603
1603
  if (needsSlice(film)) {
@@ -1610,17 +1610,17 @@ ${err} Call action "status" to retry the cut.`);
1610
1610
  return reply([plannedText(film, ctx), uncast.length ? `
1611
1611
  ${castNeededText(id, uncast)}` : ""].filter(Boolean).join("\n"));
1612
1612
  }
1613
- async function resumePlanning(film, ctx) {
1613
+ async function resumePlanning(film, ctx, soundtrack) {
1614
1614
  if (film.scenes.length > 0 && film.llmJob?.status !== "running") return null;
1615
1615
  if (film.llmJob?.status === "running") {
1616
- return { kind: "text", text: stillPlanningText(film.id, film.llmJob.kind === "script" ? "script" : "plan") };
1616
+ return { kind: "text", text: stillPlanningText(film.id, film.llmJob.kind === "script" ? "script" : "plan"), followUp: keepGoing(film.id, 30, { soundtrack }) };
1617
1617
  }
1618
1618
  const prior = film.llmJob?.status === "error" ? `The last planning run failed (${film.llmJob.error ?? "unknown"}). ` : "";
1619
1619
  if (!film.script?.trim()) {
1620
1620
  return { kind: "text", text: `${prior}This film has nothing to plan from. Start a new one with action "plan".` };
1621
1621
  }
1622
1622
  const planned = await runPlanner(ctx, film.id, `/api/films/${film.id}/plan`, { script: film.script }, 0);
1623
- if ("pending" in planned) return { kind: "text", text: `${prior}${stillPlanningText(film.id)}` };
1623
+ if ("pending" in planned) return { kind: "text", text: `${prior}${stillPlanningText(film.id)}`, followUp: keepGoing(film.id, 30, { soundtrack }) };
1624
1624
  if ("error" in planned) return { kind: "text", text: `Shot planning failed: ${planned.error} Call action "status" to retry.` };
1625
1625
  if (needsSlice(planned.film)) {
1626
1626
  const err = await sliceSong(ctx, planned.film);
@@ -1630,28 +1630,49 @@ ${err} Call action "status" to retry the cut.` };
1630
1630
  }
1631
1631
  return { kind: "text", text: plannedText(planned.film, ctx) };
1632
1632
  }
1633
- async function produce(filmId, ctx) {
1634
- const { data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1635
- if (!data.film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1636
- const planning = await resumePlanning(data.film, ctx);
1633
+ async function produce(filmId, soundtrack, ctx) {
1634
+ const { status: status2, data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1635
+ if (!data.film) return noFilm(status2, data, filmId, { action: "produce", soundtrack });
1636
+ const planning = await resumePlanning(data.film, ctx, soundtrack);
1637
1637
  if (planning) return planning;
1638
1638
  const uncast = await uncastRolesOf(ctx, filmId);
1639
1639
  if (uncast.length) return { kind: "text", text: `Nothing was charged. ${castNeededText(filmId, uncast)}` };
1640
+ let propsNote = "";
1641
+ if (!shots(data.film).some((sh) => sh.frameUrl)) {
1642
+ const locked = await filmApi(
1643
+ ctx,
1644
+ "POST",
1645
+ `/api/films/${filmId}/props`,
1646
+ {}
1647
+ ).catch(() => null);
1648
+ const made = locked?.status === 200 ? locked.data.created ?? [] : [];
1649
+ if (made.length) {
1650
+ propsNote = ` Locked in ${made.length} recurring prop${made.length === 1 ? "" : "s"} first so ${made.length === 1 ? "it looks" : "they look"} the same in every shot: ${made.map((m) => `${m.name} (${m.shots} shots)`).join(", ")}${locked?.data.creditCost ? `, ${locked.data.creditCost} credits` : ""}.`;
1651
+ }
1652
+ }
1640
1653
  const pending = shots(data.film).filter((sh) => sh.status === "idle" || sh.status === "failed" && !sh.frameUrl);
1641
1654
  let fired = 0;
1655
+ let refused;
1642
1656
  for (const sh of pending.slice(0, 6)) {
1643
1657
  const r = await filmApi(ctx, "POST", `/api/films/${filmId}/shots/${sh.id}/generate`, { kind: "frame" });
1644
1658
  if (r.status === 200) fired += 1;
1659
+ else if (!benignDispatchRefusal(r.status, r.data.error)) refused ??= r.data.error ?? `HTTP ${r.status}`;
1660
+ }
1661
+ if (fired === 0 && refused) {
1662
+ return { kind: "text", text: `Production could not start on "${data.film.title}": ${refused}` };
1645
1663
  }
1646
1664
  return {
1647
1665
  kind: "text",
1648
- text: `Production started on "${data.film.title}": ${fired} frame(s) dispatched (each is a normal frame charge; the server's Frame check reviews every one). Poll pixeldojo_film with {"film_id": "${filmId}", "action": "status"} every ~30 seconds \u2014 each poll advances the pipeline.`
1666
+ text: `Production started on "${data.film.title}": ${fired} frame(s) dispatched (each is a normal frame charge; the server's Frame check reviews every one).${propsNote}${refused ? ` Some could not start: ${refused}` : ""} Poll pixeldojo_film with {"film_id": "${filmId}", "action": "status"} every ~30 seconds \u2014 each poll advances the pipeline.`,
1667
+ // The caller's soundtrack choice rides along: the follow-up status calls
1668
+ // are the ones that reach the score.
1669
+ followUp: keepGoing(filmId, 30, { soundtrack })
1649
1670
  };
1650
1671
  }
1651
1672
  function buildScorePlanBody(film) {
1652
1673
  const scenes = film.scenes.filter((s) => s.shots.length > 0);
1653
1674
  const sections = scenes.flatMap((scene, i) => {
1654
- const runtime = Math.max(3e3, scene.shots.reduce((n, sh) => n + (sh.durationSec || 0), 0) * 1e3);
1675
+ const runtime = Math.max(3e3, Math.round(scene.shots.reduce((n, sh) => n + cutSec(sh), 0) * 1e3));
1655
1676
  const styles = i === 0 ? ARC.opening : i === scenes.length - 1 ? ARC.finale : ARC.middle;
1656
1677
  const parts = Math.max(1, Math.ceil(runtime / 12e4));
1657
1678
  const partMs = Math.round(runtime / parts);
@@ -1674,7 +1695,7 @@ function buildScorePlanBody(film) {
1674
1695
  };
1675
1696
  }
1676
1697
  function buildFlatMusicBody(film, all) {
1677
- const runtimeSec = all.reduce((n, sh) => n + (sh.durationSec || 0), 0);
1698
+ const runtimeSec = all.reduce((n, sh) => n + cutSec(sh), 0);
1678
1699
  const ms = Math.min(3e5, Math.max(3e4, Math.ceil(runtimeSec * 1e3 / 5e3) * 5e3));
1679
1700
  return {
1680
1701
  prompt: `${LOOK_MUSIC[film.look] ?? LOOK_MUSIC["live-action"]}. Instrumental only, no vocals. One consistent theme, loopable as a background bed under dialogue.`,
@@ -1684,37 +1705,57 @@ function buildFlatMusicBody(film, all) {
1684
1705
  };
1685
1706
  }
1686
1707
  async function advance(filmId, soundtrack, musicJobId, ctx) {
1687
- const { data } = await filmApi(ctx, "GET", `/api/films/${filmId}?refresh=1`);
1708
+ const { status: status2, data } = await filmApi(ctx, "GET", `/api/films/${filmId}?refresh=1`);
1688
1709
  const film = data.film;
1689
- if (!film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1710
+ if (!film) return noFilm(status2, data, filmId, { action: "status", soundtrack });
1690
1711
  const all = shots(film);
1691
1712
  if (film.status === "exported" && film.exportUrl) {
1692
1713
  return {
1693
1714
  kind: "text",
1694
- text: `Film finished: "${film.title}"
1715
+ text: [
1716
+ `Film finished: "${film.title}" (${runtimeText(film)})
1695
1717
  Watch/download: ${film.exportUrl}
1696
1718
  Workspace: ${ctx.baseUrl}/film-studio/${film.id}
1697
- It is saved in the Library folder "Film: ${film.title}".`
1719
+ It is saved in the Library folder "Film: ${film.title}".`,
1720
+ stillFlagged(film),
1721
+ shotIndex(film)
1722
+ ].filter(Boolean).join("\n")
1698
1723
  };
1699
1724
  }
1700
- if (film.exportError) {
1701
- return { kind: "text", text: `Export failed: ${film.exportError} Poll again with action "status" to retry.` };
1725
+ const startExport = async () => {
1726
+ const r = await filmApi(ctx, "POST", `/api/films/${filmId}/export`, {});
1727
+ return r.status === 200 || r.status === 409 ? null : r.data.error ?? `HTTP ${r.status}`;
1728
+ };
1729
+ if (film.exportError && all.length > 0 && all.every((sh) => sh.status === "clip-ready")) {
1730
+ const again = await startExport();
1731
+ return {
1732
+ kind: "text",
1733
+ text: again ? `Export failed: ${film.exportError} Starting it again failed too: ${again}` : `The last export failed (${film.exportError}). Started it again; exports are free. Call pixeldojo_film with {"film_id": "${filmId}", "action": "status"} in about a minute for the finished film.`
1734
+ };
1702
1735
  }
1703
- const planning = await resumePlanning(film, ctx);
1736
+ const planning = await resumePlanning(film, ctx, soundtrack);
1704
1737
  if (planning) return planning;
1705
1738
  if (needsSlice(film)) {
1706
1739
  const err = await sliceSong(ctx, film);
1707
1740
  if (err) return { kind: "text", text: `${err} Poll again with action "status" to retry.` };
1708
- return { kind: "text", text: `Song cut into one slice per shot. Poll again with {"film_id": "${filmId}", "action": "status"} to keep production moving.` };
1741
+ return {
1742
+ kind: "text",
1743
+ text: `Song cut into one slice per shot. Poll again with {"film_id": "${filmId}", "action": "status"} to keep production moving.`,
1744
+ followUp: keepGoing(filmId, 5, { soundtrack })
1745
+ };
1709
1746
  }
1710
1747
  if (shots(film).length > 0 && shots(film).every((sh) => sh.status === "idle")) {
1711
1748
  return { kind: "text", text: plannedText(film, ctx) };
1712
1749
  }
1713
1750
  let started = 0;
1714
1751
  const resent = /* @__PURE__ */ new Set();
1752
+ const refusedStarts = [];
1715
1753
  const dispatch = async (sh, kind) => {
1716
1754
  const r = await filmApi(ctx, "POST", `/api/films/${filmId}/shots/${sh.id}/generate`, { kind });
1717
- if (r.status !== 200) return;
1755
+ if (r.status !== 200) {
1756
+ if (!benignDispatchRefusal(r.status, r.data.error)) refusedStarts.push(r.data.error ?? `HTTP ${r.status}`);
1757
+ return;
1758
+ }
1718
1759
  started += 1;
1719
1760
  if (sh.status === "failed") resent.add(sh.id);
1720
1761
  };
@@ -1732,7 +1773,45 @@ It is saved in the Library folder "Film: ${film.title}".`
1732
1773
  const clipQueue = framesDone ? all.filter((sh) => sh.status === "frame-ready" || sh.status === "failed" && frameDone(sh) && !isRefused(sh)) : [];
1733
1774
  for (const sh of clipQueue.slice(0, Math.max(0, 4 - inFlight))) await dispatch(sh, "clip");
1734
1775
  const clipsDone = all.length > 0 && all.every((sh) => sh.status === "clip-ready");
1776
+ const toRedo = clipsDone ? all.filter((sh) => flaggedClip(sh) && !(sh.manualClipRedos ?? 0)) : [];
1777
+ if (toRedo.length) {
1778
+ const labels2 = shotLabels(film);
1779
+ const redone = [];
1780
+ let refusedRedo;
1781
+ for (const sh of toRedo) {
1782
+ const r = await filmApi(ctx, "POST", `/api/films/${filmId}/shots/${sh.id}/generate`, { kind: "clip" });
1783
+ if (r.status === 200) redone.push(`${labels2.get(sh.id)} (${sh.clipQa.note || sh.clipQa.issues.join(", ")})`);
1784
+ else if (!benignDispatchRefusal(r.status, r.data.error)) refusedRedo ??= r.data.error ?? `HTTP ${r.status}`;
1785
+ }
1786
+ if (redone.length) {
1787
+ return {
1788
+ kind: "text",
1789
+ text: `All ${all.length} clips rendered. The clip check flagged ${redone.length}, so each gets one more take before the final cut (a normal clip charge each): ${redone.join("; ")}.${refusedRedo ? ` Another could not start: ${refusedRedo}` : ""} Poll again in ~30s with {"film_id": "${filmId}", "action": "status"}.`,
1790
+ followUp: keepGoing(filmId, 30, { soundtrack })
1791
+ };
1792
+ }
1793
+ }
1735
1794
  if (clipsDone) {
1795
+ const reviewing = all.filter((sh) => editReviewing(sh, Date.now())).length;
1796
+ if (reviewing) {
1797
+ return {
1798
+ kind: "text",
1799
+ text: `All ${all.length} clips ready. The editor is marking where ${reviewing === 1 ? "the last clip starts and lands" : `${reviewing} clips start and land`} so the cut drops the waiting. Poll again in ~20s with {"film_id": "${filmId}", "action": "status"}.`,
1800
+ followUp: keepGoing(filmId, 20, { soundtrack })
1801
+ };
1802
+ }
1803
+ if (editorCutting(film, Date.now())) {
1804
+ return {
1805
+ kind: "text",
1806
+ text: `All ${all.length} clips ready. The editor is watching the whole film and making the cut. Poll again in ~30s with {"film_id": "${filmId}", "action": "status"}.`,
1807
+ followUp: keepGoing(filmId, 30, { soundtrack })
1808
+ };
1809
+ }
1810
+ const openCut = Boolean(film.editorCut?.shots?.length && !film.editorCut.appliedAt && !film.editorCut.skipped);
1811
+ if (openCut || all.some(openEdit)) {
1812
+ const cut = await filmApi(ctx, "POST", `/api/films/${filmId}/edit`, { action: "apply" });
1813
+ if (cut.data.film) film.scenes = cut.data.film.scenes;
1814
+ }
1736
1815
  if (all.some((sh) => needsSave(sh.videoUrl))) {
1737
1816
  await filmApi(ctx, "POST", `/api/films/${filmId}/save`, { kind: "clip" });
1738
1817
  }
@@ -1744,27 +1823,47 @@ It is saved in the Library folder "Film: ${film.title}".`
1744
1823
  const outUrl = out?.audio ?? out?.video ?? out?.images?.[0];
1745
1824
  if (job.status === "completed" && typeof outUrl === "string") {
1746
1825
  await filmApi(ctx, "PATCH", `/api/films/${filmId}`, { audioStems: [{ kind: "music", url: outUrl }] });
1747
- await filmApi(ctx, "POST", `/api/films/${filmId}/export`, {});
1748
- return { kind: "text", text: `Score attached \u2014 export started. Poll again in ~60s with {"film_id": "${filmId}", "action": "status"} for the finished film URL.` };
1826
+ const exportError2 = await startExport();
1827
+ if (exportError2) return { kind: "text", text: `Score attached, but the export could not start: ${exportError2}` };
1828
+ return {
1829
+ kind: "text",
1830
+ text: `Score attached \u2014 export started. Poll again in ~60s with {"film_id": "${filmId}", "action": "status"} for the finished film URL.`,
1831
+ followUp: keepGoing(filmId, 60, { soundtrack })
1832
+ };
1749
1833
  }
1750
1834
  if (job.status === "failed") {
1751
1835
  return { kind: "text", text: `The score failed (${job.error ?? "unknown"}) and was refunded. Poll again to retry, or pass soundtrack "none" to export without music.` };
1752
1836
  }
1753
- return { kind: "text", text: `Score still composing. Poll again in ~20s with {"film_id": "${filmId}", "action": "status", "music_job_id": "${musicJobId}"}.` };
1837
+ return {
1838
+ kind: "text",
1839
+ text: `Score still composing. Poll again in ~20s with {"film_id": "${filmId}", "action": "status", "music_job_id": "${musicJobId}"}.`,
1840
+ followUp: keepGoing(filmId, 20, { soundtrack, music_job_id: musicJobId })
1841
+ };
1754
1842
  }
1755
- const body = soundtrack === "scene-synced" ? buildScorePlanBody(film) : buildFlatMusicBody(film, all);
1843
+ const body = soundtrack === "scene-synced" ? buildScorePlanBody(film) : buildFlatMusicBody(film, shots(film));
1756
1844
  const dispatch2 = await ctx.client.run("text-to-music", body);
1757
- return { kind: "text", text: `All ${all.length} clips ready \u2014 the ${soundtrack} score is composing (charged like any music generation). Poll again in ~20s with {"film_id": "${filmId}", "action": "status", "music_job_id": "${dispatch2.jobId}"}.` };
1845
+ return {
1846
+ kind: "text",
1847
+ text: `All ${all.length} clips ready \u2014 the ${soundtrack} score is composing (charged like any music generation). Poll again in ~20s with {"film_id": "${filmId}", "action": "status", "music_job_id": "${dispatch2.jobId}"}.`,
1848
+ followUp: keepGoing(filmId, 20, { soundtrack, music_job_id: dispatch2.jobId })
1849
+ };
1758
1850
  }
1759
- await filmApi(ctx, "POST", `/api/films/${filmId}/export`, {});
1760
- return { kind: "text", text: `All clips ready \u2014 export started (${progressLine(film)}). Poll again in ~60s with action "status" for the finished film URL.` };
1851
+ const exportError = await startExport();
1852
+ if (exportError) return { kind: "text", text: `All clips ready, but the export could not start: ${exportError}` };
1853
+ return {
1854
+ kind: "text",
1855
+ text: `All clips ready \u2014 export started (${progressLine(film)}). Poll again in ~60s with action "status" for the finished film URL.`,
1856
+ followUp: keepGoing(filmId, 60, { soundtrack })
1857
+ };
1761
1858
  }
1762
1859
  const failed = all.filter((sh) => sh.status === "failed");
1763
1860
  const refused = failed.filter(isRefused);
1764
1861
  const retrying = failed.filter((sh) => !isRefused(sh));
1765
1862
  const sentAgain = retrying.filter((sh) => resent.has(sh.id)).length;
1766
- const paused = refused.length > 0 && started === 0 && !all.some((sh) => sh.status === "frame-generating" || sh.status === "clip-generating");
1767
- const labels = new Map(film.scenes.flatMap((s, si) => s.shots.map((sh, i) => [sh.id, `${si + 1}.${i + 1}`])));
1863
+ const rendering = all.some((sh) => sh.status === "frame-generating" || sh.status === "clip-generating");
1864
+ const paused = refused.length > 0 && started === 0 && !rendering;
1865
+ const moving = started > 0 || rendering;
1866
+ const labels = shotLabels(film);
1768
1867
  const reason = (sh) => {
1769
1868
  const text = (sh.errorMessage ?? "no reason given").replace(/\s*Retry (?:the shot|it)\.\s*$/i, "");
1770
1869
  return text.length > 300 ? `${text.slice(0, 297)}...` : text;
@@ -1776,14 +1875,82 @@ It is saved in the Library folder "Film: ${film.title}".`
1776
1875
  `${paused ? "Paused" : "In progress"}: ${progressLine(film)}.`,
1777
1876
  refused.length > 0 ? `${one ? "Shot" : "Shots"} ${refused.map((sh) => labels.get(sh.id)).join(", ")} need${one ? "s" : ""} a new frame or prompt. The provider refused ${one ? "it" : "them"} (${reason(refused[0])}) and usually refuses the same input again, so status does not resend ${one ? "it" : "them"}: every retry is a charged render. Change ${one ? "it" : "them"} and render again at ${ctx.baseUrl}/film-studio/${filmId}.` : "",
1778
1877
  retrying.length > 0 ? `${retrying.length} shot(s) failed (${reason(retrying[0])}): ${sentAgain === retrying.length ? "sent again" : sentAgain > 0 ? `${sentAgain} sent again, the rest on a later poll` : "sent again on a later poll"}.` : "",
1779
- paused ? `Nothing else is rendering. Once the shot${one ? "" : "s"} render${one ? "s" : ""} again, call pixeldojo_film with {"film_id": "${filmId}", "action": "status"} to carry on.` : `Poll again in ~30s with {"film_id": "${filmId}", "action": "status"}.`
1780
- ].filter(Boolean).join("\n")
1878
+ refusedStarts.length > 0 ? `${refusedStarts.length} render(s) could not start: ${refusedStarts[0]}` : "",
1879
+ paused ? `Nothing else is rendering. Once the shot${one ? "" : "s"} render${one ? "s" : ""} again, call pixeldojo_film with {"film_id": "${filmId}", "action": "status"} to carry on.` : moving ? `Poll again in ~30s with {"film_id": "${filmId}", "action": "status"}.` : `Nothing is rendering. Fix what is above, then call pixeldojo_film with {"film_id": "${filmId}", "action": "status"} to carry on.`
1880
+ ].filter(Boolean).join("\n"),
1881
+ ...moving && !paused ? { followUp: keepGoing(filmId, 30, { soundtrack }) } : {}
1882
+ };
1883
+ }
1884
+ function stillFlagged(f) {
1885
+ const labels = shotLabels(f);
1886
+ const flagged = shots(f).filter(flaggedClip);
1887
+ if (!flagged.length) return "";
1888
+ return `The clip check still flags ${flagged.length} after one redo each: ${flagged.map((sh) => `${labels.get(sh.id)} (${sh.clipQa.note || sh.clipQa.issues.join(", ")})`).join("; ")}. Ask the user before another take (action "redo", a clip charge each).`;
1889
+ }
1890
+ function shotIndex(f) {
1891
+ const all = shots(f);
1892
+ if (all.length === 0 || all.length > 24) return "";
1893
+ const labels = shotLabels(f);
1894
+ let at = 0;
1895
+ const clock = (sec) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
1896
+ const lines = all.map((sh) => {
1897
+ const from = at;
1898
+ at += cutSec(sh);
1899
+ const what = sh.prompt.replace(/\s+/g, " ").trim();
1900
+ const when = sh.leftOut ? "left out of the cut" : `about ${clock(from)}-${clock(at)}`;
1901
+ return ` ${labels.get(sh.id)} (${when}): ${what.length > 70 ? `${what.slice(0, 67)}...` : what}`;
1902
+ });
1903
+ return ['Shots (redo any with action "redo" and its label):', ...lines].join("\n");
1904
+ }
1905
+ async function redoShots(input, ctx) {
1906
+ const filmId = input.film_id;
1907
+ if (!input.shots?.length) {
1908
+ return { kind: "text", text: 'Pass shots: the labels status reports, like ["2.3"] for scene 2, shot 3.' };
1909
+ }
1910
+ const { status: status2, data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1911
+ const film = data.film;
1912
+ if (!film) return noFilm(status2, data, filmId, { action: "redo", shots: input.shots, redo: input.redo });
1913
+ const labels = shotLabels(film);
1914
+ const byLabel = new Map(shots(film).map((sh) => [labels.get(sh.id), sh]));
1915
+ const byId = new Map(shots(film).map((sh) => [sh.id, sh]));
1916
+ const kind = input.redo;
1917
+ const lines = [];
1918
+ let started = 0;
1919
+ let credits = 0;
1920
+ for (const ref of Array.from(new Set(input.shots))) {
1921
+ const sh = byLabel.get(ref) ?? byId.get(ref);
1922
+ if (!sh) {
1923
+ lines.push(`No shot ${ref} in this film (shots run 1.1 to ${Array.from(byLabel.keys()).pop()}).`);
1924
+ continue;
1925
+ }
1926
+ const label = labels.get(sh.id);
1927
+ if (kind === "clip" && !sh.frameUrl) {
1928
+ lines.push(`Shot ${label} has no frame yet, so there is no clip to redo. Redo it with redo "frame".`);
1929
+ continue;
1930
+ }
1931
+ const r = await filmApi(ctx, "POST", `/api/films/${filmId}/shots/${sh.id}/generate`, { kind });
1932
+ if (r.status === 200) {
1933
+ started += 1;
1934
+ credits += r.data.creditCost ?? 0;
1935
+ lines.push(`Shot ${label}: new ${kind} rendering${r.data.creditCost ? ` (${r.data.creditCost} credits)` : ""}.`);
1936
+ } else {
1937
+ lines.push(`Shot ${label}: could not start: ${r.data.error ?? `HTTP ${r.status}`}`);
1938
+ }
1939
+ }
1940
+ if (started === 0) return { kind: "text", text: [...lines, "Nothing was started."].join("\n") };
1941
+ return {
1942
+ kind: "text",
1943
+ text: [
1944
+ ...lines,
1945
+ `${credits ? `${credits} credits charged for the new take${started > 1 ? "s" : ""}. ` : ""}${kind === "frame" ? "Each new frame gets the Frame check, then a new clip. " : ""}The new take replaces the old one; keep calling pixeldojo_film with {"film_id": "${filmId}", "action": "status"}, which saves it and cuts a new export with it.`
1946
+ ].join("\n"),
1947
+ followUp: keepGoing(filmId, 30, { soundtrack: input.soundtrack })
1781
1948
  };
1782
1949
  }
1783
1950
  async function run8(input, ctx) {
1784
1951
  const action = input.action === "plan" && input.film_id ? input.cast?.length ? "cast" : "status" : input.action;
1785
1952
  if (action === "plan") return plan(input, ctx);
1786
- if (!input.film_id) return { kind: "text", text: "produce/status/cast need the film_id returned by the plan step." };
1953
+ if (!input.film_id) return { kind: "text", text: "produce/status/cast/redo need the film_id returned by the plan step." };
1787
1954
  if (action === "cast") {
1788
1955
  if (!input.cast?.length) return { kind: "text", text: "Pass cast: [{ name, prompt | image_urls | character_id }] to cast characters (add replace: true to swap someone already cast, or remove: true to take them off)." };
1789
1956
  const { lines } = await applyCast(ctx, input.film_id, input.cast);
@@ -1796,10 +1963,11 @@ async function run8(input, ctx) {
1796
1963
  ].join("\n")
1797
1964
  };
1798
1965
  }
1799
- if (action === "produce") return produce(input.film_id, ctx);
1966
+ if (action === "produce") return produce(input.film_id, input.soundtrack, ctx);
1967
+ if (action === "redo") return redoShots(input, ctx);
1800
1968
  return advance(input.film_id, input.soundtrack, input.music_job_id, ctx);
1801
1969
  }
1802
- var STORY_LOOKS, MUSIC_VIDEO_LOOKS, LOOKS, MUSIC_CLIP_ENGINE_ID, InputSchema8, InputJsonSchema8, shots, count, frameDone, RETRY_CLEARS, REFUSED, isRefused, PLANNER_POLL_MS, PLANNER_MAX_POLLS, isMusicVideo, needsSlice, stillPlanningText, LOOK_MUSIC, ARC, filmSkill;
1970
+ var STORY_LOOKS, MUSIC_VIDEO_LOOKS, LOOKS, MUSIC_CLIP_ENGINE_ID, InputSchema8, InputJsonSchema8, shots, benignDispatchRefusal, count, frameDone, RETRY_CLEARS, REFUSED, isRefused, PLANNER_POLL_MS, PLANNER_MAX_POLLS, isMusicVideo, needsSlice, stillPlanningText, LOOK_MUSIC, ARC, filmSkill;
1803
1971
  var init_film = __esm({
1804
1972
  "src/skills/film.ts"() {
1805
1973
  init_upload_helpers();
@@ -1816,9 +1984,12 @@ var init_film = __esm({
1816
1984
  song_duration_sec: zod.z.number().positive().max(7200).optional().describe("Music video mode (optional): the song length in seconds. Omit it and the studio measures the song."),
1817
1985
  lyrics: zod.z.string().max(1e4).optional().describe("Music video mode: the song lyrics, so shots land on the right lines. Optional."),
1818
1986
  aspect: zod.z.enum(["16:9", "9:16"]).default("16:9"),
1819
- length: zod.z.enum(["30s", "60s", "2-3min", "5min"]).default("60s").describe("Target length; drives the shot count (~6/12/24/60 shots)."),
1820
- film_id: zod.z.string().max(40).optional().describe("Existing film id (from a previous call) for produce/status/cast."),
1821
- action: zod.z.enum(["plan", "produce", "status", "cast"]).default("plan").describe("'plan' creates + storyboards (free). 'cast' casts, replaces or removes characters on an existing film. 'produce' starts charged generation. 'status' advances production and reports progress; poll it every ~30s until it returns the finished film."),
1987
+ pacing: zod.z.enum(["story", "trailer"]).default("story").describe("'story' (default) plays every shot's action whole. 'trailer' cuts it like a trailer or promo: most shots keep only their first 1.5-3 seconds."),
1988
+ length: zod.z.enum(["30s", "60s", "2-3min", "5min"]).default("60s").describe("Target length of the finished film."),
1989
+ film_id: zod.z.string().max(40).optional().describe("Existing film id (from a previous call) for produce/status/cast/redo."),
1990
+ action: zod.z.enum(["plan", "produce", "status", "cast", "redo"]).default("plan").describe("'plan' creates + storyboards (free). 'cast' casts, replaces or removes characters on an existing film. 'produce' starts charged generation. 'status' advances production and reports progress; poll it every ~30s until it returns the finished film. 'redo' renders a new take of the listed shots (charged), then status re-saves and re-exports."),
1991
+ shots: zod.z.array(zod.z.string().trim().min(1).max(40)).min(1).max(12).optional().describe('Redo: the shots to render again, by the labels status reports ("2.3" is scene 2, shot 3) or shot id.'),
1992
+ redo: zod.z.enum(["clip", "frame"]).default("clip").describe("Redo: 'clip' (default) renders a new take of the clip from the same frame; 'frame' paints a new storyboard frame, then a new clip from it."),
1822
1993
  cast: zod.z.array(
1823
1994
  zod.z.object({
1824
1995
  name: zod.z.string().trim().min(1).max(60),
@@ -1835,7 +2006,7 @@ var init_film = __esm({
1835
2006
  InputJsonSchema8 = {
1836
2007
  type: "object",
1837
2008
  properties: {
1838
- idea: { type: "string", description: "Plan step: one-to-three sentence film idea. The studio writes the screenplay (free). Provide idea or script." },
2009
+ idea: { type: "string", description: `Plan step: one-to-three sentence film idea. The studio writes the screenplay (free). Provide idea or script. Keep the user's own words for who each character is, including their gender and pronouns ("she", "he") and species; the writer has nothing else to go on.` },
1839
2010
  script: { type: "string", description: "Plan step: a full screenplay (sluglines, action lines, dialogue) to plan instead of an idea." },
1840
2011
  look: { type: "string", enum: [...LOOKS], description: `Plan step: visual style carried through frames, clips and soundtrack. Story looks: ${STORY_LOOKS.join(", ")} (default live-action). Music video looks: ${MUSIC_VIDEO_LOOKS.join(", ")} (default rap-video).` },
1841
2012
  song_url: {
@@ -1852,12 +2023,28 @@ var init_film = __esm({
1852
2023
  },
1853
2024
  lyrics: { type: "string", description: "Plan step, music video mode (optional): the song lyrics, so shots land on the right lines." },
1854
2025
  aspect: { type: "string", enum: ["16:9", "9:16"], description: "Plan step: frame aspect ratio. Default 16:9." },
1855
- length: { type: "string", enum: ["30s", "60s", "2-3min", "5min"], description: "Plan step: target length, which sets the shot count (~6/12/24/60 shots). Default 60s. Ignored for a music video, whose length is the song." },
1856
- film_id: { type: "string", description: 'The film_id returned by the plan step. Required for produce and status; with it, action "plan" is treated as "status".' },
2026
+ length: { type: "string", enum: ["30s", "60s", "2-3min", "5min"], description: "Plan step: target length. Default 60s. Ignored for a music video, whose length is the song." },
2027
+ pacing: {
2028
+ type: "string",
2029
+ enum: ["story", "trailer"],
2030
+ description: "Plan step: 'story' (default) plays every shot's action whole, for any narrative film. 'trailer' cuts it like a trailer, promo or sizzle: most shots stay on screen 1.5-3 seconds. Ignored for a music video."
2031
+ },
2032
+ film_id: { type: "string", description: 'The film_id returned by the plan step. Required for produce, status and redo; with it, action "plan" is treated as "status".' },
1857
2033
  action: {
1858
2034
  type: "string",
1859
- enum: ["plan", "produce", "status", "cast"],
1860
- description: "plan (default, free): writes the script and plans shots, returns film_id, a cost estimate, and any characters still to cast. cast: casts, replaces or removes characters on an existing film (pass film_id and cast). produce (charged): starts rendering frames; refused until every named character is cast. status: advances production one step and reports progress; call it every ~30s until it returns the finished film URL."
2035
+ enum: ["plan", "produce", "status", "cast", "redo"],
2036
+ description: "plan (default, free): writes the script and plans shots, returns film_id, a cost estimate, and any characters still to cast. cast: casts, replaces or removes characters on an existing film (pass film_id and cast). produce (charged): starts rendering frames; refused until every named character is cast. status: advances production one step and reports progress; call it every ~30s until it returns the finished film URL. redo (charged): renders a new take of the shots listed in `shots`, on a film in production or already finished; then call status, which saves the new take and cuts a new export with it."
2037
+ },
2038
+ shots: {
2039
+ type: "array",
2040
+ items: { type: "string" },
2041
+ maxItems: 12,
2042
+ description: 'Redo only: the shots to render again, by the labels status and the finished film report ("2.3" is scene 2, shot 3), or by shot id.'
2043
+ },
2044
+ redo: {
2045
+ type: "string",
2046
+ enum: ["clip", "frame"],
2047
+ description: "Redo only: 'clip' (default) renders a new take of the clip from the same storyboard frame, the fix for a glitch, bad motion or lip sync. 'frame' paints a new frame first (and then a new clip from it), the fix for a wrong look, face or composition. Each is charged like any render of that shot. To change what a shot shows, edit it on the storyboard."
1861
2048
  },
1862
2049
  cast: {
1863
2050
  type: "array",
@@ -1897,9 +2084,10 @@ var init_film = __esm({
1897
2084
  }
1898
2085
  };
1899
2086
  shots = (f) => f.scenes.flatMap((s) => s.shots);
2087
+ benignDispatchRefusal = (status2, error) => status2 === 429 || status2 === 409 && /already generating/i.test(error ?? "");
1900
2088
  count = (f, pred) => shots(f).filter(pred).length;
1901
2089
  frameDone = (sh) => Boolean(sh.frameUrl) && sh.status !== "idle" && sh.status !== "frame-generating";
1902
- RETRY_CLEARS = /automatic audio check flagged|output audio\b[\s\S]*?sensitive information|temporarily unavailable/i;
2090
+ RETRY_CLEARS = /automatic audio check flagged|output audio\b[\s\S]*?sensitive information|temporarily unavailable|output audio\b[\s\S]*?copyright|soundtrack was blocked by the model provider/i;
1903
2091
  REFUSED = /copyright|infringement|content filter|safety filter|moderation|blocked|flagged|sensitive|real (?:person|human)|nsfw|content policy|policy violation|inappropriate/i;
1904
2092
  isRefused = (sh) => sh.status === "failed" && Boolean(sh.errorMessage) && !RETRY_CLEARS.test(sh.errorMessage) && REFUSED.test(sh.errorMessage);
1905
2093
  PLANNER_POLL_MS = 3e3;
@@ -1927,8 +2115,8 @@ var init_film = __esm({
1927
2115
  };
1928
2116
  filmSkill = {
1929
2117
  name: "pixeldojo_film",
1930
- description: "Make a finished short film from one idea: screenplay, storyboard frames (AI-reviewed), video clips with spoken dialogue, scene-synced soundtrack, stitched export. Or a music video: pass song_path (a local file) or song_url (your own PixelDojo upload from pixeldojo_upload, never a third-party link) with optional lyrics; the film is cut to that song, each clip lip-synced to its slice on the studio music video engine, and no score is composed: the export plays the song. Every named character must be cast (cast: your own images, a portrait prompt, or a Character Studio character) before frames render; cast entries can also replace or remove someone. Phased: plan is free; cast; produce starts charged generation; status advances production (poll every ~30s).",
1931
- hostedDescription: "Make a finished short film from one idea: screenplay, storyboard frames (AI-reviewed), video clips with spoken dialogue, scene-synced soundtrack, stitched export. Or a music video: pass song_url (your own PixelDojo upload made with POST /api/v1/upload, never a third-party link) with optional lyrics; the film is cut to that song, each clip lip-synced to its slice on the studio music video engine, and no score is composed: the export plays the song. Every named character must be cast (cast: your own images, a portrait prompt, or a Character Studio character) before frames render; cast entries can also replace or remove someone. Phased: plan is free; cast; produce starts charged generation; status advances production (poll every ~30s).",
2118
+ description: "Make a finished short film from one idea: screenplay, storyboard frames (AI-reviewed), video clips with spoken dialogue, scene-synced soundtrack, stitched export. Or a music video: pass song_path (a local file) or song_url (your own PixelDojo upload from pixeldojo_upload, never a third-party link) with optional lyrics; the film is cut to that song, each clip lip-synced to its slice on the studio music video engine, and no score is composed: the export plays the song. Every named character must be cast (cast: your own images, a portrait prompt, or a Character Studio character) before frames render; cast entries can also replace or remove someone. Phased: plan is free; cast; produce starts charged generation; status advances production (poll every ~30s); redo renders a new take of chosen shots and status re-exports.",
2119
+ hostedDescription: "Make a finished short film from one idea: screenplay, storyboard frames (AI-reviewed), video clips with spoken dialogue, scene-synced soundtrack, stitched export. Or a music video: pass song_url (your own PixelDojo upload made with POST /api/v1/upload, never a third-party link) with optional lyrics; the film is cut to that song, each clip lip-synced to its slice on the studio music video engine, and no score is composed: the export plays the song. Every named character must be cast (cast: your own images, a portrait prompt, or a Character Studio character) before frames render; cast entries can also replace or remove someone. Phased: plan is free; cast; produce starts charged generation; status advances production (poll every ~30s); redo renders a new take of chosen shots and status re-exports.",
1932
2120
  inputZod: InputSchema8,
1933
2121
  inputJsonSchema: InputJsonSchema8,
1934
2122
  allowedModels: [],
@@ -2142,7 +2330,7 @@ var init_video = __esm({
2142
2330
  },
2143
2331
  prompt: {
2144
2332
  type: "string",
2145
- description: "What to change, how the character should move, or what happens next. Required for change and extend. Optional for move (wan-video-character-swap ignores it)."
2333
+ description: "What to change, how the character should move, or what happens next. Required for change and extend. Optional for move (wan-video-character-swap ignores it). @Name uses a character or element the user saved in Character Studio (photos attached automatically)."
2146
2334
  },
2147
2335
  video_url: {
2148
2336
  type: "string",
@@ -2745,7 +2933,7 @@ async function run13(input, ctx) {
2745
2933
  );
2746
2934
  return {
2747
2935
  kind: "handoff",
2748
- text: `Campaign ${result.campaignId}: ` + (stillRunning.length > 0 ? `${stillRunning.length} of ${result.subJobs?.length ?? 0} sub-jobs still ${stillRunning[0].status}. Wait a few seconds, then poll again.` : `aggregate state pending. Poll again shortly.`),
2936
+ text: `Campaign ${result.campaignId}: ` + (result.progress ? `${result.progress}. Wait a few seconds, then poll again.` : stillRunning.length > 0 ? `${stillRunning.length} of ${result.subJobs?.length ?? 0} sub-jobs still ${stillRunning[0].status}. Wait a few seconds, then poll again.` : `aggregate state pending. Poll again shortly.`),
2749
2937
  jobId: result.campaignId,
2750
2938
  statusUrl: result.statusUrl ?? ctx.buildStatusUrl(result.campaignId)
2751
2939
  };
@@ -2925,6 +3113,26 @@ var init_audio = __esm({
2925
3113
  }
2926
3114
  });
2927
3115
  async function run16(input, ctx) {
3116
+ if (input.source === "characters") {
3117
+ const { characters } = await ctx.client.listCharacters({ q: input.q, limit: input.limit ?? 25 });
3118
+ if (characters.length === 0) {
3119
+ return {
3120
+ kind: "text",
3121
+ text: input.q ? `No saved character or element matches "${input.q}".` : "No saved characters or elements yet. Save one from photos with pixeldojo_save_character."
3122
+ };
3123
+ }
3124
+ const lines2 = characters.map(
3125
+ (c) => `- ${c.mention} (${c.kind})${c.description ? `: ${c.description.slice(0, 100)}` : ""}
3126
+ images: ${c.image_urls.slice(0, 4).join(" ")}`
3127
+ );
3128
+ return {
3129
+ kind: "text",
3130
+ text: `${characters.length} saved character(s)/element(s):
3131
+ ${lines2.join("\n")}
3132
+
3133
+ Write the @Name in a pixeldojo_generate / pixeldojo_video / pixeldojo_edit prompt and their photos are attached automatically (models that take reference images).`
3134
+ };
3135
+ }
2928
3136
  const res = await ctx.client.listLibrary({
2929
3137
  q: input.q,
2930
3138
  model: input.model,
@@ -2959,6 +3167,7 @@ var InputSchema16, InputJsonSchema16, librarySkill;
2959
3167
  var init_library = __esm({
2960
3168
  "src/skills/library.ts"() {
2961
3169
  InputSchema16 = zod.z.object({
3170
+ source: zod.z.enum(["media", "characters"]).optional().describe("media (default): saved and recent results. characters: saved Character Studio characters and elements, usable as @Name in prompts."),
2962
3171
  q: zod.z.string().optional().describe("Substring match against the prompt that produced the asset."),
2963
3172
  model: zod.z.string().optional().describe("Restrict to a single model (apiId)."),
2964
3173
  modality: zod.z.enum(["image", "video", "audio"]).optional().describe("Restrict to one output type."),
@@ -2967,6 +3176,11 @@ var init_library = __esm({
2967
3176
  InputJsonSchema16 = {
2968
3177
  type: "object",
2969
3178
  properties: {
3179
+ source: {
3180
+ type: "string",
3181
+ enum: ["media", "characters"],
3182
+ description: "media (default): saved media and recent generations. characters: the saved Character Studio characters, products, outfits, locations, props and styles. Write @Name in any prompt to use one."
3183
+ },
2970
3184
  q: {
2971
3185
  type: "string",
2972
3186
  description: "Substring match against the prompt that produced the asset. Omit to list the newest items."
@@ -2983,7 +3197,7 @@ var init_library = __esm({
2983
3197
  };
2984
3198
  librarySkill = {
2985
3199
  name: "pixeldojo_library",
2986
- description: "Search your Library: saved media (permanent) and recent API/MCP generations (deleted 1 hour after they finish), newest first. Returns asset URLs to reuse as reference inputs.",
3200
+ description: "Search your Library, newest first. source=media (default): saved media (permanent) and recent API/MCP generations (deleted 1 hour after they finish), as URLs to reuse as references. source=characters: saved Character Studio characters and elements, each usable as @Name in any prompt.",
2987
3201
  inputZod: InputSchema16,
2988
3202
  inputJsonSchema: InputJsonSchema16,
2989
3203
  allowedModels: [],
@@ -2992,6 +3206,100 @@ var init_library = __esm({
2992
3206
  };
2993
3207
  }
2994
3208
  });
3209
+ async function run17(input, ctx) {
3210
+ const res = await ctx.client.saveMedia({
3211
+ url: input.url,
3212
+ prompt: input.prompt,
3213
+ tool_name: input.tool_name,
3214
+ modality: input.modality,
3215
+ job_id: input.job_id
3216
+ });
3217
+ return {
3218
+ kind: "text",
3219
+ text: `Saved to My Media (permanent): ${res.url}
3220
+ It will appear at https://pixeldojo.ai/my-images and in pixeldojo_library searches.`
3221
+ };
3222
+ }
3223
+ var InputSchema17, InputJsonSchema17, saveSkill;
3224
+ var init_save = __esm({
3225
+ "src/skills/save.ts"() {
3226
+ InputSchema17 = zod.z.object({
3227
+ url: zod.z.string().url().describe("The asset URL to save (image or video)."),
3228
+ prompt: zod.z.string().optional().describe("The prompt that produced it \u2014 makes it findable in the library later."),
3229
+ tool_name: zod.z.string().optional().describe("Tool/model slug that produced it."),
3230
+ modality: zod.z.enum(["image", "video", "audio"]).optional(),
3231
+ job_id: zod.z.string().optional().describe("The jobId that produced this asset, when known.")
3232
+ });
3233
+ InputJsonSchema17 = {
3234
+ type: "object",
3235
+ properties: {
3236
+ url: { type: "string", format: "uri", description: "The result URL to keep (image or video from a PixelDojo job)." },
3237
+ prompt: { type: "string", description: "The prompt that produced it, so it is findable in the library later." },
3238
+ tool_name: { type: "string", description: "Model apiId that produced it, when known." },
3239
+ modality: { type: "string", enum: ["image", "video", "audio"], description: "Output type, when the URL does not make it obvious." },
3240
+ job_id: { type: "string", description: "The jobId that produced it, when known." }
3241
+ },
3242
+ required: ["url"]
3243
+ };
3244
+ saveSkill = {
3245
+ name: "pixeldojo_save",
3246
+ description: `Save a generated image or video into the user's My Media library permanently ("save this", "keep it"). Results otherwise expire 1 hour after they finish. Pass the result URL; it is safety-checked before it is kept.`,
3247
+ inputZod: InputSchema17,
3248
+ inputJsonSchema: InputJsonSchema17,
3249
+ allowedModels: [],
3250
+ defaultModel: "",
3251
+ run: run17
3252
+ };
3253
+ }
3254
+ });
3255
+ async function run18(input, ctx) {
3256
+ const res = await ctx.client.saveCharacter({
3257
+ name: input.name,
3258
+ kind: input.kind ?? "character",
3259
+ image_urls: input.image_urls,
3260
+ description: input.description
3261
+ });
3262
+ return {
3263
+ kind: "text",
3264
+ text: `Saved ${res.kind} "${res.name}" to Character Studio (${res.image_urls.length} image${res.image_urls.length === 1 ? "" : "s"}). Use it in any prompt as ${res.mention}, for example "${res.mention} walking on a beach at sunset". It also appears at https://pixeldojo.ai/characters.`
3265
+ };
3266
+ }
3267
+ var KINDS, InputSchema18, InputJsonSchema18, saveCharacterSkill;
3268
+ var init_save_character = __esm({
3269
+ "src/skills/save-character.ts"() {
3270
+ KINDS = ["character", "product", "outfit", "location", "object", "style"];
3271
+ InputSchema18 = zod.z.object({
3272
+ name: zod.z.string().min(1).max(60).describe("What to call it. Used as @Name in prompts."),
3273
+ kind: zod.z.enum(KINDS).optional().describe("Default character."),
3274
+ image_urls: zod.z.array(zod.z.string().url()).max(8).optional().describe("3 to 5 photos from different angles works best for a character."),
3275
+ description: zod.z.string().max(1500).optional().describe("Appearance notes, or the scene text for an element.")
3276
+ });
3277
+ InputJsonSchema18 = {
3278
+ type: "object",
3279
+ properties: {
3280
+ name: { type: "string", description: "What to call it. Used as @Name in prompts afterwards." },
3281
+ kind: { type: "string", enum: KINDS, description: "character (default), product, outfit, location, object (a prop) or style." },
3282
+ image_urls: {
3283
+ type: "array",
3284
+ items: { type: "string", format: "uri" },
3285
+ maxItems: 8,
3286
+ description: "Photos of it. For a person, 3 to 5 clear photos from different angles. Required for a character."
3287
+ },
3288
+ description: { type: "string", description: "Optional appearance notes, or the scene text for an element without photos." }
3289
+ },
3290
+ required: ["name"]
3291
+ };
3292
+ saveCharacterSkill = {
3293
+ name: "pixeldojo_save_character",
3294
+ description: "Save a character (a person, from 3-5 photos) or an element (product, outfit, location, prop, style) to the user's Character Studio library. Afterwards write @Name in any prompt to use it. List saved ones with pixeldojo_library source=characters.",
3295
+ inputZod: InputSchema18,
3296
+ inputJsonSchema: InputJsonSchema18,
3297
+ allowedModels: [],
3298
+ defaultModel: "",
3299
+ run: run18
3300
+ };
3301
+ }
3302
+ });
2995
3303
  function substitutePlaceholders(args, inputs, prevUrl) {
2996
3304
  const missing = [];
2997
3305
  const fill = (value) => {
@@ -3248,6 +3556,8 @@ __export(skills_exports, {
3248
3556
  librarySkill: () => librarySkill,
3249
3557
  listWorkflowsSkill: () => listWorkflowsSkill,
3250
3558
  runWorkflowSkill: () => runWorkflowSkill,
3559
+ saveCharacterSkill: () => saveCharacterSkill,
3560
+ saveSkill: () => saveSkill,
3251
3561
  saveWorkflowSkill: () => saveWorkflowSkill,
3252
3562
  statusSkill: () => statusSkill,
3253
3563
  storyboardSkill: () => storyboardSkill,
@@ -3293,6 +3603,8 @@ var init_skills = __esm({
3293
3603
  init_upload();
3294
3604
  init_audio();
3295
3605
  init_library();
3606
+ init_save();
3607
+ init_save_character();
3296
3608
  init_workflows();
3297
3609
  exports.ALL_SKILLS = [
3298
3610
  generateSkill,
@@ -3302,6 +3614,8 @@ var init_skills = __esm({
3302
3614
  upscaleSkill,
3303
3615
  audioSkill,
3304
3616
  librarySkill,
3617
+ saveSkill,
3618
+ saveCharacterSkill,
3305
3619
  saveWorkflowSkill,
3306
3620
  runWorkflowSkill,
3307
3621
  listWorkflowsSkill,
@@ -3349,6 +3663,8 @@ var init_skills = __esm({
3349
3663
  pixeldojo_status: read("Check job status"),
3350
3664
  pixeldojo_campaign_status: read("Check campaign status"),
3351
3665
  pixeldojo_library: read("Search media library"),
3666
+ pixeldojo_save: render("Save to My Media"),
3667
+ pixeldojo_save_character: render("Save character or element"),
3352
3668
  pixeldojo_list_workflows: read("List saved workflows"),
3353
3669
  pixeldojo_from_url: { ...read("Read product page"), openWorldHint: true }
3354
3670
  };
@@ -3405,8 +3721,8 @@ function makeRedactedLogger(target = console) {
3405
3721
  }
3406
3722
 
3407
3723
  // src/server.ts
3408
- var PACKAGE_VERSION = "0.18.0" ;
3409
- var SERVER_INSTRUCTIONS = "PixelDojo generates images, video, audio and short films across 100+ models, billed in credits from the connected PixelDojo account. A job that runs past the 30-second wait returns a jobId to check later; check it rather than submitting again, since every new submission is charged. Generated asset URLs are deleted one hour after they are created, so download anything the user wants to keep. Finished films are the exception and stay in the account library.";
3724
+ var PACKAGE_VERSION = "0.20.0" ;
3725
+ var SERVER_INSTRUCTIONS = "Pixel Dojo generates images, video, audio and short films from one catalog of AI models, billed in credits from the connected Pixel Dojo account. A job that runs past the 30-second wait returns a jobId to check later; check it rather than submitting again, since every new submission is charged. Generated asset URLs are deleted one hour after they are created: pixeldojo_save keeps one in the user's My Media permanently (after a safety check), and finished films stay in the account library. In any prompt, @Name uses a character, product, outfit, location, prop or style the user saved in Character Studio: its photos are attached automatically. pixeldojo_library source=characters lists them, and pixeldojo_save_character saves a new one from 3 to 5 photos.";
3410
3726
  function buildServer(options) {
3411
3727
  if (!options.apiKey) {
3412
3728
  throw new Error(