@shop-os/modelkit 0.2.26 → 0.3.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/CHANGELOG.md CHANGED
@@ -9,6 +9,30 @@ header in the release PR.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.3.0] - Unreleased
13
+
14
+ ### Added
15
+ - Row type `video-tool`: video in, video out, no prompt. `constraints.tool` says what the row does (`kind: "upscale" | "interpolate" | "hdr"`) and its limits (`upscaleFactor`, `maxOutputLongEdge`, `fps`, `outputFormats`). Rows `topaz-precision` (Proteus) and `topaz-starlight` (Starlight Precise 2.6) upscale up to 4x with a 3840 px long edge, `topaz-apollo` interpolates to 16-120 fps, and `topaz-hyperion-2.5` writes an HDR master in H.265 (HDR10) or ProRes. All four run on fal, cap the source at 300 s, and are listed on `cowork` only.
16
+ - `kit.submitVideoTool(req, { webhookUrl })` submits to fal's queue (with `fal_webhook`) and returns a JSON `VideoToolSubmission`; `videoToolStatus`, `videoToolResult` (from a verified webhook body or the stored result) and `cancelVideoTool` take it back from any process. Measured on 2026-10-02: a 30 s 1080p-to-4K Starlight job took 2,593 s and a 30 s 4K SDR to HDR job 6,695 s, so callers must not wait inside one task. Submits are one attempt: a retry could start a second paid job. Stored URLs outside `https://queue.fal.run` are refused before the key is sent.
17
+ - `planVideoTool(row, req)` (also on `@shop-os/modelkit/catalog`): the output size, fps, resolution tier and format a request produces, or why the row refuses it. `resolutionTier(width, height)` tiers by the short edge. `UsageEvent.fps` carries the output frame rate.
18
+ - `falWebhookBodySchema` parses fal's webhook body.
19
+ - `capabilities.videoEdit` marks the rows offered for prompt-driven edits: `true` on `seedance-2.5`, `gemini-omni-flash`, `gemini-omni-1.1-flash`; `false` on `seedance-2.0` and `seedance-2.0-fast`, which take a source video but are not offered for edits. A row without the flag is read as `acceptsSourceVideo` by Studio.
20
+ - `constraints.references.audioSeconds` (`{ min?, max?, total? }`), `audioFormats` (lowercase extensions) and `audioMaxBytes` describe reference audio. Set from BytePlus's docs on `seedance-2.5` (2-30 s per clip, 30 s in all) and `seedance-2.0` / `seedance-2.0-fast` (2-15 s per clip, 15 s in all), all `wav`/`mp3` up to 15 MB.
21
+ - `constraints.extension.returns`: `"joined"` (absent, Omni) returns the whole clip so far; `"new"` returns only the added footage. `seedance-2.5` gains `videoExtension` and `extension: { segmentSeconds: 30, maxTotalSeconds: 60, returns: "new" }`; a live extend on 2026-10-02 returned only the 4 s it added, at the requested resolution.
22
+
23
+ ### Live rows to add or edit after publish
24
+ Older kits drop a `video-tool` row (unknown type) with a warning and keep loading the catalog. Pro-backend's admin API validates rows with this kit, so it must run 0.3.0 before the rows can be saved. In each environment, once pro-backend and cowork run 0.3.0 and the prices exist:
25
+ - Add `topaz-precision`, `topaz-starlight`, `topaz-apollo` and `topaz-hyperion-2.5` as bundled.
26
+ - `seedance-2.5`: add `videoExtension` to `constraints.supports`, set `constraints.extension` to `{ "segmentSeconds": 30, "maxTotalSeconds": 60, "returns": "new" }` and `capabilities.videoEdit` to `true`.
27
+ - `gemini-omni-flash`, `gemini-omni-1.1-flash`: set `capabilities.videoEdit` to `true`; on `gemini-omni-1.1-flash` add `"returns": "joined"` to `constraints.extension`.
28
+ - `seedance-2.0`, `seedance-2.0-fast`: set `capabilities.videoEdit` to `false`, and add `"audioSeconds": { "min": 2, "max": 15, "total": 15 }, "audioFormats": ["wav", "mp3"], "audioMaxBytes": 15728640` to `constraints.references`.
29
+ - `seedance-2.5`: add `"audioSeconds": { "min": 2, "max": 30, "total": 30 }, "audioFormats": ["wav", "mp3"], "audioMaxBytes": 15728640` to `constraints.references`.
30
+
31
+ ## [0.2.27] - Unreleased
32
+
33
+ ### Fixed
34
+ - A `sloosh` call never goes through the Cloudflare AI Gateway. With `routing.fal: "cloudflare-passthrough"` or `routing.text: "cloudflare"` the gateway's BYOK secrets are the pro provider keys, so a sloosh call through it spent the pro account and, because the tier had no gateway token, failed first with `auth` ("Missing Sloosh API key for cloudflare"). The kit now resolves the routing per tier: sloosh calls the provider directly with `keys.sloosh` (`fal`, or the vendor key); pro and enterprise keep the gateway (ENG-2990).
35
+
12
36
  ## [0.2.26] - Unreleased
