corent-mcp 0.8.1 → 0.10.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/server.js CHANGED
@@ -18,7 +18,7 @@ import { z } from "zod";
18
18
  import { MEDIA_WIDGET_HTML } from "./widget-html.js";
19
19
  import { registerPrompts } from "./prompts.js";
20
20
  export const DEFAULT_API_URL = "https://api.corent.tech";
21
- export const SERVER_VERSION = "0.8.1";
21
+ export const SERVER_VERSION = "0.10.0";
22
22
  // Transport resilience, matching the two SDKs: a 429 or 5xx is retried with
23
23
  // backoff, but ONLY on calls that carry an Idempotency-Key (or are GETs), so a
24
24
  // retry can never buy a second generation.
@@ -84,6 +84,8 @@ function errorCode(err) {
84
84
  return "invalid_request";
85
85
  if (err.status === 409 || detail.includes("estimate"))
86
86
  return "estimate_exceeds_ceiling";
87
+ if (err.status === 400 && detail.includes("max_cost_cents"))
88
+ return "estimate_exceeds_ceiling";
87
89
  if (err.status === 429)
88
90
  return "rate_limited";
89
91
  if (err.status === 503)
@@ -170,6 +172,20 @@ export function createCorentServer(config = {}) {
170
172
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
171
173
  };
172
174
  }
175
+ /**
176
+ * POST /v1/intent answers task_type "film" for anything one clip cannot
177
+ * deliver (longer than one take, or several characters telling a story), and
178
+ * /v1/intent/execute will not make a single clip for it. Say so in this
179
+ * server's own tool names, so the assistant goes to plan_film.
180
+ */
181
+ function pointFilmsToPlanFilm(res) {
182
+ if (res && typeof res === "object" && res.plan?.task_type === "film") {
183
+ res.next_step = "plan_film";
184
+ const hint = "This is a film: call plan_film with the same request, show the user the plan and price, then make_film.";
185
+ res.message = typeof res.message === "string" && res.message ? `${res.message} ${hint}` : hint;
186
+ }
187
+ return res;
188
+ }
173
189
  function wrap(fn) {
174
190
  return async (args) => {
175
191
  try {
@@ -237,6 +253,12 @@ export function createCorentServer(config = {}) {
237
253
  // deliverable in it (parity audit 2026-08-30).
238
254
  audio: z.array(z.object({ url: z.string() }).passthrough()).optional(),
239
255
  progress_percent: z.number().nullable().optional(),
256
+ // Video jobs (2026-09-12): the PNG of the final frame when return_last_frame
257
+ // was asked for (pass it as the next clip's image_url), the task the model
258
+ // ran (auto | reference | edit | extend) and the ACTUAL clip length.
259
+ last_frame_url: z.string().nullable().optional(),
260
+ task: z.string().nullable().optional(),
261
+ duration_s: z.number().nullable().optional(),
240
262
  meta: mediaMeta.optional(),
241
263
  error: z.string().optional(),
242
264
  };
@@ -332,10 +354,73 @@ export function createCorentServer(config = {}) {
332
354
  headers: { "Idempotency-Key": idempotencyKey() },
333
355
  body: JSON.stringify(args),
334
356
  })));
