@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/index.js CHANGED
@@ -458,14 +458,6 @@ function buildPayload(apiId, input, src, slot, audioSlot) {
458
458
  async function run(input, ctx) {
459
459
  const paramsGate = paramsWithoutModel(input.params, input.model);
460
460
  if (paramsGate) return { kind: "text", text: paramsGate };
461
- if (input.upgrade_draft) return upgradeDraft(input, ctx);
462
- if (input.draft && input.model && input.model !== DRAFT_MODEL) {
463
- return {
464
- kind: "text",
465
- text: `Draft mode runs on ${DRAFT_MODEL} only. Drop model (or set it to ${DRAFT_MODEL}) to render a 480p draft.`
466
- };
467
- }
468
- if (input.draft) input = { ...input, model: DRAFT_MODEL, resolution: DRAFT_RESOLUTION };
469
461
  let resolved = input;
470
462
  let referenceUrls = [];
471
463
  try {
@@ -527,8 +519,6 @@ async function run(input, ctx) {
527
519
  }
528
520
  const body = buildPayload(apiId, resolved, src, refField, audioSlot);
529
521
  const paramsNote = applyParams(body, input.params, input.model, apiId);
530
- const draftDuration = input.draft ? Number(body.duration ?? DRAFT_DEFAULT_DURATION) : void 0;
531
- if (input.draft) Object.assign(body, { draft: true, resolution: DRAFT_RESOLUTION, duration: draftDuration });
532
522
  const note = [routeNote, paramsNote].filter(Boolean).join(" ");
533
523
  const submitted = await ctx.client.run(apiId, body);
534
524
  const result = await pollJobUntilDoneOrHandoff(
@@ -540,45 +530,15 @@ async function run(input, ctx) {
540
530
  sleep: ctx.pollOverrides?.sleep
541
531
  }
542
532
  );
543
- const out = prefixNote(
533
+ return prefixNote(
544
534
  pollResultToSkillResult(
545
535
  result,
546
536
  (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(", ")}`
547
537
  ),
548
538
  note
549
539
  );
550
- return draftDuration === void 0 ? out : { ...out, text: `${out.text}
551
-
552
- ${draftLine(submitted.jobId, draftDuration)}` };
553
- }
554
- function draftLine(jobId, duration) {
555
- 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).`;
556
- }
557
- async function upgradeDraft(input, ctx) {
558
- if (input.duration === void 0) {
559
- return {
560
- kind: "text",
561
- 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).`
562
- };
563
- }
564
- const submitted = await ctx.client.run(DRAFT_MODEL, {
565
- draft_job_id: input.upgrade_draft,
566
- resolution: DRAFT_FINAL_RESOLUTION,
567
- duration: input.duration
568
- });
569
- const result = await pollJobUntilDoneOrHandoff(submitted.jobId, (id) => ctx.client.getJob(id), ctx.buildStatusUrl, {
570
- now: ctx.pollOverrides?.now,
571
- sleep: ctx.pollOverrides?.sleep
572
- });
573
- return prefixNote(
574
- pollResultToSkillResult(
575
- result,
576
- (urls) => urls.length === 0 ? `Job ${submitted.jobId} completed but returned no assets.` : `Upgraded to ${DRAFT_FINAL_RESOLUTION}. Asset: ${urls[0]}`
577
- ),
578
- `Rendering draft ${input.upgrade_draft} at ${DRAFT_FINAL_RESOLUTION}.`
579
- );
580
540
  }
581
- 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;
541
+ var InputSchema, DEFAULT_MODEL, VIDEO_REFERENCE_MODEL, VIDEO_REFERENCE_SLOTS, VIDEO_INPUT_MODELS, InputJsonSchema, generateSkill;
582
542
  var init_generate = __esm({
583
543
  "src/skills/generate.ts"() {
584
544
  init_poll();
@@ -586,12 +546,6 @@ var init_generate = __esm({
586
546
  init_upload_helpers();
587
547
  init_schema_snap();
588
548
  init_params();
589
- DRAFT_MODEL = "seedance-2-5";
590
- DRAFT_RESOLUTION = "480p";
591
- DRAFT_FINAL_RESOLUTION = "1080p";
592
- DRAFT_DEFAULT_DURATION = 5;
593
- 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.";
594
- 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.";
595
549
  InputSchema = z.object({
596
550
  prompt: 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.'),
597
551
  model: z.string().optional().describe(
@@ -610,8 +564,6 @@ var init_generate = __esm({
610
564
  "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."
611
565
  ),
612
566
  params: ParamsInput,
613
- draft: z.boolean().optional().describe(DRAFT_DESCRIPTION),
614
- upgrade_draft: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/).optional().describe(UPGRADE_DESCRIPTION),
615
567
  image_path: z.string().min(1).optional().describe(
616
568
  "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."
617
569
  ),
@@ -665,8 +617,6 @@ var init_generate = __esm({
665
617
  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."
666
618
  },
667
619
  params: PARAMS_JSON_SCHEMA,
668
- draft: { type: "boolean", description: DRAFT_DESCRIPTION },
669
- upgrade_draft: { type: "string", description: UPGRADE_DESCRIPTION },
670
620
  image_urls: {
671
621
  type: "array",
672
622
  items: { type: "string", format: "uri" },
@@ -686,8 +636,8 @@ var init_generate = __esm({
686
636
  };
687
637
  generateSkill = {
688
638
  name: "pixeldojo_generate",
689
- 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.",
690
- 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.",
639
+ 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.",
640
+ 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.",
691
641
  inputZod: InputSchema,
692
642
  inputJsonSchema: InputJsonSchema,
693
643
  allowedModels: null,
@@ -1453,10 +1403,51 @@ async function filmApi(ctx, method, path, body) {
1453
1403
  const data = await res.json().catch(() => ({}));
1454
1404
  return { status: res.status, data };
1455
1405
  }
1406
+ function noFilm(status2, data, filmId, retry) {
1407
+ if (status2 === 404) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1408
+ const input = { film_id: filmId, ...Object.fromEntries(Object.entries(retry).filter(([, v]) => v !== void 0)) };
1409
+ return {
1410
+ kind: "text",
1411
+ 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.`,
1412
+ ...input.action === "status" ? { followUp: { input, afterSec: 30 } } : {}
1413
+ };
1414
+ }
1456
1415
  function keepGoing(filmId, afterSec, extra = {}) {
1457
1416
  const set = Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== void 0));
1458
1417
  return { input: { film_id: filmId, action: "status", ...set }, afterSec };
1459
1418
  }
1419
+ function flaggedClip(sh) {
1420
+ const qa = sh.clipQa;
1421
+ const file = (u) => u?.split("/").pop();
1422
+ return Boolean(qa && qa.ok === false && sh.videoUrl && file(qa.url) === file(sh.videoUrl));
1423
+ }
1424
+ function editReviewing(sh, nowMs) {
1425
+ const e = sh.editSuggestion;
1426
+ const file = (u) => u?.split("/").pop();
1427
+ return Boolean(e?.pending && sh.videoUrl && file(e.url) === file(sh.videoUrl) && nowMs - Date.parse(e.checkedAt) < 4 * 6e4);
1428
+ }
1429
+ function openEdit(sh) {
1430
+ const e = sh.editSuggestion;
1431
+ const file = (u) => u?.split("/").pop();
1432
+ return Boolean(e && !e.pending && !e.appliedAt && !e.skipped && e.inSec !== void 0 && sh.videoUrl && file(e.url) === file(sh.videoUrl));
1433
+ }
1434
+ function cutSec(sh) {
1435
+ if (sh.leftOut) return 0;
1436
+ const speed = sh.speed && sh.speed > 1 ? sh.speed : 1;
1437
+ return Math.max(1, (sh.videoDurationSec ?? sh.durationSec ?? 0) - (sh.trimStartSec ?? 0) - (sh.trimEndSec ?? 0)) / speed;
1438
+ }
1439
+ function editorCutting(f, nowMs) {
1440
+ const c = f.editorCut;
1441
+ const fresh = (iso) => Boolean(iso && nowMs - Date.parse(iso) < 4 * 6e4);
1442
+ if (c?.pending) return fresh(c.startedAt);
1443
+ const lastReview = shots(f).map((sh) => sh.editSuggestion?.checkedAt).filter((t) => Boolean(t)).sort().pop();
1444
+ if (!lastReview || !fresh(lastReview)) return false;
1445
+ return !(c?.builtAt && c.builtAt >= lastReview);
1446
+ }
1447
+ function runtimeText(f) {
1448
+ const sec = Math.round(shots(f).reduce((n, sh) => n + cutSec(sh), 0));
1449
+ return sec < 90 ? `${sec}s` : `${Math.floor(sec / 60)}m ${sec % 60}s`;
1450
+ }
1460
1451
  function shotLabels(f) {
1461
1452
  return new Map(f.scenes.flatMap((s, si) => s.shots.map((sh, i) => [sh.id, `${si + 1}.${i + 1}`])));
1462
1453
  }
@@ -1564,6 +1555,7 @@ async function plan(input, ctx) {
1564
1555
  aspect: input.aspect,
1565
1556
  look: input.look ?? (music ? MUSIC_VIDEO_LOOKS[0] : "live-action"),
1566
1557
  lengthTarget: input.length,
1558
+ ...!music && input.pacing === "trailer" ? { pacing: "trailer" } : {},
1567
1559
  assetIds: [],
1568
1560
  ...music ? {
1569
1561
  // No script step names a music video, so it would stay "Untitled film".
@@ -1637,12 +1629,25 @@ ${err} Call action "status" to retry the cut.` };
1637
1629
  return { kind: "text", text: plannedText(planned.film, ctx) };
1638
1630
  }
1639
1631
  async function produce(filmId, soundtrack, ctx) {
1640
- const { data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1641
- if (!data.film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1632
+ const { status: status2, data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1633
+ if (!data.film) return noFilm(status2, data, filmId, { action: "produce", soundtrack });
1642
1634
  const planning = await resumePlanning(data.film, ctx, soundtrack);
1643
1635
  if (planning) return planning;
1644
1636
  const uncast = await uncastRolesOf(ctx, filmId);
1645
1637
  if (uncast.length) return { kind: "text", text: `Nothing was charged. ${castNeededText(filmId, uncast)}` };
1638
+ let propsNote = "";
1639
+ if (!shots(data.film).some((sh) => sh.frameUrl)) {
1640
+ const locked = await filmApi(
1641
+ ctx,
1642
+ "POST",
1643
+ `/api/films/${filmId}/props`,
1644
+ {}
1645
+ ).catch(() => null);
1646
+ const made = locked?.status === 200 ? locked.data.created ?? [] : [];
1647
+ if (made.length) {
1648
+ 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` : ""}.`;
1649
+ }
1650
+ }
1646
1651
  const pending = shots(data.film).filter((sh) => sh.status === "idle" || sh.status === "failed" && !sh.frameUrl);
1647
1652
  let fired = 0;
1648
1653
  let refused;
@@ -1656,7 +1661,7 @@ async function produce(filmId, soundtrack, ctx) {
1656
1661
  }
1657
1662
  return {
1658
1663
  kind: "text",
1659
- 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.`,
1664
+ 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.`,
1660
1665
  // The caller's soundtrack choice rides along: the follow-up status calls
1661
1666
  // are the ones that reach the score.
1662
1667
  followUp: keepGoing(filmId, 30, { soundtrack })
@@ -1665,7 +1670,7 @@ async function produce(filmId, soundtrack, ctx) {
1665
1670
  function buildScorePlanBody(film) {
1666
1671
  const scenes = film.scenes.filter((s) => s.shots.length > 0);
1667
1672
  const sections = scenes.flatMap((scene, i) => {
1668
- const runtime = Math.max(3e3, scene.shots.reduce((n, sh) => n + (sh.durationSec || 0), 0) * 1e3);
1673
+ const runtime = Math.max(3e3, Math.round(scene.shots.reduce((n, sh) => n + cutSec(sh), 0) * 1e3));
1669
1674
  const styles = i === 0 ? ARC.opening : i === scenes.length - 1 ? ARC.finale : ARC.middle;
1670
1675
  const parts = Math.max(1, Math.ceil(runtime / 12e4));
1671
1676
  const partMs = Math.round(runtime / parts);
@@ -1688,7 +1693,7 @@ function buildScorePlanBody(film) {
1688
1693
  };
1689
1694
  }
1690
1695
  function buildFlatMusicBody(film, all) {
1691
- const runtimeSec = all.reduce((n, sh) => n + (sh.durationSec || 0), 0);
1696
+ const runtimeSec = all.reduce((n, sh) => n + cutSec(sh), 0);
1692
1697
  const ms = Math.min(3e5, Math.max(3e4, Math.ceil(runtimeSec * 1e3 / 5e3) * 5e3));
1693
1698
  return {
1694
1699
  prompt: `${LOOK_MUSIC[film.look] ?? LOOK_MUSIC["live-action"]}. Instrumental only, no vocals. One consistent theme, loopable as a background bed under dialogue.`,
@@ -1698,18 +1703,19 @@ function buildFlatMusicBody(film, all) {
1698
1703
  };
1699
1704
  }
1700
1705
  async function advance(filmId, soundtrack, musicJobId, ctx) {
1701
- const { data } = await filmApi(ctx, "GET", `/api/films/${filmId}?refresh=1`);
1706
+ const { status: status2, data } = await filmApi(ctx, "GET", `/api/films/${filmId}?refresh=1`);
1702
1707
  const film = data.film;
1703
- if (!film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1708
+ if (!film) return noFilm(status2, data, filmId, { action: "status", soundtrack });
1704
1709
  const all = shots(film);
1705
1710
  if (film.status === "exported" && film.exportUrl) {
1706
1711
  return {
1707
1712
  kind: "text",
1708
1713
  text: [
1709
- `Film finished: "${film.title}"
1714
+ `Film finished: "${film.title}" (${runtimeText(film)})
1710
1715
  Watch/download: ${film.exportUrl}
1711
1716
  Workspace: ${ctx.baseUrl}/film-studio/${film.id}
1712
1717
  It is saved in the Library folder "Film: ${film.title}".`,
1718
+ stillFlagged(film),
1713
1719
  shotIndex(film)
1714
1720
  ].filter(Boolean).join("\n")
1715
1721
  };
@@ -1765,7 +1771,45 @@ It is saved in the Library folder "Film: ${film.title}".`,
1765
1771
  const clipQueue = framesDone ? all.filter((sh) => sh.status === "frame-ready" || sh.status === "failed" && frameDone(sh) && !isRefused(sh)) : [];
1766
1772
  for (const sh of clipQueue.slice(0, Math.max(0, 4 - inFlight))) await dispatch(sh, "clip");
1767
1773
  const clipsDone = all.length > 0 && all.every((sh) => sh.status === "clip-ready");
1774
+ const toRedo = clipsDone ? all.filter((sh) => flaggedClip(sh) && !(sh.manualClipRedos ?? 0)) : [];
1775
+ if (toRedo.length) {
1776
+ const labels2 = shotLabels(film);
1777
+ const redone = [];
1778
+ let refusedRedo;
1779
+ for (const sh of toRedo) {
1780
+ const r = await filmApi(ctx, "POST", `/api/films/${filmId}/shots/${sh.id}/generate`, { kind: "clip" });
1781
+ if (r.status === 200) redone.push(`${labels2.get(sh.id)} (${sh.clipQa.note || sh.clipQa.issues.join(", ")})`);
1782
+ else if (!benignDispatchRefusal(r.status, r.data.error)) refusedRedo ??= r.data.error ?? `HTTP ${r.status}`;
1783
+ }
1784
+ if (redone.length) {
1785
+ return {
1786
+ kind: "text",
1787
+ 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"}.`,
1788
+ followUp: keepGoing(filmId, 30, { soundtrack })
1789
+ };
1790
+ }
1791
+ }
1768
1792
  if (clipsDone) {
1793
+ const reviewing = all.filter((sh) => editReviewing(sh, Date.now())).length;
1794
+ if (reviewing) {
1795
+ return {
1796
+ kind: "text",
1797
+ 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"}.`,
1798
+ followUp: keepGoing(filmId, 20, { soundtrack })
1799
+ };
1800
+ }
1801
+ if (editorCutting(film, Date.now())) {
1802
+ return {
1803
+ kind: "text",
1804
+ 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"}.`,
1805
+ followUp: keepGoing(filmId, 30, { soundtrack })
1806
+ };
1807
+ }
1808
+ const openCut = Boolean(film.editorCut?.shots?.length && !film.editorCut.appliedAt && !film.editorCut.skipped);
1809
+ if (openCut || all.some(openEdit)) {
1810
+ const cut = await filmApi(ctx, "POST", `/api/films/${filmId}/edit`, { action: "apply" });
1811
+ if (cut.data.film) film.scenes = cut.data.film.scenes;
1812
+ }
1769
1813
  if (all.some((sh) => needsSave(sh.videoUrl))) {
1770
1814
  await filmApi(ctx, "POST", `/api/films/${filmId}/save`, { kind: "clip" });
1771
1815
  }
@@ -1794,7 +1838,7 @@ It is saved in the Library folder "Film: ${film.title}".`,
1794
1838
  followUp: keepGoing(filmId, 20, { soundtrack, music_job_id: musicJobId })
1795
1839
  };
1796
1840
  }
1797
- const body = soundtrack === "scene-synced" ? buildScorePlanBody(film) : buildFlatMusicBody(film, all);
1841
+ const body = soundtrack === "scene-synced" ? buildScorePlanBody(film) : buildFlatMusicBody(film, shots(film));
1798
1842
  const dispatch2 = await ctx.client.run("text-to-music", body);
1799
1843
  return {
1800
1844
  kind: "text",
@@ -1835,6 +1879,12 @@ It is saved in the Library folder "Film: ${film.title}".`,
1835
1879
  ...moving && !paused ? { followUp: keepGoing(filmId, 30, { soundtrack }) } : {}
1836
1880
  };
