@pixeldojo/mcp 0.19.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/cli.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,12 +548,6 @@ 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
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(
@@ -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
  ),
@@ -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,
@@ -1455,10 +1405,51 @@ 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
+ }
1458
1417
  function keepGoing(filmId, afterSec, extra = {}) {
1459
1418
  const set = Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== void 0));
1460
1419
  return { input: { film_id: filmId, action: "status", ...set }, afterSec };
1461
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
+ }
1462
1453
  function shotLabels(f) {
1463
1454
  return new Map(f.scenes.flatMap((s, si) => s.shots.map((sh, i) => [sh.id, `${si + 1}.${i + 1}`])));
1464
1455
  }
@@ -1566,6 +1557,7 @@ async function plan(input, ctx) {
1566
1557
  aspect: input.aspect,
1567
1558
  look: input.look ?? (music ? MUSIC_VIDEO_LOOKS[0] : "live-action"),
1568
1559
  lengthTarget: input.length,
1560
+ ...!music && input.pacing === "trailer" ? { pacing: "trailer" } : {},
1569
1561
  assetIds: [],
1570
1562
  ...music ? {
1571
1563
  // No script step names a music video, so it would stay "Untitled film".
@@ -1639,12 +1631,25 @@ ${err} Call action "status" to retry the cut.` };
1639
1631
  return { kind: "text", text: plannedText(planned.film, ctx) };
1640
1632
  }
1641
1633
  async function produce(filmId, soundtrack, ctx) {
1642
- const { data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1643
- if (!data.film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1634
+ const { status: status2, data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1635
+ if (!data.film) return noFilm(status2, data, filmId, { action: "produce", soundtrack });
1644
1636
  const planning = await resumePlanning(data.film, ctx, soundtrack);
1645
1637
  if (planning) return planning;
1646
1638
  const uncast = await uncastRolesOf(ctx, filmId);
1647
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
+ }
1648
1653
  const pending = shots(data.film).filter((sh) => sh.status === "idle" || sh.status === "failed" && !sh.frameUrl);
1649
1654
  let fired = 0;
1650
1655
  let refused;
@@ -1658,7 +1663,7 @@ async function produce(filmId, soundtrack, ctx) {
1658
1663
  }
1659
1664
  return {
1660
1665
  kind: "text",
1661
- 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).${refused ? ` Some could not start: ${refused}` : ""} 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.`,
1662
1667
  // The caller's soundtrack choice rides along: the follow-up status calls
1663
1668
  // are the ones that reach the score.
1664
1669
  followUp: keepGoing(filmId, 30, { soundtrack })
@@ -1667,7 +1672,7 @@ async function produce(filmId, soundtrack, ctx) {
1667
1672
  function buildScorePlanBody(film) {
1668
1673
  const scenes = film.scenes.filter((s) => s.shots.length > 0);
1669
1674
  const sections = scenes.flatMap((scene, i) => {
1670
- 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));
1671
1676
  const styles = i === 0 ? ARC.opening : i === scenes.length - 1 ? ARC.finale : ARC.middle;
1672
1677
  const parts = Math.max(1, Math.ceil(runtime / 12e4));
1673
1678
  const partMs = Math.round(runtime / parts);
@@ -1690,7 +1695,7 @@ function buildScorePlanBody(film) {
1690
1695
  };
1691
1696
  }
1692
1697
  function buildFlatMusicBody(film, all) {
1693
- const runtimeSec = all.reduce((n, sh) => n + (sh.durationSec || 0), 0);
1698
+ const runtimeSec = all.reduce((n, sh) => n + cutSec(sh), 0);
1694
1699
  const ms = Math.min(3e5, Math.max(3e4, Math.ceil(runtimeSec * 1e3 / 5e3) * 5e3));
1695
1700
  return {
1696
1701
  prompt: `${LOOK_MUSIC[film.look] ?? LOOK_MUSIC["live-action"]}. Instrumental only, no vocals. One consistent theme, loopable as a background bed under dialogue.`,
@@ -1700,18 +1705,19 @@ function buildFlatMusicBody(film, all) {
1700
1705
  };
1701
1706
  }
1702
1707
  async function advance(filmId, soundtrack, musicJobId, ctx) {
1703
- 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`);
1704
1709
  const film = data.film;
1705
- if (!film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1710
+ if (!film) return noFilm(status2, data, filmId, { action: "status", soundtrack });
1706
1711
  const all = shots(film);
1707
1712
  if (film.status === "exported" && film.exportUrl) {
1708
1713
  return {
1709
1714
  kind: "text",
1710
1715
  text: [
1711
- `Film finished: "${film.title}"
1716
+ `Film finished: "${film.title}" (${runtimeText(film)})
1712
1717
  Watch/download: ${film.exportUrl}
1713
1718
  Workspace: ${ctx.baseUrl}/film-studio/${film.id}
1714
1719
  It is saved in the Library folder "Film: ${film.title}".`,
1720
+ stillFlagged(film),
1715
1721
  shotIndex(film)
1716
1722
  ].filter(Boolean).join("\n")
1717
1723
  };
@@ -1767,7 +1773,45 @@ It is saved in the Library folder "Film: ${film.title}".`,
1767
1773
  const clipQueue = framesDone ? all.filter((sh) => sh.status === "frame-ready" || sh.status === "failed" && frameDone(sh) && !isRefused(sh)) : [];
1768
1774
  for (const sh of clipQueue.slice(0, Math.max(0, 4 - inFlight))) await dispatch(sh, "clip");
1769
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
+ }
1770
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
+ }
1771
1815
  if (all.some((sh) => needsSave(sh.videoUrl))) {
1772
1816
  await filmApi(ctx, "POST", `/api/films/${filmId}/save`, { kind: "clip" });
1773
1817
  }
@@ -1796,7 +1840,7 @@ It is saved in the Library folder "Film: ${film.title}".`,
1796
1840
  followUp: keepGoing(filmId, 20, { soundtrack, music_job_id: musicJobId })
1797
1841
  };
1798
1842
  }
1799
- const body = soundtrack === "scene-synced" ? buildScorePlanBody(film) : buildFlatMusicBody(film, all);
1843
+ const body = soundtrack === "scene-synced" ? buildScorePlanBody(film) : buildFlatMusicBody(film, shots(film));
1800
1844
  const dispatch2 = await ctx.client.run("text-to-music", body);
1801
1845
  return {
1802
1846
  kind: "text",
@@ -1837,6 +1881,12 @@ It is saved in the Library folder "Film: ${film.title}".`,
1837
1881
  ...moving && !paused ? { followUp: keepGoing(filmId, 30, { soundtrack }) } : {}
1838
1882
  };
1839
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
+ }
1840
1890
  function shotIndex(f) {
1841
1891
  const all = shots(f);
1842
1892
  if (all.length === 0 || all.length > 24) return "";
@@ -1845,9 +1895,10 @@ function shotIndex(f) {
1845
1895
  const clock = (sec) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
1846
1896
  const lines = all.map((sh) => {
1847
1897
  const from = at;
1848
- at += sh.durationSec || 0;
1898
+ at += cutSec(sh);
1849
1899
  const what = sh.prompt.replace(/\s+/g, " ").trim();
1850
- return ` ${labels.get(sh.id)} (about ${clock(from)}-${clock(at)}): ${what.length > 70 ? `${what.slice(0, 67)}...` : what}`;
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}`;
1851
1902
  });