357
+ // --- Video references (Seedance 2.x, 2026-09-12). The prompt points at
358
+ // them as "@Image 1", "@Video 1", "@Audio 1" in the order given. The API
359
+ // enforces three rules; they are mirrored here so a bad combination is
360
+ // refused before it costs a round trip.
361
+ const VIDEO_ASPECTS = ["16:9", "9:16", "1:1", "adaptive"];
362
+ const VIDEO_TASKS = ["auto", "reference", "edit", "extend"];
363
+ const REAL_FACE_RULE = "The supplier rejects photos of REAL people as references; characters generated inside Corent are accepted.";
364
+ const videoReferenceFields = {
365
+ reference_image_urls: z
366
+ .array(z.string().url())
367
+ .min(1)
368
+ .max(30)
369
+ .optional()
370
+ .describe("1 to 30 public https images the clip should keep to (a character, a product, a setting). Refer to " +
371
+ "them in the prompt as @Image 1, @Image 2 in this order. Needs model " +
372
+ "(corent-seedance-2.5); cannot be combined with image_url/end_image_url. " + REAL_FACE_RULE),
373
+ reference_video_urls: z
374
+ .array(z.string().url())
375
+ .min(1)
376
+ .max(10)
377
+ .optional()
378
+ .describe("1 to 10 public https clips, @Video 1, @Video 2 in the prompt: the source for an edit or extend task, " +
379
+ "or motion/style to follow. Cannot be combined with image_url/end_image_url."),
380
+ reference_audio_urls: z
381
+ .array(z.string().url())
382
+ .min(1)
383
+ .max(10)
384
+ .optional()
385
+ .describe("1 to 10 public https audio files, @Audio 1 in the prompt, e.g. a track the clip should cut to."),
386
+ task: z
387
+ .enum(VIDEO_TASKS)
388
+ .optional()
389
+ .describe("What to do with the references (default auto). edit changes something in @Video 1 and keeps its " +
390
+ "length: omit duration_s. extend continues @Video 1. Both need reference_video_urls and " +
391
+ "aspect_ratio omitted or adaptive. The prompt must say the intent (add, remove, replace, change; " +
392
+ "extend, continue)."),
393
+ return_last_frame: z
394
+ .boolean()
395
+ .optional()
396
+ .describe("true also stores a PNG of the final frame and returns it as last_frame_url on the finished job, so the " +
397
+ "next clip can start exactly there (pass it as image_url). Use it to chain shots."),
398
+ output_format: z.enum(["mp4", "mov"]).optional().describe("Delivered file type (default mp4)."),
399
+ };
400
+ function checkVideoRules(args, where = "") {
401
+ const bad = (message) => {
402
+ throw new CorentApiError(422, { message: `${where}${message}` });
403
+ };
404
+ const frames = Boolean(args.image_url || args.end_image_url);
405
+ const refs = ["reference_image_urls", "reference_video_urls", "reference_audio_urls"].some((k) => Array.isArray(args[k]) && args[k].length > 0);
406
+ const task = args.task ?? "auto";
407
+ if (frames && refs) {
408
+ bad("image_url/end_image_url (first/last frame) cannot be combined with reference_*: pick one way to guide the clip.");
409
+ }
410
+ if ((task === "edit" || task === "extend") && !(Array.isArray(args.reference_video_urls) && args.reference_video_urls.length)) {
411
+ bad(`task ${task} needs at least one reference_video_urls entry: the clip to ${task}.`);
412
+ }
413
+ if (task === "edit" && args.duration_s !== undefined) {
414
+ bad("omit duration_s on an edit task: the edited clip keeps the source clip's length.");
415
+ }
416
+ if ((frames || task === "edit" || task === "extend") && args.aspect_ratio !== undefined && args.aspect_ratio !== "adaptive") {
417
+ bad('aspect_ratio must be omitted or "adaptive" on first/last-frame, edit and extend tasks: the clip keeps the source\'s shape.');
418
+ }
419
+ }
335
420
  server.registerTool("generate_video", {
336
421
  title: "Generate video",
337
422
  _meta: toolMeta("Starting video", "Video job started", true),
338
- description: "Start a video from a text prompt, or animate a source image. Asynchronous: returns a job id at once; poll get_job until status is completed. Costs tens of cents to a few dollars per clip (premium ~52c, pro ~84c). Failed generations are never billed.",
423
+ description: "Start a video from text, a source image, or references (@Image 1, @Video 1, @Audio 1 in the prompt). Asynchronous: returns a job id; poll get_job until completed. Premium ~52c, pro ~84c per clip; failed generations are never billed. References need corent-seedance-2.5; photos of real people are rejected as references.",
339
424
  inputSchema: {
340
425
  prompt: z.string().describe("What to generate, in plain language"),
341
426
  tier: z
@@ -347,15 +432,19 @@ export function createCorentServer(config = {}) {
347
432
  .optional()
348
433
  .describe("Optional content style hint, same vocabulary as generate_image"),
349
434
  aspect_ratio: z
350
- .enum(["16:9", "9:16", "1:1"])
435
+ .enum(VIDEO_ASPECTS)
351
436
  .optional()
352
- .describe("Shape of the clip. OMIT IT when animating a source image: those models render the still's own shape and cannot be given a different one, so naming a shape is refused rather than silently ignored. Omitted for text-to-video means the model's own default (landscape)."),
437
+ .describe("Shape of the clip. OMIT IT (or pass adaptive) when animating a source image, editing or extending a clip: those keep the source's own shape, so naming another is refused rather than silently ignored. Omitted for text-to-video means the model's own default (landscape)."),
353
438
  duration_s: z.number().int().min(1).max(30).optional().describe("Requested duration in seconds"),
354
439
  resolution: z
355
440
  .enum(["720p", "1080p", "4k"])
356
441
  .optional()
357
442
  .describe("Pixel resolution (default 720p). Tier-capped: pro allows up to 1080p, max_pro up to 4k; above the tier's cap it is clamped down, never rejected. Higher resolutions cost more (1080p ~2.5x, 4k ~5.5x). GET /v1/tiers lists each tier's menu with prices."),
358
- image_url: z.string().url().optional().describe("If set, animates this image instead of pure text-to-video"),
443
+ image_url: z
444
+ .string()
445
+ .url()
446
+ .optional()
447
+ .describe("If set, animates this image as the FIRST frame instead of pure text-to-video. Use a previous clip's last_frame_url to continue from it."),
359
448
  audio: z
360
449
  .boolean()
361
450
  .optional()
@@ -383,15 +472,105 @@ export function createCorentServer(config = {}) {
383
472
  model: z
384
473
  .string()
385
474
  .optional()
386
- .describe('Direct model access: pin an exact model by name, e.g. "corent-seedance-2.0" (list_models shows the menu; every name there is spelled corent-*). Bypasses routing -- never substituted; duration and resolution snap to THAT model\'s own menu rather than a tier cap. Mutually exclusive with tier.'),
475
+ .describe('Direct model access: pin an exact model by name, e.g. "corent-seedance-2.5" (list_models shows the menu; every name there is spelled corent-*). Bypasses routing -- never substituted; duration and resolution snap to THAT model\'s own menu rather than a tier cap. Mutually exclusive with tier. list_models reports supports_reference_images, supports_video_edit, supports_video_extend and supports_last_frame per video model.'),
476
+ ...videoReferenceFields,
387
477
  },
388
478
  outputSchema: jobOutput,
389
479
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
390
- }, wrap(async (args) => corent("/v1/videos/generate", {
391
- method: "POST",
392
- headers: { "Idempotency-Key": idempotencyKey() },
393
- body: JSON.stringify(args),
394
- })));
480
+ }, wrap(async (args) => {
481
+ checkVideoRules(args);
482
+ return corent("/v1/videos/generate", {
483
+ method: "POST",
484
+ headers: { "Idempotency-Key": idempotencyKey() },
485
+ body: JSON.stringify(args),
486
+ });
487
+ }));
488
+ // --- Two conveniences over generate_video for the clip-in, clip-out asks.
489
+ // Same endpoint, same Idempotency-Key, same widget; they only set task and
490
+ // put the source clip in reference_video_urls so the agent cannot get the
491
+ // plumbing wrong. ---
492
+ const editExtendShared = {
493
+ reference_image_urls: z
494
+ .array(z.string().url())
495
+ .min(1)
496
+ .max(30)
497
+ .optional()
498
+ .describe("Optional images the result should match (@Image 1, @Image 2 in the prompt), e.g. a character or product to bring in. " +
499
+ REAL_FACE_RULE),
500
+ reference_audio_urls: z
501
+ .array(z.string().url())
502
+ .min(1)
503
+ .max(10)
504
+ .optional()
505
+ .describe("Optional audio (@Audio 1 in the prompt) for the result to follow."),
506
+ model: z
507
+ .string()
508
+ .optional()
509
+ .describe('Model to run, default "corent-seedance-2.5", the model that can edit or extend a clip today.'),
510
+ resolution: z.enum(["720p", "1080p", "4k"]).optional().describe("Pixel resolution (default 720p); higher costs more."),
511
+ audio: z.boolean().optional().describe("Ask for native sound in the result."),
512
+ seed: z.number().int().min(0).optional().describe("Reproducibility, same contract as generate_video."),
513
+ negative_prompt: z.string().optional().describe("What must NOT appear in the result."),
514
+ return_last_frame: z
515
+ .boolean()
516
+ .optional()
517
+ .describe("true returns last_frame_url on the finished job, a PNG of the final frame to start the next clip from."),
518
+ output_format: z.enum(["mp4", "mov"]).optional().describe("Delivered file type (default mp4)."),
519
+ };
520
+ server.registerTool("edit_video", {
521
+ title: "Edit video",
522
+ _meta: toolMeta("Editing video", "Video edit started", true),
523
+ description: "Edit an existing clip with a prompt: add, remove, replace or change something in @Video 1. Keeps the source clip's length and shape. Asynchronous: returns a job id; poll get_job until completed. Runs on corent-seedance-2.5. Photos of real people are rejected as references; Corent-made characters are fine.",
524
+ inputSchema: {
525
+ prompt: z
526
+ .string()
527
+ .min(1)
528
+ .describe('The change, naming the source as @Video 1 and using an edit verb: "Replace the red car in @Video 1 with a blue bicycle".'),
529
+ video_url: z.string().url().describe("Public https URL of the clip to edit (becomes @Video 1)."),
530
+ ...editExtendShared,
531
+ },
532
+ outputSchema: jobOutput,
533
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
534
+ }, wrap(async ({ prompt, video_url, model, ...rest }) => {
535
+ const body = { prompt, task: "edit", reference_video_urls: [video_url], model: model ?? "corent-seedance-2.5", ...rest };
536
+ checkVideoRules(body);
537
+ return corent("/v1/videos/generate", {
538
+ method: "POST",
539
+ headers: { "Idempotency-Key": idempotencyKey() },
540
+ body: JSON.stringify(body),
541
+ });
542
+ }));
543
+ server.registerTool("extend_video", {
544
+ title: "Extend video",
545
+ _meta: toolMeta("Extending video", "Video extension started", true),
546
+ description: "Continue an existing clip (@Video 1) forward or backward, with a prompt for what happens next or before. Keeps the source shape; duration_s is the added length. Asynchronous: returns a job id; poll get_job until completed. Runs on corent-seedance-2.5. Pair with return_last_frame to chain shots.",
547
+ inputSchema: {
548
+ prompt: z.string().min(1).describe("What happens in the new footage. The direction line is added for you."),
549
+ video_url: z.string().url().describe("Public https URL of the clip to continue (becomes @Video 1)."),
550
+ direction: z
551
+ .enum(["forward", "backward"])
552
+ .optional()
553
+ .describe("forward (default) adds footage after the clip; backward adds what happened before it."),
554
+ duration_s: z.number().int().min(1).max(30).optional().describe("Seconds of NEW footage to add."),
555
+ ...editExtendShared,
556
+ },
557
+ outputSchema: jobOutput,
558
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
559
+ }, wrap(async ({ prompt, video_url, direction, model, ...rest }) => {
560
+ const body = {
561
+ prompt: `Extend @Video 1 ${direction ?? "forward"}. ${prompt}`,
562
+ task: "extend",
563
+ reference_video_urls: [video_url],
564
+ model: model ?? "corent-seedance-2.5",
565
+ ...rest,
566
+ };
567
+ checkVideoRules(body);
568
+ return corent("/v1/videos/generate", {
569
+ method: "POST",
570
+ headers: { "Idempotency-Key": idempotencyKey() },
571
+ body: JSON.stringify(body),
572
+ });
573
+ }));
395
574
  server.registerTool("generate_speech", {
396
575
  title: "Generate speech (voice)",
397
576
  _meta: toolMeta("Generating speech", "Speech ready"),
@@ -644,22 +823,31 @@ export function createCorentServer(config = {}) {
644
823
  .array(z.object({
645
824
  prompt: z.string(),
646
825
  tier: z.enum(TIERS).optional(),
826
+ model: z.string().optional().describe("Pin a model, e.g. corent-seedance-2.5 for reference/edit/extend items."),
647
827
  style: z.enum(IMAGE_STYLES).optional(),
648
- aspect_ratio: z.enum(["16:9", "9:16", "1:1"]).optional(),
828
+ aspect_ratio: z.enum(VIDEO_ASPECTS).optional(),
649
829
  duration_s: z.number().int().min(1).max(30).optional(),
650
830
  resolution: z.enum(["720p", "1080p", "4k"]).optional(),
831
+ image_url: z.string().url().optional(),
832
+ end_image_url: z.string().url().optional(),
833
+ audio: z.boolean().optional(),
834
+ seed: z.number().int().min(0).optional(),
835
+ ...videoReferenceFields,
651
836
  }))
652
837
  .min(1)
653
838
  .max(50)
654
- .describe("1 to 50 video requests"),
839
+ .describe("1 to 50 video requests; each item takes the same reference fields as generate_video"),
655
840
  },
656
841
  outputSchema: batchOutput,
657
842
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
658
- }, wrap(async ({ items }) => corent("/v1/videos/generate/batch", {
659
- method: "POST",
660
- headers: { "Idempotency-Key": idempotencyKey() },
661
- body: JSON.stringify({ items }),
662
- })));
843
+ }, wrap(async ({ items }) => {
844
+ items.forEach((item, i) => checkVideoRules(item, `items[${i}]: `));
845
+ return corent("/v1/videos/generate/batch", {
846
+ method: "POST",
847
+ headers: { "Idempotency-Key": idempotencyKey() },
848
+ body: JSON.stringify({ items }),
849
+ });
850
+ }));
663
851
  server.registerTool("get_batch", {
664
852
  title: "Check batch progress",
665
853
  _meta: toolMeta("Checking batch", "Batch checked"),
@@ -715,7 +903,7 @@ export function createCorentServer(config = {}) {
715
903
  server.registerTool("plan", {
716
904
  title: "Plan (cost preview, no generation)",
717
905
  _meta: toolMeta("Planning", "Plan ready"),
718
- description: "Preview how Corent would handle a plain-language request without generating anything (a fraction of a cent). Returns image vs video, tier, aspect ratio, duration and an estimated cost. Covers image and video only; for speech call generate_speech. Impossible asks come back with can_fulfill false and a reason.",
906
+ description: "Preview how Corent would handle a plain-language request, without generating: image vs video, tier, shape, duration and an estimated cost. Image and video only; speech is generate_speech. A story with several characters or anything over about 30 seconds is a film: use plan_film. Impossible asks come back with can_fulfill false.",
719
907
  inputSchema: {
720
908
  intent: z
721
909
  .string()
@@ -724,13 +912,14 @@ export function createCorentServer(config = {}) {
724
912
  outputSchema: {
725
913
  plan: z.object({ can_fulfill: z.boolean().optional() }).passthrough().optional(),
726
914
  estimated_cost_cents: z.number().nullable().optional(),
915
+ next_step: z.string().nullable().optional(),
727
916
  },
728
917
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
729
- }, wrap(async ({ intent }) => corent("/v1/intent", { method: "POST", body: JSON.stringify({ intent }) })));
918
+ }, wrap(async ({ intent }) => pointFilmsToPlanFilm(await corent("/v1/intent", { method: "POST", body: JSON.stringify({ intent }) }))));
730
919
  server.registerTool("create", {
731
920
  title: "Create (plan + generate, budget-capped)",
732
921
  _meta: toolMeta("Creating", "Created", true),
733
- description: "Describe what you want in plain language; Corent plans and generates it, choosing image vs video, tier, shape and duration. max_cost_cents is a required spend ceiling: above it nothing is generated and you are told the estimate. Images return a URL; videos return a job id for get_job. Failed generations are never billed.",
922
+ description: "Describe one image or one clip in plain language; Corent picks type, tier, shape and duration and generates it. max_cost_cents is a required ceiling: above it nothing is generated. Videos return a job id for get_job. Failed generations are never billed. A story with several characters or anything over about 30 seconds is a film: use plan_film, then make_film.",
734
923
  inputSchema: {
735
924
  intent: z.string().describe("What you want, in natural language"),
736
925
  max_cost_cents: z
@@ -745,6 +934,7 @@ export function createCorentServer(config = {}) {
745
934
  actual_cost_cents: z.number().nullable().optional(),
746
935
  result: z.object({}).passthrough().nullable().optional(),
747
936
  message: z.string().nullable().optional(),
937
+ next_step: z.string().nullable().optional(),
748
938
  },
749
939
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
750
940
  }, wrap(async ({ intent, max_cost_cents }) => {
@@ -758,8 +948,142 @@ export function createCorentServer(config = {}) {
758
948
  if (res && typeof res.message === "string") {
759
949
  res.message = res.message.replace("confirm_estimated_cost_cents_up_to", "max_cost_cents");
760
950
  }
761
- return res;
951
+ return pointFilmsToPlanFilm(res);
952
+ }));
953
+ // --- Films (v0.10.0): one sentence in, one finished video with sound out.
954
+ // Corent writes the script, creates the characters once, shoots every scene
955
+ // with them, checks and reshoots, and joins the scenes. plan_film is free and
956
+ // shows the price; make_film spends, capped by max_cost_cents; a failed or
957
+ // cancelled film is not billed. ---
958
+ const FILM_ASPECTS = ["16:9", "9:16", "1:1"];
959
+ const FILM_RESOLUTIONS = ["720p", "1080p"];
960
+ const filmOptions = {
961
+ duration_s: z.number().int().min(10).max(120).optional().describe("Length of the finished film in seconds, 10 to 120 (default 30)."),
962
+ aspect_ratio: z.enum(FILM_ASPECTS).optional().describe("16:9 (default), 9:16 for vertical, or 1:1."),
963
+ resolution: z.enum(FILM_RESOLUTIONS).optional().describe("720p (default) or 1080p; 1080p costs more."),
964
+ language: z.string().optional().describe('Language the characters speak, e.g. "en" or "es". Omit to follow the brief.'),
965
+ customer_id: z.string().min(1).max(128).optional().describe("Your own id for the end customer this film is for."),
966
+ };
967
+ const filmCharacter = z
968
+ .object({
969
+ id: z.string().optional(),
970
+ name: z.string().nullable().optional(),
971
+ description: z.string().nullable().optional(),
972
+ image_url: z.string().nullable().optional(),
973
+ })
974
+ .passthrough();
975
+ const filmScene = z
976
+ .object({
977
+ index: z.number().optional(),
978
+ duration_s: z.number().nullable().optional(),
979
+ setting: z.string().nullable().optional(),
980
+ action: z.string().nullable().optional(),
981
+ camera: z.string().nullable().optional(),
982
+ characters: z.array(z.string()).nullable().optional(),
983
+ dialogue: z.array(z.object({ character_id: z.string().optional(), line: z.string().optional() }).passthrough()).nullable().optional(),
984
+ status: z.string().nullable().optional(),
985
+ video_url: z.string().nullable().optional(),
986
+ last_frame_url: z.string().nullable().optional(),
987
+ reshoots: z.number().nullable().optional(),
988
+ })
989
+ .passthrough();
990
+ const filmOutput = {
991
+ film_id: z.string().optional(),
992
+ status: z.string().optional(),
993
+ progress_percent: z.number().nullable().optional(),
994
+ stage_detail: z.string().nullable().optional(),
995
+ title: z.string().nullable().optional(),
996
+ characters: z.array(filmCharacter).nullable().optional(),
997
+ scenes: z.array(filmScene).nullable().optional(),
998
+ video_url: z.string().nullable().optional(),
999
+ duration_s: z.number().nullable().optional(),
1000
+ estimated_cost_cents: z.number().nullable().optional(),
1001
+ cost_cents: z.number().nullable().optional(),
1002
+ billed: z.boolean().nullable().optional(),
1003
+ quality_notes: z.array(z.string()).nullable().optional(),
1004
+ error: z.string().nullable().optional(),
1005
+ error_message: z.string().nullable().optional(),
1006
+ created_at: z.string().nullable().optional(),
1007
+ completed_at: z.string().nullable().optional(),
1008
+ customer_id: z.string().nullable().optional(),
1009
+ };
1010
+ server.registerTool("plan_film", {
1011
+ title: "Plan a film (script and price, free)",
1012
+ _meta: toolMeta("Writing the film plan", "Film plan ready"),
1013
+ description: "Turn a one-sentence film idea into a plan without spending anything: title, characters, every scene with its dialogue, total length and the price range. The plan lasts 24 hours. Show the user the plan and the price and get a clear yes before make_film. Use it for any story with several characters or anything over about 30 seconds.",
1014
+ inputSchema: {
1015
+ brief: z
1016
+ .string()
1017
+ .min(1)
1018
+ .max(2000)
1019
+ .describe("The film in the user's own words, e.g. 'a 50 second parody where 5 founders start a startup and know nothing'."),
1020
+ ...filmOptions,
1021
+ },
1022
+ outputSchema: {
1023
+ plan_id: z.string().optional(),
1024
+ title: z.string().nullable().optional(),
1025
+ logline: z.string().nullable().optional(),
1026
+ style: z.string().nullable().optional(),
1027
+ characters: z.array(filmCharacter).nullable().optional(),
1028
+ scenes: z.array(filmScene).nullable().optional(),
1029
+ total_duration_s: z.number().nullable().optional(),
1030
+ estimated_cost_cents: z.number().nullable().optional(),
1031
+ estimated_cost_range: z.object({ min: z.number().optional(), max: z.number().optional() }).passthrough().nullable().optional(),
1032
+ expires_at: z.string().nullable().optional(),
1033
+ },
1034
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1035
+ }, wrap(async (args) => corent("/v1/films/plan", { method: "POST", body: JSON.stringify(args) })));
1036
+ server.registerTool("make_film", {
1037
+ title: "Make a film (budget-capped)",
1038
+ _meta: toolMeta("Starting film", "Film started", true),
1039
+ description: "Start the film the user approved: pass plan_id from plan_film (or a brief), and max_cost_cents a little above the estimate. The charge never goes above max_cost_cents, and a failed or cancelled film is not billed. Returns film_id right away; a film takes about 15 to 30 minutes, so check get_film every 60 seconds.",
1040
+ inputSchema: {
1041
+ plan_id: z.string().optional().describe("The plan_id from plan_film. Preferred: it is the plan and price the user saw."),
1042
+ brief: z.string().min(1).max(2000).optional().describe("Only when there is no plan_id: the film idea, planned and made in one go."),
1043
+ ...filmOptions,
1044
+ max_cost_cents: z
1045
+ .number()
1046
+ .int()
1047
+ .min(1)
1048
+ .describe("Spend ceiling in cents. Refused up front if the estimate is higher; the charge never goes above it."),
1049
+ webhook_url: z.string().url().optional().describe("Optional https URL that receives the finished film."),
1050
+ webhook_secret: z.string().optional().describe("Optional secret that signs the webhook delivery."),
1051
+ },
1052
+ outputSchema: filmOutput,
1053
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1054
+ }, wrap(async ({ plan_id, brief, duration_s, aspect_ratio, resolution, language, ...rest }) => {
1055
+ if (!plan_id && !brief) {
1056
+ throw new CorentApiError(422, { message: "Pass plan_id from plan_film, or a brief." });
1057
+ }
1058
+ const options = { duration_s, aspect_ratio, resolution, language };
1059
+ if (plan_id && (brief || Object.values(options).some((v) => v !== undefined))) {
1060
+ throw new CorentApiError(422, {
1061
+ message: "A plan already fixes the story, length, shape and resolution: pass plan_id on its own, or call plan_film again with the new options.",
1062
+ });
1063
+ }
1064
+ const body = plan_id ? { plan_id, ...rest } : { brief, ...options, ...rest };
1065
+ return corent("/v1/films", {
1066
+ method: "POST",
1067
+ headers: { "Idempotency-Key": idempotencyKey() },
1068
+ body: JSON.stringify(body),
1069
+ });
762
1070
  }));
1071
+ server.registerTool("get_film", {
1072
+ title: "Check film progress",
1073
+ _meta: toolMeta("Checking film", "Film checked", true),
1074
+ description: "Progress of a film from make_film: the stage (queued, writing, casting, shooting, checking, editing, completed, failed, cancelled), progress_percent, and each scene. When completed it carries video_url, the finished film with sound, and cost_cents. Read-only and free. Tell the user the stage in one plain sentence.",
1075
+ inputSchema: { film_id: z.string().min(1).describe("The film_id returned by make_film") },
1076
+ outputSchema: filmOutput,
1077
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1078
+ }, wrap(async ({ film_id }) => corent(`/v1/films/${encodeURIComponent(film_id)}`)));
1079
+ server.registerTool("cancel_film", {
1080
+ title: "Cancel a film",
1081
+ _meta: toolMeta("Cancelling film", "Film cancelled"),
1082
+ description: "Stop a film that has not finished. A cancelled film is not billed. Use it as soon as the user says they did not mean to start it or want to change the story. A film that already finished stays completed.",
1083
+ inputSchema: { film_id: z.string().min(1).describe("The film_id to cancel") },
1084
+ outputSchema: filmOutput,
1085
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
1086
+ }, wrap(async ({ film_id }) => corent(`/v1/films/${encodeURIComponent(film_id)}/cancel`, { method: "POST" })));
763
1087
  // --- Image tools (v0.8.0): edit what already exists instead of generating
764
1088
  // anew. All three are synchronous, flat-priced, and replay-safe under an
765
1089
  // Idempotency-Key exactly like generate_image. ---