1837
1881
  }
1882
+ function stillFlagged(f) {
1883
+ const labels = shotLabels(f);
1884
+ const flagged = shots(f).filter(flaggedClip);
1885
+ if (!flagged.length) return "";
1886
+ 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).`;
1887
+ }
1838
1888
  function shotIndex(f) {
1839
1889
  const all = shots(f);
1840
1890
  if (all.length === 0 || all.length > 24) return "";
@@ -1843,9 +1893,10 @@ function shotIndex(f) {
1843
1893
  const clock = (sec) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
1844
1894
  const lines = all.map((sh) => {
1845
1895
  const from = at;
1846
- at += sh.durationSec || 0;
1896
+ at += cutSec(sh);
1847
1897
  const what = sh.prompt.replace(/\s+/g, " ").trim();
1848
- return ` ${labels.get(sh.id)} (about ${clock(from)}-${clock(at)}): ${what.length > 70 ? `${what.slice(0, 67)}...` : what}`;
1898
+ const when = sh.leftOut ? "left out of the cut" : `about ${clock(from)}-${clock(at)}`;
1899
+ return ` ${labels.get(sh.id)} (${when}): ${what.length > 70 ? `${what.slice(0, 67)}...` : what}`;
1849
1900
  });
1850
1901
  return ['Shots (redo any with action "redo" and its label):', ...lines].join("\n");
1851
1902
  }
@@ -1854,9 +1905,9 @@ async function redoShots(input, ctx) {
1854
1905
  if (!input.shots?.length) {
1855
1906
  return { kind: "text", text: 'Pass shots: the labels status reports, like ["2.3"] for scene 2, shot 3.' };
1856
1907
  }
1857
- const { data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1908
+ const { status: status2, data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1858
1909
  const film = data.film;
1859
- if (!film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1910
+ if (!film) return noFilm(status2, data, filmId, { action: "redo", shots: input.shots, redo: input.redo });
1860
1911
  const labels = shotLabels(film);
1861
1912
  const byLabel = new Map(shots(film).map((sh) => [labels.get(sh.id), sh]));
1862
1913
  const byId = new Map(shots(film).map((sh) => [sh.id, sh]));
@@ -1931,7 +1982,8 @@ var init_film = __esm({
1931
1982
  song_duration_sec: z.number().positive().max(7200).optional().describe("Music video mode (optional): the song length in seconds. Omit it and the studio measures the song."),
1932
1983
  lyrics: z.string().max(1e4).optional().describe("Music video mode: the song lyrics, so shots land on the right lines. Optional."),
1933
1984
  aspect: z.enum(["16:9", "9:16"]).default("16:9"),
1934
- length: z.enum(["30s", "60s", "2-3min", "5min"]).default("60s").describe("Target length; drives the shot count (~6/12/24/60 shots)."),
1985
+ pacing: 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."),
1986
+ length: z.enum(["30s", "60s", "2-3min", "5min"]).default("60s").describe("Target length of the finished film."),
1935
1987
  film_id: z.string().max(40).optional().describe("Existing film id (from a previous call) for produce/status/cast/redo."),
1936
1988
  action: 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."),
1937
1989
  shots: z.array(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.'),
@@ -1952,7 +2004,7 @@ var init_film = __esm({
1952
2004
  InputJsonSchema8 = {
1953
2005
  type: "object",
1954
2006
  properties: {
1955
- idea: { type: "string", description: "Plan step: one-to-three sentence film idea. The studio writes the screenplay (free). Provide idea or script." },
2007
+ 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.` },
1956
2008
  script: { type: "string", description: "Plan step: a full screenplay (sluglines, action lines, dialogue) to plan instead of an idea." },