1852
1903
  return ['Shots (redo any with action "redo" and its label):', ...lines].join("\n");
1853
1904
  }
@@ -1856,9 +1907,9 @@ async function redoShots(input, ctx) {
1856
1907
  if (!input.shots?.length) {
1857
1908
  return { kind: "text", text: 'Pass shots: the labels status reports, like ["2.3"] for scene 2, shot 3.' };
1858
1909
  }
1859
- const { data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1910
+ const { status: status2, data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1860
1911
  const film = data.film;
1861
- if (!film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1912
+ if (!film) return noFilm(status2, data, filmId, { action: "redo", shots: input.shots, redo: input.redo });
1862
1913
  const labels = shotLabels(film);
1863
1914
  const byLabel = new Map(shots(film).map((sh) => [labels.get(sh.id), sh]));
1864
1915
  const byId = new Map(shots(film).map((sh) => [sh.id, sh]));
@@ -1933,7 +1984,8 @@ var init_film = __esm({
1933
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."),
1934
1985
  lyrics: zod.z.string().max(1e4).optional().describe("Music video mode: the song lyrics, so shots land on the right lines. Optional."),
1935
1986
  aspect: zod.z.enum(["16:9", "9:16"]).default("16:9"),
1936
- length: zod.z.enum(["30s", "60s", "2-3min", "5min"]).default("60s").describe("Target length; drives the shot count (~6/12/24/60 shots)."),
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."),
1937
1989
  film_id: zod.z.string().max(40).optional().describe("Existing film id (from a previous call) for produce/status/cast/redo."),
1938
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."),
1939
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.'),
@@ -1954,7 +2006,7 @@ var init_film = __esm({
1954
2006
  InputJsonSchema8 = {
1955
2007
  type: "object",
1956
2008
  properties: {
1957
- 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.` },
1958
2010
  script: { type: "string", description: "Plan step: a full screenplay (sluglines, action lines, dialogue) to plan instead of an idea." },
1959
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).` },
1960
2012
  song_url: {
@@ -1971,7 +2023,12 @@ var init_film = __esm({
1971
2023
  },
1972
2024
  lyrics: { type: "string", description: "Plan step, music video mode (optional): the song lyrics, so shots land on the right lines." },
1973
2025
  aspect: { type: "string", enum: ["16:9", "9:16"], description: "Plan step: frame aspect ratio. Default 16:9." },
1974
- 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." },
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
+ },
1975
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".' },
1976
2033
  action: {
1977
2034
  type: "string",
@@ -2030,7 +2087,7 @@ var init_film = __esm({
2030
2087
  benignDispatchRefusal = (status2, error) => status2 === 429 || status2 === 409 && /already generating/i.test(error ?? "");
2031
2088
  count = (f, pred) => shots(f).filter(pred).length;
2032
2089
  frameDone = (sh) => Boolean(sh.frameUrl) && sh.status !== "idle" && sh.status !== "frame-generating";
2033
- 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;
2034
2091
  REFUSED = /copyright|infringement|content filter|safety filter|moderation|blocked|flagged|sensitive|real (?:person|human)|nsfw|content policy|policy violation|inappropriate/i;
2035
2092
  isRefused = (sh) => sh.status === "failed" && Boolean(sh.errorMessage) && !RETRY_CLEARS.test(sh.errorMessage) && REFUSED.test(sh.errorMessage);
2036
2093
  PLANNER_POLL_MS = 3e3;
@@ -3664,7 +3721,7 @@ function makeRedactedLogger(target = console) {
3664
3721
  }
3665
3722
 
3666
3723
  // src/server.ts
3667
- var PACKAGE_VERSION = "0.19.0" ;
3724
+ var PACKAGE_VERSION = "0.20.0" ;
3668
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.";
3669
3726
  function buildServer(options) {
3670
3727
  if (!options.apiKey) {