13
37
 
14
38
  ### Added
package/README.md CHANGED
@@ -51,6 +51,25 @@ await kit.generateText({
51
51
  await clip.cleanup?.();
52
52
  ```
53
53
 
54
+ ### Video tools
55
+
56
+ `video-tool` rows (Topaz upscale, frame interpolation, SDR to HDR on fal) take a clip and
57
+ return a clip with no prompt. They run as provider jobs the caller tracks, because a 30 s
58
+ 4K job can take over 40 minutes:
59
+
60
+ ```ts
61
+ const plan = planVideoTool(kit.describe("topaz-precision"), { source, upscaleFactor: 2 }); // validate + price
62
+ const sub = await kit.submitVideoTool(
63
+ { model: "topaz-precision", source, upscaleFactor: 2 },
64
+ { webhookUrl }, // fal posts here when the job ends
65
+ );
66
+ // store `sub` (plain JSON); later, in any process:
67
+ const out = await kit.videoToolResult(sub, { webhook: verifiedBody }); // or poll videoToolStatus(sub)
68
+ ```
69
+
70
+ `source` carries the caller's measured `width`, `height`, `durationSeconds` and `fps`; the kit
71
+ never measures. Verifying the webhook signature is the caller's job.
72
+
54
73
  Keys come from `process.env` by default: bare names for pro (`OPENAI_API_KEY`, `FAL_KEY`, `ARK_API_KEY` ...), the `ENTERPRISE_` prefix for enterprise and the `SLOOSH_` prefix for sloosh. Pass `keys: { pro, enterprise, sloosh }` to override. `tier: "sloosh"` uses the sloosh keys only: a missing key is a `ModelKitError` with code `auth`, never a fallback to pro.
55
74
 
56
75
  ## Browser-safe catalog
@@ -252,7 +271,7 @@ const defaultKit = createDefaultModelKit({ keys: { pro: keysFromEnv(process.env)
252
271
 
253
272
  ## Add a model
254
273
 
255
- 1. Add a row to `src/catalog/{image,video,text,other}.ts`. Copy `constraints` from the provider docs.
274
+ 1. Add a row to `src/catalog/{image,video,video-tools,text,other}.ts`. Copy `constraints` from the provider docs.
256
275
  2. Run `pnpm test`. The catalog test checks id and alias uniqueness.
257
276
  3. Bump `version` in `package.json` in a PR that touches only `packages/modelkit/**`.
258
277
  Merging it to `dev` runs `modelkit-publish.yml`, which publishes through the npm
package/dist/catalog.cjs CHANGED
@@ -36,6 +36,7 @@ __export(catalog_entry_exports, {
36
36
  SurfaceSchema: () => SurfaceSchema,
37
37
  TEXT_MODELS: () => TEXT_MODELS,
38
38
  VIDEO_MODELS: () => VIDEO_MODELS,
39
+ VIDEO_TOOL_MODELS: () => VIDEO_TOOL_MODELS,
39
40
  billedAudio: () => billedAudio,
40
41
  compareCatalogOrder: () => compareCatalogOrder,
41
42
  isListed: () => isListed,
@@ -46,15 +47,18 @@ __export(catalog_entry_exports, {
46
47
  nearestAspectRatio: () => nearestAspectRatio,
47
48
  normalizeExposure: () => normalizeExposure,
48
49
  outputAudio: () => outputAudio,
50
+ planVideoTool: () => planVideoTool,
49
51
  primeCatalog: () => primeCatalog,
50
52
  promptConventions: () => promptConventions,
51
53
  resetLiveCatalog: () => resetLiveCatalog,
54
+ resolutionTier: () => resolutionTier,
52
55
  resolveAudioOptions: () => resolveAudioOptions,
53
56
  resolveImageOptions: () => resolveImageOptions,
54
57
  resolveOptions: () => resolveOptions,
55
58
  resolveVideoOptions: () => resolveVideoOptions,
56
59
  sortCatalogRows: () => sortCatalogRows,
57
- videoLengthViolation: () => videoLengthViolation
60
+ videoLengthViolation: () => videoLengthViolation,
61
+ videoToolConstraints: () => videoToolConstraints
58
62
  });
59
63
  module.exports = __toCommonJS(catalog_entry_exports);
60
64
 
@@ -1353,6 +1357,11 @@ var NEGATIVE_PROMPT = "blur, distort, and low quality";
1353
1357
  var FRAMES_EXCLUDE_REFERENCES = { framesExcludeReferences: true };
1354
1358
  var KLING_PROMPT = { referenceSyntax: "numbered", maxChars: 2500 };
1355
1359
  var SEEDANCE_PROMPT = { referenceSyntax: "numbered", maxChars: 3e4 };
1360
+ var SEEDANCE_2_AUDIO = {
1361
+ audioSeconds: { min: 2, max: 15, total: 15 },
1362
+ audioFormats: ["wav", "mp3"],
1363
+ audioMaxBytes: 15 * 1024 * 1024
1364
+ };
1356
1365
  var H3_PROMPT = { referenceSyntax: "numbered", maxChars: 5e4 };
1357
1366
  var OMNI_PROMPT = { referenceSyntax: "caption", maxChars: 5e4 };
1358
1367
  var BYTEDANCE_LOGO = "https://d31vvfpk2ppdyq.cloudfront.net/assets/image/3bba5d81-4e57-4e71-911b-cdfa05acca2c_compressed.png";
@@ -1489,7 +1498,7 @@ var VIDEO_MODELS = [
1489
1498
  description: "Google's conversational video model with native audio, human support, and natural language editing. Aspect ratio only \u2014 resolution and duration are model-determined.",
1490
1499
  logo: GOOGLE_LOGO2
1491
1500
  },
1492
- capabilities: { acceptsSourceVideo: true }
1501
+ capabilities: { acceptsSourceVideo: true, videoEdit: true }
1493
1502
  },
1494
1503
  {
1495
1504
  id: "gemini-omni-1.1-flash",
@@ -1514,7 +1523,7 @@ var VIDEO_MODELS = [
1514
1523
  // with a video task, so those calls carry the prompt alone).
1515
1524
  // First-to-last-frame interpolation: `<LAST_FRAME>` goes with `<FIRST_FRAME>`.
1516
1525
  supports: ["videoExtension", "lastFrame"],
1517
- extension: { segmentSeconds: 10, maxTotalSeconds: 40 },
1526
+ extension: { segmentSeconds: 10, maxTotalSeconds: 40, returns: "joined" },
1518
1527
  prompt: OMNI_PROMPT,
1519
1528
  audio: "always"
1520
1529
  },
@@ -1526,7 +1535,11 @@ var VIDEO_MODELS = [
1526
1535
  logo: GOOGLE_LOGO2,
1527
1536
  description: "Google Gemini Omni Flash 1.1 conversational video generation, editing and extension; text, image, and up to 10-second video input, 3-10 second output at up to 4K and 24 FPS, extendable to 40 seconds."
1528
1537
  },
1529
- capabilities: { acceptsSourceVideo: true, lastFrameRequiresFirstFrame: true }
1538
+ capabilities: {
1539
+ acceptsSourceVideo: true,
1540
+ videoEdit: true,
1541
+ lastFrameRequiresFirstFrame: true
1542
+ }
1530
1543
  },
1531
1544
  {
1532
1545
  // vibe-triggers and Space Builder drop the ratio when a frame is wired; Cowork always sends it.
@@ -1548,7 +1561,8 @@ var VIDEO_MODELS = [
1548
1561
  videos: 3,
1549
1562
  audios: 3,
1550
1563
  audioOnly: false,
1551
- videoSeconds: { min: 2, max: 15, total: 15 }
1564
+ videoSeconds: { min: 2, max: 15, total: 15 },
1565
+ ...SEEDANCE_2_AUDIO
1552
1566
  },
1553
1567
  sourceVideoSeconds: { min: 2, max: 15, inReferenceTotal: true },
1554
1568
  ratioIgnoredWith: ["firstFrame", "sourceVideo"],
@@ -1570,7 +1584,7 @@ var VIDEO_MODELS = [
1570
1584
  description: "High-quality AI video generation with reference image conditioning. Supports 4-15 second clips with human preprocessing.",
1571
1585
  logo: BYTEDANCE_LOGO
1572
1586
  },
1573
- capabilities: { acceptsSourceVideo: true, ...FRAMES_EXCLUDE_REFERENCES }
1587
+ capabilities: { acceptsSourceVideo: true, videoEdit: false, ...FRAMES_EXCLUDE_REFERENCES }
1574
1588
  },
1575
1589
  {
1576
1590
  id: "seedance-2.0-fast",
@@ -1590,7 +1604,8 @@ var VIDEO_MODELS = [
1590
1604
  videos: 3,
1591
1605
  audios: 3,
1592
1606
  audioOnly: false,
1593
- videoSeconds: { min: 2, max: 15, total: 15 }
1607
+ videoSeconds: { min: 2, max: 15, total: 15 },
1608
+ ...SEEDANCE_2_AUDIO
1594
1609
  },
1595
1610
  sourceVideoSeconds: { min: 2, max: 15, inReferenceTotal: true },
1596
1611
  ratioIgnoredWith: ["firstFrame", "sourceVideo"],
@@ -1612,7 +1627,7 @@ var VIDEO_MODELS = [
1612
1627
  logo: BYTEDANCE_LOGO,
1613
1628
  description: "Speed- and cost-optimized ByteDance Seedance 2.0 Fast; text plus image, video, and audio references to generate or edit 4-15 second clips."
1614
1629
  },
1615
- capabilities: { acceptsSourceVideo: true, ...FRAMES_EXCLUDE_REFERENCES }
1630
+ capabilities: { acceptsSourceVideo: true, videoEdit: false, ...FRAMES_EXCLUDE_REFERENCES }
1616
1631
  },
1617
1632
  {
1618
1633
  // omni_reference_task_type edit/extend: adaptive ratio, edit sends duration -1.
@@ -1632,12 +1647,20 @@ var VIDEO_MODELS = [
1632
1647
  images: 30,
1633
1648
  videos: 10,
1634
1649
  audios: 10,
1635
- videoSeconds: { min: 2, max: 30, total: 30, editMin: 4 }
1650
+ videoSeconds: { min: 2, max: 30, total: 30, editMin: 4 },
1651
+ // BytePlus: 2-30 s per clip, 30 s in all, wav/mp3, 15 MB each.
1652
+ audioSeconds: { min: 2, max: 30, total: 30 },
1653
+ audioFormats: ["wav", "mp3"],
1654
+ audioMaxBytes: 15 * 1024 * 1024
1636
1655
  },
1637
1656
  sourceVideoSeconds: { min: 2, max: 30, editMin: 4, inReferenceTotal: true },
1638
1657
  ratioIgnoredWith: ["firstFrame", "sourceVideo"],
1639
1658
  // No seed: 2.x echoes it but does not honour it (two seeded runs differed, 2026-10-01).
1640
- supports: ["generateAudio", "lastFrame", "watermark", "taskType"],
1659
+ supports: ["generateAudio", "lastFrame", "watermark", "taskType", "videoExtension"],
1660
+ // Extend (probe 2026-10-02, 480p): returns ONLY the added footage, continuing from
1661
+ // the source's last frame at the source's framing and fps but at the requested
1662
+ // resolution, so the caller joins the clips. Forward only in practice.
1663
+ extension: { segmentSeconds: 30, maxTotalSeconds: 60, returns: "new" },
1641
1664
  prompt: SEEDANCE_PROMPT,
1642
1665
  audio: "optional"
1643
1666
  },
@@ -1654,7 +1677,7 @@ var VIDEO_MODELS = [
1654
1677
  logo: BYTEDANCE_LOGO,
1655
1678
  description: "ByteDance Seedance 2.5 for long-form storytelling and omni-reference generation/editing; up to 50 reference assets and 4-30 second clips at up to 1080p."
1656
1679
  },
1657
- capabilities: { acceptsSourceVideo: true, ...FRAMES_EXCLUDE_REFERENCES }
1680
+ capabilities: { acceptsSourceVideo: true, videoEdit: true, ...FRAMES_EXCLUDE_REFERENCES }
1658
1681
  },
1659
1682
  {
1660
1683
  id: "grok-imagine",
@@ -2018,11 +2041,116 @@ var VIDEO_MODELS = [
2018
2041
  }
2019
2042
  ];
2020
2043
 
2044
+ // src/catalog/video-tools.ts
2045
+ var TOPAZ_LOGO = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.97.0/light/topazlabs.png";
2046
+ var TOPAZ_SOURCE_SECONDS = { max: 300 };
2047
+ var TOPAZ_USAGE = { unit: "seconds" };
2048
+ var VIDEO_TOOL_MODELS = [
2049
+ {
2050
+ id: "topaz-precision",
2051
+ aliases: [],
2052
+ label: "Topaz Precision",
2053
+ type: "video-tool",
2054
+ adapter: "fal",
2055
+ providerModelId: "topaz/upscale/video/precision",
2056
+ lifecycle: "active",
2057
+ exposure: ["cowork"],
2058
+ constraints: {
2059
+ supports: [],
2060
+ sourceVideoSeconds: TOPAZ_SOURCE_SECONDS,
2061
+ tool: { kind: "upscale", upscaleFactor: { min: 1, max: 4 }, maxOutputLongEdge: 3840 }
2062
+ },
2063
+ defaults: {},
2064
+ providerOptions: { fal: { model: "Proteus" } },
2065
+ usage: TOPAZ_USAGE,
2066
+ pricingKind: "video_per_second",
2067
+ display: {
2068
+ provider: "topaz",
2069
+ logo: TOPAZ_LOGO,
2070
+ description: "Topaz Proteus upscaling: sharper, cleaner footage up to 4K while staying faithful to the source.",
2071
+ sortOrder: 20
2072
+ }
2073
+ },
2074
+ {
2075
+ id: "topaz-starlight",
2076
+ aliases: [],
2077
+ label: "Topaz Starlight",
2078
+ type: "video-tool",
2079
+ adapter: "fal",
2080
+ providerModelId: "topaz/upscale/video/generative",
2081
+ lifecycle: "active",
2082
+ exposure: ["cowork"],
2083
+ constraints: {
2084
+ supports: [],
2085
+ sourceVideoSeconds: TOPAZ_SOURCE_SECONDS,
2086
+ tool: { kind: "upscale", upscaleFactor: { min: 1, max: 4 }, maxOutputLongEdge: 3840 }
2087
+ },
2088
+ defaults: {},
2089
+ providerOptions: { fal: { model: "Starlight Precise 2.6" } },
2090
+ usage: TOPAZ_USAGE,
2091
+ pricingKind: "video_per_second",
2092
+ display: {
2093
+ provider: "topaz",
2094
+ logo: TOPAZ_LOGO,
2095
+ description: "Topaz Starlight Precise 2.6: generative upscaling that rebuilds fine detail, up to 4K. Slower and costlier than Precision.",
2096
+ sortOrder: 10
2097
+ }
2098
+ },
2099
+ {
2100
+ id: "topaz-apollo",
2101
+ aliases: [],
2102
+ label: "Topaz Apollo",
2103
+ type: "video-tool",
2104
+ adapter: "fal",
2105
+ providerModelId: "topaz/interpolate/video",
2106
+ lifecycle: "active",
2107
+ exposure: ["cowork"],
2108
+ constraints: {
2109
+ supports: [],
2110
+ sourceVideoSeconds: TOPAZ_SOURCE_SECONDS,
2111
+ tool: { kind: "interpolate", fps: { min: 16, max: 120 } }
2112
+ },
2113
+ defaults: {},
2114
+ providerOptions: { fal: { model: "Apollo" } },
2115
+ usage: TOPAZ_USAGE,
2116
+ pricingKind: "video_per_second",
2117
+ display: {
2118
+ provider: "topaz",
2119
+ logo: TOPAZ_LOGO,
2120
+ description: "Topaz Apollo frame interpolation: smoother motion at up to 120 fps, same length as the source."
2121
+ }
2122
+ },
2123
+ {
2124
+ id: "topaz-hyperion-2.5",
2125
+ aliases: [],
2126
+ label: "Topaz Hyperion 2.5",
2127
+ type: "video-tool",
2128
+ adapter: "fal",
2129
+ providerModelId: "topaz/sdr-to-hdr/video",
2130
+ lifecycle: "active",
2131
+ exposure: ["cowork"],
2132
+ constraints: {
2133
+ supports: [],
2134
+ sourceVideoSeconds: TOPAZ_SOURCE_SECONDS,
2135
+ tool: { kind: "hdr", outputFormats: ["h265", "prores"] }
2136
+ },
2137
+ defaults: {},
2138
+ usage: TOPAZ_USAGE,
2139
+ pricingKind: "video_per_second",
2140
+ display: {
2141
+ provider: "topaz",
2142
+ logo: TOPAZ_LOGO,
2143
+ description: "Topaz Hyperion 2.5 SDR to HDR: a 10-bit HDR master in H.265 (HDR10) or ProRes 422 HQ, at the source's size and frame rate."
2144
+ }
2145
+ }
2146
+ ];
2147
+
2021
2148
  // src/catalog/models.ts
2022
2149
  var MODELS = [
2023
2150
  ...TEXT_MODELS,
2024
2151
  ...IMAGE_MODELS,
2025
2152
  ...VIDEO_MODELS,
2153
+ ...VIDEO_TOOL_MODELS,
2026
2154
  ...OTHER_MODELS
2027
2155
  ];
2028
2156
 
@@ -2328,7 +2456,15 @@ var ADAPTER_IDS = [
2328
2456
  "higgsfield",
2329
2457
  "xai-video"
2330
2458
  ];
2331
- var ModelTypeSchema = import_zod3.z.enum(["text", "image", "video", "audio", "upscale", "bg-remove"]);
2459
+ var ModelTypeSchema = import_zod3.z.enum([
2460
+ "text",
2461
+ "image",
2462
+ "video",
2463
+ "audio",
2464
+ "upscale",
2465
+ "bg-remove",
2466
+ "video-tool"
2467
+ ]);
2332
2468
  var LifecycleSchema = import_zod3.z.enum(["active", "deprecated", "retired"]);
2333
2469
  var SurfaceSchema = import_zod3.z.enum(["cowork", "space_builder"]);
2334
2470
  var SURFACES2 = SurfaceSchema.options;
@@ -2408,7 +2544,22 @@ var ReferenceLimitsSchema = import_zod3.z.looseObject({
2408
2544
  audios: import_zod3.z.number().optional(),
2409
2545
  total: import_zod3.z.number().optional(),
2410
2546
  audioOnly: import_zod3.z.boolean().optional(),
2411
- videoSeconds: VideoSecondsLimitsSchema.optional()
2547
+ videoSeconds: VideoSecondsLimitsSchema.optional(),
2548
+ audioSeconds: import_zod3.z.looseObject({
2549
+ min: import_zod3.z.number().optional(),
2550
+ max: import_zod3.z.number().optional(),
2551
+ total: import_zod3.z.number().optional()
2552
+ }).optional(),
2553
+ audioFormats: import_zod3.z.array(import_zod3.z.string()).optional(),
2554
+ audioMaxBytes: import_zod3.z.number().positive().optional()
2555
+ });
2556
+ var RangeSchema = import_zod3.z.looseObject({ min: import_zod3.z.number(), max: import_zod3.z.number() });
2557
+ var VideoToolConstraintsSchema = import_zod3.z.looseObject({
2558
+ kind: import_zod3.z.enum(["upscale", "interpolate", "hdr"]),
2559
+ upscaleFactor: RangeSchema.optional(),
2560
+ maxOutputLongEdge: import_zod3.z.number().positive().optional(),
2561
+ fps: RangeSchema.optional(),
2562
+ outputFormats: import_zod3.z.array(import_zod3.z.enum(["h265", "prores"])).min(1).optional()
2412
2563
  });
2413
2564
  var ModelConstraintsSchema = import_zod3.z.looseObject({
2414
2565
  aspectRatios: import_zod3.z.array(AspectRatioSchema).optional(),
@@ -2423,7 +2574,14 @@ var ModelConstraintsSchema = import_zod3.z.looseObject({
2423
2574
  supports: import_zod3.z.array(SupportedOptionSchema).optional(),
2424
2575
  ratioIgnoredWith: import_zod3.z.array(import_zod3.z.enum(["firstFrame", "sourceVideo"])).optional(),
2425
2576
  sourceVideoSeconds: VideoSecondsLimitsSchema.omit({ total: true }).extend({ inReferenceTotal: import_zod3.z.boolean().optional() }).optional(),
2426
- extension: import_zod3.z.looseObject({ segmentSeconds: import_zod3.z.number(), maxTotalSeconds: import_zod3.z.number() }).optional(),
2577
+ // segmentSeconds/maxTotalSeconds stay required: a kit before 0.3.0 drops a row whose
2578
+ // extension lacks them, so every extension block keeps them.
2579
+ extension: import_zod3.z.looseObject({
2580
+ segmentSeconds: import_zod3.z.number(),
2581
+ maxTotalSeconds: import_zod3.z.number(),
2582
+ returns: import_zod3.z.enum(["new", "joined"]).optional()
2583
+ }).optional(),
2584
+ tool: VideoToolConstraintsSchema.optional(),
2427
2585
  // Loose on purpose: the read path keeps a row whose value is invalid (the accessors in
2428
2586
  // conventions.ts read it as "not set"); CatalogModelSchema below rejects it on write.
2429
2587
  audio: import_zod3.z.unknown().optional(),
@@ -2495,6 +2653,7 @@ var CatalogDisplaySchema = import_zod3.z.looseObject({
2495
2653
  });
2496
2654
  var CatalogCapabilitiesSchema = import_zod3.z.looseObject({
2497
2655
  acceptsSourceVideo: import_zod3.z.boolean().optional(),
2656
+ videoEdit: import_zod3.z.boolean().optional(),
2498
2657
  framesExcludeReferences: import_zod3.z.boolean().optional(),
2499
2658
  lastFrameRequiresFirstFrame: import_zod3.z.boolean().optional(),
2500
2659
  reasoning: import_zod3.z.boolean().optional(),
@@ -2537,6 +2696,13 @@ var CatalogModelSchemaBase = import_zod3.z.looseObject({
2537
2696
  message: "active text rows need contextWindow"
2538
2697
  });
2539
2698
  }
2699
+ if (row.type === "video-tool" && row.constraints.tool === void 0) {
2700
+ ctx.addIssue({
2701
+ code: "custom",
2702
+ path: ["constraints", "tool"],
2703
+ message: "video-tool rows need constraints.tool"
2704
+ });
2705
+ }
2540
2706
  if (row.replacedBy !== void 0 && row.replacedBy === row.id) {
2541
2707
  ctx.addIssue({
2542
2708
  code: "custom",
@@ -2842,6 +3008,114 @@ function resetLiveCatalog() {
2842
3008
  liveCatalog.replace([...MODELS]);
2843
3009
  }
2844
3010
 
3011
+ // src/catalog/video-tools-spec.ts
3012
+ var import_zod6 = require("zod");
3013
+ var RangeSchema2 = import_zod6.z.object({ min: import_zod6.z.number(), max: import_zod6.z.number() });
3014
+ var ToolSchema = import_zod6.z.object({
3015
+ kind: import_zod6.z.enum(["upscale", "interpolate", "hdr"]),
3016
+ upscaleFactor: RangeSchema2.optional(),
3017
+ maxOutputLongEdge: import_zod6.z.number().positive().optional(),
3018
+ fps: RangeSchema2.optional(),
3019
+ outputFormats: import_zod6.z.array(import_zod6.z.enum(["h265", "prores"])).min(1).optional()
3020
+ });
3021
+ function videoToolConstraints(row) {
3022
+ if (row.type !== "video-tool") return void 0;
3023
+ const parsed = ToolSchema.safeParse(row.constraints.tool);
3024
+ if (!parsed.success) return void 0;
3025
+ const { kind, upscaleFactor, maxOutputLongEdge, fps, outputFormats } = parsed.data;
3026
+ return {
3027
+ kind,
3028
+ ...upscaleFactor !== void 0 && { upscaleFactor },
3029
+ ...maxOutputLongEdge !== void 0 && { maxOutputLongEdge },
3030
+ ...fps !== void 0 && { fps },
3031
+ ...outputFormats !== void 0 && { outputFormats }
3032
+ };
3033
+ }
3034
+ function resolutionTier(width, height) {
3035
+ const shortEdge = Math.min(width, height);
3036
+ if (shortEdge <= 360) return "360p";
3037
+ if (shortEdge <= 480) return "480p";
3038
+ if (shortEdge <= 720) return "720p";
3039
+ if (shortEdge <= 1080) return "1080p";
3040
+ if (shortEdge <= 1440) return "2k";
3041
+ return "4k";
3042
+ }
3043
+ function fail(error) {
3044
+ return { ok: false, error };
3045
+ }
3046
+ function planVideoTool(row, req) {
3047
+ const tool = videoToolConstraints(row);
3048
+ if (!tool) return fail(`${row.id} is not a video tool`);
3049
+ const { width, height, durationSeconds, fps } = req.source;
3050
+ if (!(width > 0 && height > 0 && durationSeconds > 0)) {
3051
+ return fail("The source video's width, height and duration are required");
3052
+ }
3053
+ const maxSeconds = row.constraints.sourceVideoSeconds?.max;
3054
+ if (maxSeconds !== void 0 && durationSeconds > maxSeconds) {
3055
+ return fail(`${row.label} takes source videos up to ${maxSeconds} seconds.`);
3056
+ }
3057
+ const base = {
3058
+ durationSeconds,
3059
+ ...fps !== void 0 && { fps }
3060
+ };
3061
+ if (tool.kind === "upscale") {
3062
+ const factor = req.upscaleFactor;
3063
+ if (factor === void 0) return fail("An upscale factor is required");
3064
+ const range = tool.upscaleFactor;
3065
+ if (range && (factor < range.min || factor > range.max)) {
3066
+ return fail(`${row.label} upscales by ${range.min}x to ${range.max}x.`);
3067
+ }
3068
+ const outWidth = Math.round(width * factor);
3069
+ const outHeight = Math.round(height * factor);
3070
+ const limit = tool.maxOutputLongEdge;
3071
+ if (limit !== void 0 && Math.max(outWidth, outHeight) > limit) {
3072
+ return fail(
3073
+ `A ${factor}x upscale of a ${width}x${height} video passes the ${limit}px limit.`
3074
+ );
3075
+ }
3076
+ return {
3077
+ ok: true,
3078
+ output: {
3079
+ ...base,
3080
+ width: outWidth,
3081
+ height: outHeight,
3082
+ resolution: resolutionTier(outWidth, outHeight),
3083
+ format: "h264"
3084
+ }
3085
+ };
3086
+ }
3087
+ if (tool.kind === "interpolate") {
3088
+ const target = req.targetFps;
3089
+ if (target === void 0 || !Number.isInteger(target)) {
3090
+ return fail("A whole-number target frame rate is required");
3091
+ }
3092
+ const range = tool.fps;
3093
+ if (range && (target < range.min || target > range.max)) {
3094
+ return fail(`${row.label} outputs ${range.min} to ${range.max} fps.`);
3095
+ }
3096
+ return {
3097
+ ok: true,
3098
+ output: {
3099
+ durationSeconds,
3100
+ fps: target,
3101
+ width,
3102
+ height,
3103
+ resolution: resolutionTier(width, height),
3104
+ format: "h264"
3105
+ }
3106
+ };
3107
+ }
3108
+ const formats = tool.outputFormats ?? ["h265"];
3109
+ const format = req.outputFormat ?? formats[0] ?? "h265";
3110
+ if (!formats.includes(format)) {
3111
+ return fail(`${row.label} writes ${formats.join(" or ")}.`);
3112
+ }
3113
+ return {
3114
+ ok: true,
3115
+ output: { ...base, width, height, resolution: resolutionTier(width, height), format }
3116
+ };
3117
+ }
3118
+
2845
3119
  // src/resolve-options.ts
2846
3120
  var SIZE_ORDER = ["512", "1K", "2K", "4K"];
2847
3121
  var RESOLUTION_ORDER = ["360p", "480p", "720p", "768p", "1080p", "2k", "4k"];