1957
2009
  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).` },
1958
2010
  song_url: {
@@ -1969,7 +2021,12 @@ var init_film = __esm({
1969
2021
  },
1970
2022
  lyrics: { type: "string", description: "Plan step, music video mode (optional): the song lyrics, so shots land on the right lines." },
1971
2023
  aspect: { type: "string", enum: ["16:9", "9:16"], description: "Plan step: frame aspect ratio. Default 16:9." },
1972
- 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." },
2024
+ 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." },
2025
+ pacing: {
2026
+ type: "string",
2027
+ enum: ["story", "trailer"],
2028
+ 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."
2029
+ },
1973
2030
  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".' },
1974
2031
  action: {
1975
2032
  type: "string",
@@ -2028,7 +2085,7 @@ var init_film = __esm({
2028
2085
  benignDispatchRefusal = (status2, error) => status2 === 429 || status2 === 409 && /already generating/i.test(error ?? "");
2029
2086
  count = (f, pred) => shots(f).filter(pred).length;
2030
2087
  frameDone = (sh) => Boolean(sh.frameUrl) && sh.status !== "idle" && sh.status !== "frame-generating";
2031
- RETRY_CLEARS = /automatic audio check flagged|output audio\b[\s\S]*?sensitive information|temporarily unavailable/i;
2088
+ 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;
2032
2089
  REFUSED = /copyright|infringement|content filter|safety filter|moderation|blocked|flagged|sensitive|real (?:person|human)|nsfw|content policy|policy violation|inappropriate/i;
2033
2090
  isRefused = (sh) => sh.status === "failed" && Boolean(sh.errorMessage) && !RETRY_CLEARS.test(sh.errorMessage) && REFUSED.test(sh.errorMessage);
2034
2091
  PLANNER_POLL_MS = 3e3;
@@ -3662,7 +3719,7 @@ function makeRedactedLogger(target = console) {
3662
3719
  }
3663
3720
 
3664
3721
  // src/server.ts
3665
- var PACKAGE_VERSION = "0.19.0" ;
3722
+ var PACKAGE_VERSION = "0.20.0" ;
3666
3723
  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.";
3667
3724
  function buildServer(options) {
3668
3725
  if (!options.apiKey) {