@pixeldojo/mcp 0.17.0 → 0.19.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
@@ -593,7 +593,7 @@ var init_generate = __esm({
593
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
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
595
  InputSchema = z.object({
596
- prompt: z.string().min(1).max(8e3).describe("Prompt describing what to generate."),
596
+ 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
597
  model: z.string().optional().describe(
598
598
  `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.`
599
599
  ),
@@ -633,7 +633,7 @@ var init_generate = __esm({
633
633
  InputJsonSchema = {
634
634
  type: "object",
635
635
  properties: {
636
- prompt: { type: "string", description: "Prompt describing what to generate." },
636
+ 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.' },
637
637
  model: {
638
638
  type: "string",
639
639
  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.`
@@ -847,7 +847,7 @@ var init_edit = __esm({
847
847
  InputJsonSchema2 = {
848
848
  type: "object",
849
849
  properties: {
850
- prompt: { type: "string", description: "Edit instruction." },
850
+ 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.' },
851
851
  image_url: {
852
852
  type: "string",
853
853
  format: "uri",
@@ -1453,6 +1453,13 @@ async function filmApi(ctx, method, path, body) {
1453
1453
  const data = await res.json().catch(() => ({}));
1454
1454
  return { status: res.status, data };
1455
1455
  }
1456
+ function keepGoing(filmId, afterSec, extra = {}) {
1457
+ const set = Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== void 0));
1458
+ return { input: { film_id: filmId, action: "status", ...set }, afterSec };
1459
+ }
1460
+ function shotLabels(f) {
1461
+ return new Map(f.scenes.flatMap((s, si) => s.shots.map((sh, i) => [sh.id, `${si + 1}.${i + 1}`])));
1462
+ }
1456
1463
  async function applyCast(ctx, filmId, cast) {
1457
1464
  let charged = false;
1458
1465
  const lines = await Promise.all(cast.map(async (c) => {
@@ -1464,13 +1471,23 @@ async function applyCast(ctx, filmId, cast) {
1464
1471
  role: c.name,
1465
1472
  ...c.character_id ? { characterId: c.character_id } : {},
1466
1473
  ...c.image_urls ? { imageUrls: c.image_urls } : {},
1467
- ...c.prompt ? { prompt: c.prompt } : {}
1474
+ ...c.prompt ? { prompt: c.prompt } : {},
1475
+ ...c.replace ? { replace: true } : {},
1476
+ ...c.remove ? { remove: true } : {}
1468
1477
  }
1469
1478
  );
1470
- if (r.status === 200 && r.data.creditCost) charged = true;
1471
- if (r.status !== 200) return `Could not cast ${c.name}: ${r.data.error ?? r.status}`;
1472
- if (r.data.alreadyCast) return `${c.name} was already cast; nothing changed.`;
1473
- return `Cast ${c.name}${r.data.creditCost ? ` (portrait, ${r.data.creditCost} credits)` : ""}${r.data.boundShots ? `, in ${r.data.boundShots} shot(s)` : ""}.`;
1479
+ if (r.data.creditCost) charged = true;
1480
+ if (r.status !== 200) {
1481
+ const where2 = r.data.code === "cast-rendered" ? ` Storyboard: ${ctx.baseUrl}/film-studio/${filmId}` : "";
1482
+ return `Could not ${c.remove ? "remove" : c.replace ? "replace" : "cast"} ${c.name}: ${r.data.error ?? r.status}${where2}`;
1483
+ }
1484
+ if (r.data.removed) return `Removed ${c.name} from the film and every shot.`;
1485
+ if (r.data.alreadyCast) {
1486
+ return c.replace ? `${c.name} is already played by that character; nothing changed.` : `${c.name} is already cast; nothing changed. To swap who plays ${c.name}, send the entry again with replace: true.`;
1487
+ }
1488
+ const cost = r.data.creditCost ? ` (portrait, ${r.data.creditCost} credits)` : "";
1489
+ const where = r.data.boundShots ? `, in ${r.data.boundShots} shot(s)` : "";
1490
+ return r.data.replaced ? `Replaced ${r.data.replaced.name} with ${r.data.character?.name ?? "the new cast member"}${r.data.character?.id ? ` (character_id ${r.data.character.id})` : ""} as ${c.name}${cost}${where}.` : `Cast ${c.name}${cost}${where}.`;
1474
1491
  }));
1475
1492
  return { lines, charged };
1476
1493
  }
@@ -1562,12 +1579,13 @@ async function plan(input, ctx) {
1562
1579
  const castStarted = Date.now();
1563
1580
  const cast = input.cast?.length ? await applyCast(ctx, id, input.cast) : { lines: [], charged: false };
1564
1581
  const pollBudget = Math.max(0, PLANNER_MAX_POLLS - Math.ceil((Date.now() - castStarted) / PLANNER_POLL_MS));
1565
- const reply = (text) => ({
1582
+ const reply = (text, followUp) => ({
1566
1583
  kind: "text",
1567
1584
  text: [
1568
1585
  ...cast.lines,
1569
1586
  cast.charged ? text.replace(/Nothing has been charged( yet)?\./g, "Only the cast portraits above have been charged.") : text
1570
- ].join("\n")
1587
+ ].join("\n"),
1588
+ ...followUp ? { followUp } : {}
1571
1589
  });
1572
1590
  let planned;
1573
1591
  if (music) {
@@ -1578,14 +1596,14 @@ ${input.lyrics.trim()}` : ""].filter(Boolean).join("\n\n");
1578
1596
  let script = input.script?.trim() ?? "";
1579
1597
  if (!script) {
1580
1598
  const wrote = await runPlanner(ctx, id, `/api/films/${id}/script`, { idea: input.idea.trim() }, pollBudget);
1581
- if ("pending" in wrote) return reply(stillPlanningText(id, "script"));
1599
+ if ("pending" in wrote) return reply(stillPlanningText(id, "script"), keepGoing(id, 30, { soundtrack: input.soundtrack }));
1582
1600
  if ("error" in wrote) return reply(`Script writing failed: ${wrote.error}`);
1583
1601
  script = wrote.film.script ?? "";
1584
1602
  if (!script) return reply("Script writing failed: the studio returned an empty script.");
1585
1603
  }
1586
1604
  planned = await runPlanner(ctx, id, `/api/films/${id}/plan`, { script });
1587
1605
  }
1588
- if ("pending" in planned) return reply(stillPlanningText(id));
1606
+ if ("pending" in planned) return reply(stillPlanningText(id), keepGoing(id, 30, { soundtrack: input.soundtrack }));
1589
1607
  if ("error" in planned) return reply(`Shot planning failed: ${planned.error}`);
1590
1608
  const film = planned.film;
1591
1609
  if (needsSlice(film)) {
@@ -1598,17 +1616,17 @@ ${err} Call action "status" to retry the cut.`);
1598
1616
  return reply([plannedText(film, ctx), uncast.length ? `
1599
1617
  ${castNeededText(id, uncast)}` : ""].filter(Boolean).join("\n"));
1600
1618
  }
1601
- async function resumePlanning(film, ctx) {
1619
+ async function resumePlanning(film, ctx, soundtrack) {
1602
1620
  if (film.scenes.length > 0 && film.llmJob?.status !== "running") return null;
1603
1621
  if (film.llmJob?.status === "running") {
1604
- return { kind: "text", text: stillPlanningText(film.id, film.llmJob.kind === "script" ? "script" : "plan") };
1622
+ return { kind: "text", text: stillPlanningText(film.id, film.llmJob.kind === "script" ? "script" : "plan"), followUp: keepGoing(film.id, 30, { soundtrack }) };
1605
1623
  }
1606
1624
  const prior = film.llmJob?.status === "error" ? `The last planning run failed (${film.llmJob.error ?? "unknown"}). ` : "";
1607
1625
  if (!film.script?.trim()) {
1608
1626
  return { kind: "text", text: `${prior}This film has nothing to plan from. Start a new one with action "plan".` };
1609
1627
  }
1610
1628
  const planned = await runPlanner(ctx, film.id, `/api/films/${film.id}/plan`, { script: film.script }, 0);
1611
- if ("pending" in planned) return { kind: "text", text: `${prior}${stillPlanningText(film.id)}` };
1629
+ if ("pending" in planned) return { kind: "text", text: `${prior}${stillPlanningText(film.id)}`, followUp: keepGoing(film.id, 30, { soundtrack }) };
1612
1630
  if ("error" in planned) return { kind: "text", text: `Shot planning failed: ${planned.error} Call action "status" to retry.` };
1613
1631
  if (needsSlice(planned.film)) {
1614
1632
  const err = await sliceSong(ctx, planned.film);
@@ -1618,22 +1636,30 @@ ${err} Call action "status" to retry the cut.` };
1618
1636
  }
1619
1637
  return { kind: "text", text: plannedText(planned.film, ctx) };
1620
1638
  }
1621
- async function produce(filmId, ctx) {
1639
+ async function produce(filmId, soundtrack, ctx) {
1622
1640
  const { data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1623
1641
  if (!data.film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1624
- const planning = await resumePlanning(data.film, ctx);
1642
+ const planning = await resumePlanning(data.film, ctx, soundtrack);
1625
1643
  if (planning) return planning;
1626
1644
  const uncast = await uncastRolesOf(ctx, filmId);
1627
1645
  if (uncast.length) return { kind: "text", text: `Nothing was charged. ${castNeededText(filmId, uncast)}` };
1628
1646
  const pending = shots(data.film).filter((sh) => sh.status === "idle" || sh.status === "failed" && !sh.frameUrl);
1629
1647
  let fired = 0;
1648
+ let refused;
1630
1649
  for (const sh of pending.slice(0, 6)) {
1631
1650
  const r = await filmApi(ctx, "POST", `/api/films/${filmId}/shots/${sh.id}/generate`, { kind: "frame" });
1632
1651
  if (r.status === 200) fired += 1;
1652
+ else if (!benignDispatchRefusal(r.status, r.data.error)) refused ??= r.data.error ?? `HTTP ${r.status}`;
1653
+ }
1654
+ if (fired === 0 && refused) {
1655
+ return { kind: "text", text: `Production could not start on "${data.film.title}": ${refused}` };
1633
1656
  }
1634
1657
  return {
1635
1658
  kind: "text",
1636
- 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.`
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.`,
1660
+ // The caller's soundtrack choice rides along: the follow-up status calls
1661
+ // are the ones that reach the score.
1662
+ followUp: keepGoing(filmId, 30, { soundtrack })
1637
1663
  };
1638
1664
  }
1639
1665
  function buildScorePlanBody(film) {
@@ -1679,30 +1705,49 @@ async function advance(filmId, soundtrack, musicJobId, ctx) {
1679
1705
  if (film.status === "exported" && film.exportUrl) {
1680
1706
  return {
1681
1707
  kind: "text",
1682
- text: `Film finished: "${film.title}"
1708
+ text: [
1709
+ `Film finished: "${film.title}"
1683
1710
  Watch/download: ${film.exportUrl}
1684
1711
  Workspace: ${ctx.baseUrl}/film-studio/${film.id}
1685
- It is saved in the Library folder "Film: ${film.title}".`
1712
+ It is saved in the Library folder "Film: ${film.title}".`,
1713
+ shotIndex(film)
1714
+ ].filter(Boolean).join("\n")
1686
1715
  };
1687
1716
  }
1688
- if (film.exportError) {
1689
- return { kind: "text", text: `Export failed: ${film.exportError} Poll again with action "status" to retry.` };
1717
+ const startExport = async () => {
1718
+ const r = await filmApi(ctx, "POST", `/api/films/${filmId}/export`, {});
1719
+ return r.status === 200 || r.status === 409 ? null : r.data.error ?? `HTTP ${r.status}`;
1720
+ };
1721
+ if (film.exportError && all.length > 0 && all.every((sh) => sh.status === "clip-ready")) {
1722
+ const again = await startExport();
1723
+ return {
1724
+ kind: "text",
1725
+ 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.`
1726
+ };
1690
1727
  }
1691
- const planning = await resumePlanning(film, ctx);
1728
+ const planning = await resumePlanning(film, ctx, soundtrack);
1692
1729
  if (planning) return planning;
1693
1730
  if (needsSlice(film)) {
1694
1731
  const err = await sliceSong(ctx, film);
1695
1732
  if (err) return { kind: "text", text: `${err} Poll again with action "status" to retry.` };
1696
- return { kind: "text", text: `Song cut into one slice per shot. Poll again with {"film_id": "${filmId}", "action": "status"} to keep production moving.` };
1733
+ return {
1734
+ kind: "text",
1735
+ text: `Song cut into one slice per shot. Poll again with {"film_id": "${filmId}", "action": "status"} to keep production moving.`,
1736
+ followUp: keepGoing(filmId, 5, { soundtrack })
1737
+ };
1697
1738
  }
1698
1739
  if (shots(film).length > 0 && shots(film).every((sh) => sh.status === "idle")) {
1699
1740
  return { kind: "text", text: plannedText(film, ctx) };
1700
1741
  }
1701
1742
  let started = 0;
1702
1743
  const resent = /* @__PURE__ */ new Set();
1744
+ const refusedStarts = [];
1703
1745
  const dispatch = async (sh, kind) => {
1704
1746
  const r = await filmApi(ctx, "POST", `/api/films/${filmId}/shots/${sh.id}/generate`, { kind });
1705
- if (r.status !== 200) return;
1747
+ if (r.status !== 200) {
1748
+ if (!benignDispatchRefusal(r.status, r.data.error)) refusedStarts.push(r.data.error ?? `HTTP ${r.status}`);
1749
+ return;
1750
+ }
1706
1751
  started += 1;
1707
1752
  if (sh.status === "failed") resent.add(sh.id);
1708
1753
  };
@@ -1732,27 +1777,47 @@ It is saved in the Library folder "Film: ${film.title}".`
1732
1777
  const outUrl = out?.audio ?? out?.video ?? out?.images?.[0];
1733
1778
  if (job.status === "completed" && typeof outUrl === "string") {
1734
1779
  await filmApi(ctx, "PATCH", `/api/films/${filmId}`, { audioStems: [{ kind: "music", url: outUrl }] });
1735
- await filmApi(ctx, "POST", `/api/films/${filmId}/export`, {});
1736
- return { kind: "text", text: `Score attached \u2014 export started. Poll again in ~60s with {"film_id": "${filmId}", "action": "status"} for the finished film URL.` };
1780
+ const exportError2 = await startExport();
1781
+ if (exportError2) return { kind: "text", text: `Score attached, but the export could not start: ${exportError2}` };
1782
+ return {
1783
+ kind: "text",
1784
+ text: `Score attached \u2014 export started. Poll again in ~60s with {"film_id": "${filmId}", "action": "status"} for the finished film URL.`,
1785
+ followUp: keepGoing(filmId, 60, { soundtrack })
1786
+ };
1737
1787
  }
1738
1788
  if (job.status === "failed") {
1739
1789
  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.` };
1740
1790
  }
1741
- return { kind: "text", text: `Score still composing. Poll again in ~20s with {"film_id": "${filmId}", "action": "status", "music_job_id": "${musicJobId}"}.` };
1791
+ return {
1792
+ kind: "text",
1793
+ text: `Score still composing. Poll again in ~20s with {"film_id": "${filmId}", "action": "status", "music_job_id": "${musicJobId}"}.`,
1794
+ followUp: keepGoing(filmId, 20, { soundtrack, music_job_id: musicJobId })
1795
+ };
1742
1796
  }
1743
1797
  const body = soundtrack === "scene-synced" ? buildScorePlanBody(film) : buildFlatMusicBody(film, all);
1744
1798
  const dispatch2 = await ctx.client.run("text-to-music", body);
1745
- 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}"}.` };
1799
+ return {
1800
+ kind: "text",
1801
+ 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}"}.`,
1802
+ followUp: keepGoing(filmId, 20, { soundtrack, music_job_id: dispatch2.jobId })
1803
+ };
1746
1804
  }
1747
- await filmApi(ctx, "POST", `/api/films/${filmId}/export`, {});
1748
- return { kind: "text", text: `All clips ready \u2014 export started (${progressLine(film)}). Poll again in ~60s with action "status" for the finished film URL.` };
1805
+ const exportError = await startExport();
1806
+ if (exportError) return { kind: "text", text: `All clips ready, but the export could not start: ${exportError}` };
1807
+ return {
1808
+ kind: "text",
1809
+ text: `All clips ready \u2014 export started (${progressLine(film)}). Poll again in ~60s with action "status" for the finished film URL.`,
1810
+ followUp: keepGoing(filmId, 60, { soundtrack })
1811
+ };
1749
1812
  }
1750
1813
  const failed = all.filter((sh) => sh.status === "failed");
1751
1814
  const refused = failed.filter(isRefused);
1752
1815
  const retrying = failed.filter((sh) => !isRefused(sh));
1753
1816
  const sentAgain = retrying.filter((sh) => resent.has(sh.id)).length;
1754
- const paused = refused.length > 0 && started === 0 && !all.some((sh) => sh.status === "frame-generating" || sh.status === "clip-generating");
1755
- const labels = new Map(film.scenes.flatMap((s, si) => s.shots.map((sh, i) => [sh.id, `${si + 1}.${i + 1}`])));
1817
+ const rendering = all.some((sh) => sh.status === "frame-generating" || sh.status === "clip-generating");
1818
+ const paused = refused.length > 0 && started === 0 && !rendering;
1819
+ const moving = started > 0 || rendering;
1820
+ const labels = shotLabels(film);
1756
1821
  const reason = (sh) => {
1757
1822
  const text = (sh.errorMessage ?? "no reason given").replace(/\s*Retry (?:the shot|it)\.\s*$/i, "");
1758
1823
  return text.length > 300 ? `${text.slice(0, 297)}...` : text;
@@ -1764,16 +1829,77 @@ It is saved in the Library folder "Film: ${film.title}".`
1764
1829
  `${paused ? "Paused" : "In progress"}: ${progressLine(film)}.`,
1765
1830
  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}.` : "",
1766
1831
  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"}.` : "",
1767
- 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"}.`
1768
- ].filter(Boolean).join("\n")
1832
+ refusedStarts.length > 0 ? `${refusedStarts.length} render(s) could not start: ${refusedStarts[0]}` : "",
1833
+ 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.`
1834
+ ].filter(Boolean).join("\n"),
1835
+ ...moving && !paused ? { followUp: keepGoing(filmId, 30, { soundtrack }) } : {}
1836
+ };
1837
+ }
1838
+ function shotIndex(f) {
1839
+ const all = shots(f);
1840
+ if (all.length === 0 || all.length > 24) return "";
1841
+ const labels = shotLabels(f);
1842
+ let at = 0;
1843
+ const clock = (sec) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
1844
+ const lines = all.map((sh) => {
1845
+ const from = at;
1846
+ at += sh.durationSec || 0;
1847
+ 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}`;
1849
+ });
1850
+ return ['Shots (redo any with action "redo" and its label):', ...lines].join("\n");
1851
+ }
1852
+ async function redoShots(input, ctx) {
1853
+ const filmId = input.film_id;
1854
+ if (!input.shots?.length) {
1855
+ return { kind: "text", text: 'Pass shots: the labels status reports, like ["2.3"] for scene 2, shot 3.' };
1856
+ }
1857
+ const { data } = await filmApi(ctx, "GET", `/api/films/${filmId}`);
1858
+ const film = data.film;
1859
+ if (!film) return { kind: "text", text: `Film not found: ${data.error ?? filmId}` };
1860
+ const labels = shotLabels(film);
1861
+ const byLabel = new Map(shots(film).map((sh) => [labels.get(sh.id), sh]));
1862
+ const byId = new Map(shots(film).map((sh) => [sh.id, sh]));
1863
+ const kind = input.redo;
1864
+ const lines = [];
1865
+ let started = 0;
1866
+ let credits = 0;
1867
+ for (const ref of Array.from(new Set(input.shots))) {
1868
+ const sh = byLabel.get(ref) ?? byId.get(ref);
1869
+ if (!sh) {
1870
+ lines.push(`No shot ${ref} in this film (shots run 1.1 to ${Array.from(byLabel.keys()).pop()}).`);
1871
+ continue;
1872
+ }
1873
+ const label = labels.get(sh.id);
1874
+ if (kind === "clip" && !sh.frameUrl) {
1875
+ lines.push(`Shot ${label} has no frame yet, so there is no clip to redo. Redo it with redo "frame".`);
1876
+ continue;
1877
+ }
1878
+ const r = await filmApi(ctx, "POST", `/api/films/${filmId}/shots/${sh.id}/generate`, { kind });
1879
+ if (r.status === 200) {
1880
+ started += 1;
1881
+ credits += r.data.creditCost ?? 0;
1882
+ lines.push(`Shot ${label}: new ${kind} rendering${r.data.creditCost ? ` (${r.data.creditCost} credits)` : ""}.`);
1883
+ } else {
1884
+ lines.push(`Shot ${label}: could not start: ${r.data.error ?? `HTTP ${r.status}`}`);
1885
+ }
1886
+ }
1887
+ if (started === 0) return { kind: "text", text: [...lines, "Nothing was started."].join("\n") };
1888
+ return {
1889
+ kind: "text",
1890
+ text: [
1891
+ ...lines,
1892
+ `${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.`
1893
+ ].join("\n"),
1894
+ followUp: keepGoing(filmId, 30, { soundtrack: input.soundtrack })
1769
1895
  };
1770
1896
  }
1771
1897
  async function run8(input, ctx) {
1772
1898
  const action = input.action === "plan" && input.film_id ? input.cast?.length ? "cast" : "status" : input.action;
1773
1899
  if (action === "plan") return plan(input, ctx);
1774
- if (!input.film_id) return { kind: "text", text: "produce/status/cast need the film_id returned by the plan step." };
1900
+ if (!input.film_id) return { kind: "text", text: "produce/status/cast/redo need the film_id returned by the plan step." };
1775
1901
  if (action === "cast") {
1776
- if (!input.cast?.length) return { kind: "text", text: "Pass cast: [{ name, prompt | image_urls | character_id }] to cast characters." };
1902
+ 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)." };
1777
1903
  const { lines } = await applyCast(ctx, input.film_id, input.cast);
1778
1904
  const uncast = await uncastRolesOf(ctx, input.film_id);
1779
1905
  return {
@@ -1784,10 +1910,11 @@ async function run8(input, ctx) {
1784
1910
  ].join("\n")
1785
1911
  };
1786
1912
  }
1787
- if (action === "produce") return produce(input.film_id, ctx);
1913
+ if (action === "produce") return produce(input.film_id, input.soundtrack, ctx);
1914
+ if (action === "redo") return redoShots(input, ctx);
1788
1915
  return advance(input.film_id, input.soundtrack, input.music_job_id, ctx);
1789
1916
  }
1790
- 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;
1917
+ 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;
1791
1918
  var init_film = __esm({
1792
1919
  "src/skills/film.ts"() {
1793
1920
  init_upload_helpers();
@@ -1805,16 +1932,20 @@ var init_film = __esm({
1805
1932
  lyrics: z.string().max(1e4).optional().describe("Music video mode: the song lyrics, so shots land on the right lines. Optional."),
1806
1933
  aspect: z.enum(["16:9", "9:16"]).default("16:9"),
1807
1934
  length: z.enum(["30s", "60s", "2-3min", "5min"]).default("60s").describe("Target length; drives the shot count (~6/12/24/60 shots)."),
1808
- film_id: z.string().max(40).optional().describe("Existing film id (from a previous call) for produce/status/cast."),
1809
- action: z.enum(["plan", "produce", "status", "cast"]).default("plan").describe("'plan' creates + storyboards (free). 'cast' casts 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."),
1935
+ film_id: z.string().max(40).optional().describe("Existing film id (from a previous call) for produce/status/cast/redo."),
1936
+ 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
+ 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.'),
1938
+ redo: 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."),
1810
1939
  cast: z.array(
1811
1940
  z.object({
1812
1941
  name: z.string().trim().min(1).max(60),
1813
1942
  image_urls: z.array(z.string().url().startsWith("https://").max(2048)).min(1).max(8).optional(),
1814
1943
  prompt: z.string().trim().min(1).max(600).optional(),
1815
- character_id: z.string().max(40).optional()
1944
+ character_id: z.string().max(40).optional(),
1945
+ replace: z.boolean().optional(),
1946
+ remove: z.boolean().optional()
1816
1947
  })
1817
- ).max(8).optional().describe("Cast members, one per named character. Each gives image_urls (your own photos), a prompt (a portrait is generated, charged), or a character_id (a Character Studio character); with none of these the Character Studio character of that name is used."),
1948
+ ).max(8).optional().describe("Cast members, one per named character. Each gives image_urls (your own photos), a prompt (a portrait is generated, charged), or a character_id (a Character Studio character); with none of these the Character Studio character of that name is used. replace: true swaps out whoever already plays that name; remove: true takes them off the film."),
1818
1949
  soundtrack: z.enum(["scene-synced", "flat", "none"]).default("scene-synced").describe("scene-synced composes one musical section per scene, pinned to the scene lengths (default). flat is a single cohesive bed. none skips music."),
1819
1950
  music_job_id: z.string().max(80).optional().describe("The music_job_id from the previous status reply while the score is composing, so that score is attached. A status call without it, once all clips are ready and no score is attached, starts and charges for a new score.")
1820
1951
  });
@@ -1839,16 +1970,27 @@ var init_film = __esm({
1839
1970
  lyrics: { type: "string", description: "Plan step, music video mode (optional): the song lyrics, so shots land on the right lines." },
1840
1971
  aspect: { type: "string", enum: ["16:9", "9:16"], description: "Plan step: frame aspect ratio. Default 16:9." },
1841
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." },
1842
- 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".' },
1973
+ 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".' },
1843
1974
  action: {
1844
1975
  type: "string",
1845
- enum: ["plan", "produce", "status", "cast"],
1846
- description: "plan (default, free): writes the script and plans shots, returns film_id, a cost estimate, and any characters still to cast. cast: casts 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."
1976
+ enum: ["plan", "produce", "status", "cast", "redo"],
1977
+ 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."
1978
+ },
1979
+ shots: {
1980
+ type: "array",
1981
+ items: { type: "string" },
1982
+ maxItems: 12,
1983
+ 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.'
1984
+ },
1985
+ redo: {
1986
+ type: "string",
1987
+ enum: ["clip", "frame"],
1988
+ 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."
1847
1989
  },
1848
1990
  cast: {
1849
1991
  type: "array",
1850
1992
  maxItems: 8,
1851
- description: 'Cast members, one per named character. Every named character must be cast before frames render, so faces, ages and species hold in every shot. Pass them on the plan call (the script then stars them by name) or later with action "cast" using the names the plan reports. Each entry: name, plus ONE of image_urls (your own photos of an adult, made into a character), prompt (a portrait is generated from it, charged like any image), or character_id (a Character Studio character). An entry with only a name uses your Character Studio character of that name.',
1993
+ description: 'Cast members, one per named character. Every named character must be cast before frames render, so faces, ages and species hold in every shot. Pass them on the plan call (the script then stars them by name) or later with action "cast" using the names the plan reports. Each entry: name, plus ONE of image_urls (your own photos of an adult, made into a character), prompt (a portrait is generated from it, charged like any image), or character_id (a Character Studio character). An entry with only a name uses your Character Studio character of that name. A name that is already cast is left alone unless the entry sets replace: true, which hands that part (every shot, outfit and voice) to the new cast member. remove: true with only the name takes that cast member off the film; the part then needs a new cast member before frames render. replace and remove work until the shots of that cast member are rendered; after that, recast on the storyboard, where the frames that show them can be redone.',
1852
1994
  items: {
1853
1995
  type: "object",
1854
1996
  properties: {
@@ -1858,7 +2000,15 @@ var init_film = __esm({
1858
2000
  type: "string",
1859
2001
  description: 'A description to generate the portrait from: species if not human, gender, adult age, look and outfit (e.g. "man in his late 50s, grey mustache, sunburnt, red windbreaker").'
1860
2002
  },
1861
- character_id: { type: "string", description: "A Character Studio character id." }
2003
+ character_id: { type: "string", description: "A Character Studio character id." },
2004
+ replace: {
2005
+ type: "boolean",
2006
+ description: "Swap out whoever already plays this name for the image_urls, prompt or character_id given here, before their shots are rendered. Without it, an already-cast name is left unchanged."
2007
+ },
2008
+ remove: {
2009
+ type: "boolean",
2010
+ description: "Take whoever plays this name off the film and every shot (free), before their shots are rendered. Give only the name."
2011
+ }
1862
2012
  },
1863
2013
  required: ["name"]
1864
2014
  }
@@ -1875,6 +2025,7 @@ var init_film = __esm({
1875
2025
  }
1876
2026
  };
1877
2027
  shots = (f) => f.scenes.flatMap((s) => s.shots);
2028
+ benignDispatchRefusal = (status2, error) => status2 === 429 || status2 === 409 && /already generating/i.test(error ?? "");
1878
2029
  count = (f, pred) => shots(f).filter(pred).length;
1879
2030
  frameDone = (sh) => Boolean(sh.frameUrl) && sh.status !== "idle" && sh.status !== "frame-generating";
1880
2031
  RETRY_CLEARS = /automatic audio check flagged|output audio\b[\s\S]*?sensitive information|temporarily unavailable/i;
@@ -1905,8 +2056,8 @@ var init_film = __esm({
1905
2056
  };
1906
2057
  filmSkill = {
1907
2058
  name: "pixeldojo_film",
1908
- 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. Phased: plan is free; cast; produce starts charged generation; status advances production (poll every ~30s).",
1909
- 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. Phased: plan is free; cast; produce starts charged generation; status advances production (poll every ~30s).",
2059
+ 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.",
2060
+ 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.",
1910
2061
  inputZod: InputSchema8,
1911
2062
  inputJsonSchema: InputJsonSchema8,
1912
2063
  allowedModels: [],
@@ -2120,7 +2271,7 @@ var init_video = __esm({
2120
2271
  },
2121
2272
  prompt: {
2122
2273
  type: "string",
2123
- 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)."
2274
+ 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)."
2124
2275
  },
2125
2276
  video_url: {
2126
2277
  type: "string",
@@ -2723,7 +2874,7 @@ async function run13(input, ctx) {
2723
2874
  );
2724
2875
  return {
2725
2876
  kind: "handoff",
2726
- 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.`),
2877
+ 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.`),
2727
2878
  jobId: result.campaignId,
2728
2879
  statusUrl: result.statusUrl ?? ctx.buildStatusUrl(result.campaignId)
2729
2880
  };
@@ -2903,6 +3054,26 @@ var init_audio = __esm({
2903
3054
  }
2904
3055
  });
2905
3056
  async function run16(input, ctx) {
3057
+ if (input.source === "characters") {
3058
+ const { characters } = await ctx.client.listCharacters({ q: input.q, limit: input.limit ?? 25 });
3059
+ if (characters.length === 0) {
3060
+ return {
3061
+ kind: "text",
3062
+ 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."
3063
+ };
3064
+ }
3065
+ const lines2 = characters.map(
3066
+ (c) => `- ${c.mention} (${c.kind})${c.description ? `: ${c.description.slice(0, 100)}` : ""}
3067
+ images: ${c.image_urls.slice(0, 4).join(" ")}`
3068
+ );
3069
+ return {
3070
+ kind: "text",
3071
+ text: `${characters.length} saved character(s)/element(s):
3072
+ ${lines2.join("\n")}
3073
+
3074
+ Write the @Name in a pixeldojo_generate / pixeldojo_video / pixeldojo_edit prompt and their photos are attached automatically (models that take reference images).`
3075
+ };
3076
+ }
2906
3077
  const res = await ctx.client.listLibrary({
2907
3078
  q: input.q,
2908
3079
  model: input.model,
@@ -2937,6 +3108,7 @@ var InputSchema16, InputJsonSchema16, librarySkill;
2937
3108
  var init_library = __esm({
2938
3109
  "src/skills/library.ts"() {
2939
3110
  InputSchema16 = z.object({
3111
+ source: z.enum(["media", "characters"]).optional().describe("media (default): saved and recent results. characters: saved Character Studio characters and elements, usable as @Name in prompts."),
2940
3112
  q: z.string().optional().describe("Substring match against the prompt that produced the asset."),
2941
3113
  model: z.string().optional().describe("Restrict to a single model (apiId)."),
2942
3114
  modality: z.enum(["image", "video", "audio"]).optional().describe("Restrict to one output type."),
@@ -2945,6 +3117,11 @@ var init_library = __esm({
2945
3117
  InputJsonSchema16 = {
2946
3118
  type: "object",
2947
3119
  properties: {
3120
+ source: {
3121
+ type: "string",
3122
+ enum: ["media", "characters"],
3123
+ 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."
3124
+ },
2948
3125
  q: {
2949
3126
  type: "string",
2950
3127
  description: "Substring match against the prompt that produced the asset. Omit to list the newest items."
@@ -2961,7 +3138,7 @@ var init_library = __esm({
2961
3138
  };
2962
3139
  librarySkill = {
2963
3140
  name: "pixeldojo_library",
2964
- 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.",
3141
+ 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.",
2965
3142
  inputZod: InputSchema16,
2966
3143
  inputJsonSchema: InputJsonSchema16,
2967
3144
  allowedModels: [],
@@ -2970,6 +3147,100 @@ var init_library = __esm({
2970
3147
  };
2971
3148
  }
2972
3149
  });
3150
+ async function run17(input, ctx) {
3151
+ const res = await ctx.client.saveMedia({
3152
+ url: input.url,
3153
+ prompt: input.prompt,
3154
+ tool_name: input.tool_name,
3155
+ modality: input.modality,
3156
+ job_id: input.job_id
3157
+ });
3158
+ return {
3159
+ kind: "text",
3160
+ text: `Saved to My Media (permanent): ${res.url}
3161
+ It will appear at https://pixeldojo.ai/my-images and in pixeldojo_library searches.`
3162
+ };
3163
+ }
3164
+ var InputSchema17, InputJsonSchema17, saveSkill;
3165
+ var init_save = __esm({
3166
+ "src/skills/save.ts"() {
3167
+ InputSchema17 = z.object({
3168
+ url: z.string().url().describe("The asset URL to save (image or video)."),
3169
+ prompt: z.string().optional().describe("The prompt that produced it \u2014 makes it findable in the library later."),
3170
+ tool_name: z.string().optional().describe("Tool/model slug that produced it."),
3171
+ modality: z.enum(["image", "video", "audio"]).optional(),
3172
+ job_id: z.string().optional().describe("The jobId that produced this asset, when known.")
3173
+ });
3174
+ InputJsonSchema17 = {
3175
+ type: "object",
3176
+ properties: {
3177
+ url: { type: "string", format: "uri", description: "The result URL to keep (image or video from a PixelDojo job)." },
3178
+ prompt: { type: "string", description: "The prompt that produced it, so it is findable in the library later." },
3179
+ tool_name: { type: "string", description: "Model apiId that produced it, when known." },
3180
+ modality: { type: "string", enum: ["image", "video", "audio"], description: "Output type, when the URL does not make it obvious." },
3181
+ job_id: { type: "string", description: "The jobId that produced it, when known." }
3182
+ },
3183
+ required: ["url"]
3184
+ };
3185
+ saveSkill = {
3186
+ name: "pixeldojo_save",
3187
+ 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.`,
3188
+ inputZod: InputSchema17,
3189
+ inputJsonSchema: InputJsonSchema17,
3190
+ allowedModels: [],
3191
+ defaultModel: "",
3192
+ run: run17
3193
+ };
3194
+ }
3195
+ });
3196
+ async function run18(input, ctx) {
3197
+ const res = await ctx.client.saveCharacter({
3198
+ name: input.name,
3199
+ kind: input.kind ?? "character",
3200
+ image_urls: input.image_urls,
3201
+ description: input.description
3202
+ });
3203
+ return {
3204
+ kind: "text",
3205
+ 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.`
3206
+ };
3207
+ }
3208
+ var KINDS, InputSchema18, InputJsonSchema18, saveCharacterSkill;
3209
+ var init_save_character = __esm({
3210
+ "src/skills/save-character.ts"() {
3211
+ KINDS = ["character", "product", "outfit", "location", "object", "style"];
3212
+ InputSchema18 = z.object({
3213
+ name: z.string().min(1).max(60).describe("What to call it. Used as @Name in prompts."),
3214
+ kind: z.enum(KINDS).optional().describe("Default character."),
3215
+ image_urls: z.array(z.string().url()).max(8).optional().describe("3 to 5 photos from different angles works best for a character."),
3216
+ description: z.string().max(1500).optional().describe("Appearance notes, or the scene text for an element.")
3217
+ });
3218
+ InputJsonSchema18 = {
3219
+ type: "object",
3220
+ properties: {
3221
+ name: { type: "string", description: "What to call it. Used as @Name in prompts afterwards." },
3222
+ kind: { type: "string", enum: KINDS, description: "character (default), product, outfit, location, object (a prop) or style." },
3223
+ image_urls: {
3224
+ type: "array",
3225
+ items: { type: "string", format: "uri" },
3226
+ maxItems: 8,
3227
+ description: "Photos of it. For a person, 3 to 5 clear photos from different angles. Required for a character."
3228
+ },
3229
+ description: { type: "string", description: "Optional appearance notes, or the scene text for an element without photos." }
3230
+ },
3231
+ required: ["name"]
3232
+ };
3233
+ saveCharacterSkill = {
3234
+ name: "pixeldojo_save_character",
3235
+ 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.",
3236
+ inputZod: InputSchema18,
3237
+ inputJsonSchema: InputJsonSchema18,
3238
+ allowedModels: [],
3239
+ defaultModel: "",
3240
+ run: run18
3241
+ };
3242
+ }
3243
+ });
2973
3244
  function substitutePlaceholders(args, inputs, prevUrl) {
2974
3245
  const missing = [];
2975
3246
  const fill = (value) => {
@@ -3226,6 +3497,8 @@ __export(skills_exports, {
3226
3497
  librarySkill: () => librarySkill,
3227
3498
  listWorkflowsSkill: () => listWorkflowsSkill,
3228
3499
  runWorkflowSkill: () => runWorkflowSkill,
3500
+ saveCharacterSkill: () => saveCharacterSkill,
3501
+ saveSkill: () => saveSkill,
3229
3502
  saveWorkflowSkill: () => saveWorkflowSkill,
3230
3503
  statusSkill: () => statusSkill,
3231
3504
  storyboardSkill: () => storyboardSkill,
@@ -3271,6 +3544,8 @@ var init_skills = __esm({
3271
3544
  init_upload();
3272
3545
  init_audio();
3273
3546
  init_library();
3547
+ init_save();
3548
+ init_save_character();
3274
3549
  init_workflows();
3275
3550
  ALL_SKILLS = [
3276
3551
  generateSkill,
@@ -3280,6 +3555,8 @@ var init_skills = __esm({
3280
3555
  upscaleSkill,
3281
3556
  audioSkill,
3282
3557
  librarySkill,
3558
+ saveSkill,
3559
+ saveCharacterSkill,
3283
3560
  saveWorkflowSkill,
3284
3561
  runWorkflowSkill,
3285
3562
  listWorkflowsSkill,
@@ -3327,6 +3604,8 @@ var init_skills = __esm({
3327
3604
  pixeldojo_status: read("Check job status"),
3328
3605
  pixeldojo_campaign_status: read("Check campaign status"),
3329
3606
  pixeldojo_library: read("Search media library"),
3607
+ pixeldojo_save: render("Save to My Media"),
3608
+ pixeldojo_save_character: render("Save character or element"),
3330
3609
  pixeldojo_list_workflows: read("List saved workflows"),
3331
3610
  pixeldojo_from_url: { ...read("Read product page"), openWorldHint: true }
3332
3611
  };
@@ -3383,8 +3662,8 @@ function makeRedactedLogger(target = console) {
3383
3662
  }
3384
3663
 
3385
3664
  // src/server.ts
3386
- var PACKAGE_VERSION = "0.17.0" ;
3387
- 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.";
3665
+ var PACKAGE_VERSION = "0.19.0" ;
3666
+ 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.";
3388
3667
  function buildServer(options) {
3389
3668
  if (!options.apiKey) {
3390
3669
  throw new Error(