@kolbo/mcp 1.83.2 → 1.83.4
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/package.json +1 -1
- package/skill/GENERATED.md +1 -1
- package/skill/SKILL.md +2 -2
- package/skill/VERSION +1 -1
- package/skill/references/workflows/cost-and-validation.md +6 -5
- package/src/client.js +38 -4
- package/src/tools/generate.js +2 -2
- package/src/tools/models.js +2 -2
package/package.json
CHANGED
package/skill/GENERATED.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AUTO-GENERATED — do not edit
|
|
2
2
|
|
|
3
|
-
This tree is mirrored from kolbo-code@
|
|
3
|
+
This tree is mirrored from kolbo-code@3a5d6b7, the single source of truth.
|
|
4
4
|
Canonical source: packages/opencode/skills/kolbo/
|
|
5
5
|
Distribution: .github/workflows/sync-skill-to-plugin.yml
|
|
6
6
|
|
package/skill/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
version: 0.9.
|
|
2
|
+
version: 0.9.12
|
|
3
3
|
name: kolbo
|
|
4
4
|
description: |
|
|
5
5
|
Generate, edit, analyze, and direct creative media through Kolbo AI: images,
|
|
@@ -53,7 +53,7 @@ Then generate **only** with the confirmed parameters. If the user changes an opt
|
|
|
53
53
|
|
|
54
54
|
**Cost rules** (full tables + formulas in `references/workflows/cost-and-validation.md`):
|
|
55
55
|
|
|
56
|
-
- **Video/lipsync `credit` is per-SECOND, not per-clip**: `total = credit ×
|
|
56
|
+
- **Video/lipsync `credit` is per-SECOND, not per-clip**: normally `total = credit × output_duration`. If video references are attached and `video_input_credit` is present, use the alternate provider tariff instead: `video_input_credit × (sum nominal input seconds + nominal output seconds) × video_input_resolution_multiplier`. MP4 encoder padding within 0.15s of an integer snaps to that integer; other fractional durations round up. Dedicated Seedance Edit uses its selected source duration as output; Extend uses the requested added duration. The other carve-out is `flat_credit_by_resolution`.
|
|
57
57
|
- **Batch totalling 100+ credits**: run `check_credits` first.
|
|
58
58
|
- **Quote real cost**: when the user approves the result, log its actual `credits_used` (from the tool result) to `.kolbo/production.md` — never `base × count`.
|
|
59
59
|
- **Never state "credits remaining" from arithmetic** (opening balance − generation costs). Coding/chat usage deducts credits too, so the math is always wrong. Report cost only; if the user asks for their balance, call `check_credits` fresh at that moment.
|
package/skill/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.9.
|
|
1
|
+
0.9.12
|
|
@@ -12,7 +12,7 @@ Creative generations bill against the user's Kolbo credit balance. **Billing uni
|
|
|
12
12
|
| **Image edit** | per image (flat) | 2–20 cr | |
|
|
13
13
|
| **Video** | **cr/s × duration** | 2–30 cr/s | Kandinsky 5 Fast × 5s = 10 cr; Seedance 2.0 × 10s = 300 cr. Check `resolution_multipliers` + `sound_credit_multiplier`. |
|
|
14
14
|
| **Video from image** | **cr/s × duration** | 4–30 cr/s | Same per-second rule. |
|
|
15
|
-
| **Elements (ref-to-video)** |
|
|
15
|
+
| **Elements (ref-to-video)** | output seconds normally; combined input + output seconds when `video_input_credit` exists and video is attached | 4–40 cr/s | Read both pricing profiles from `list_models type="elements"`. |
|
|
16
16
|
| **Lipsync** | **cr/s × duration** | 5–20 cr/s | |
|
|
17
17
|
| **Music** | per generation (flat) | 15–60 cr | Suno v5 = 15 cr; ElevenLabs Music = 60 cr |
|
|
18
18
|
| **Speech (TTS)** | per 100 characters | 2–5 cr/100 chars | ElevenLabs (5) × 500 chars = 25 cr |
|
|
@@ -24,7 +24,8 @@ Creative generations bill against the user's Kolbo credit balance. **Billing uni
|
|
|
24
24
|
|
|
25
25
|
Apply when confirming cost before firing:
|
|
26
26
|
|
|
27
|
-
- **Video / Lipsync**: `total = model_credit_per_second ×
|
|
27
|
+
- **Video / Lipsync**: normally `total = model_credit_per_second × output_duration_seconds`. Never assume the credit shown is a flat per-generation cost for these types.
|
|
28
|
+
- **Video-input tariff**: when the request contains one or more video inputs and the model returns `video_input_credit`, use `ceil(video_input_credit × (sum nominal_video_seconds(each input) + nominal_output_seconds) × video_input_resolution_multipliers[resolution])`. A probed duration within 0.15s of an integer is encoder padding and snaps to that integer; any larger fraction rounds up. Dedicated Seedance Video Edit output seconds follow the selected source clip; Seedance Extend output seconds are only the requested added duration. Do not also apply the ordinary `credit` profile.
|
|
28
29
|
- **Music**: flat per generation — `total = model_credit` (duration does not change cost).
|
|
29
30
|
- **TTS**: `total = model_credit × ceil(character_count / 100)`. Count actual characters first. 1000 chars with ElevenLabs = 50 credits.
|
|
30
31
|
- **Images / 3D / Sound effects**: `total = model_credit × quantity`.
|
|
@@ -60,7 +61,7 @@ Before submitting:
|
|
|
60
61
|
1. Call `list_models type=<tool-type>` (text mode is enough for picking; `format: "json"` for programmatic comparison).
|
|
61
62
|
2. For each input array (refs / DNAs / elements) — check `length <= <cap>` from the canonical field reference below. If over, drop the lowest-priority entries OR ask the user.
|
|
62
63
|
3. For each enumerated value (`aspect_ratio` / `resolution` / `duration`) — check it's in `supported_*`. If not, **do not silently substitute**; show the user the allowed set and ask.
|
|
63
|
-
4. For each duration-bearing file (
|
|
64
|
+
4. For each duration-bearing file (source/reference video for elements/v2v/extend, audio for lipsync/elements) — pre-check duration against the min/max range. Use ffmpeg if needed. Seedance 2/2.5 video inputs are rejected below `min_video_duration` (currently 4 seconds).
|
|
64
65
|
5. For uploads — pre-check size against `max_file_size`.
|
|
65
66
|
|
|
66
67
|
The MCP tool descriptions also embed the cap field name on the relevant parameter (e.g. `reference_images: "...Cap: pass at most max_reference_images..."`) — use those as inline reminders.
|
|
@@ -82,7 +83,7 @@ The same conceptual slot (e.g. "max reference images") lives under **different f
|
|
|
82
83
|
| `aspect_ratio` | any | `supported_aspect_ratios` (or `_by_type[<type>]` when multimodal) | empty → `default_aspect_ratio` if set |
|
|
83
84
|
| `resolution` | any | `supported_resolutions` (+ `resolution_multipliers` for cost) | empty → no resolution tiering |
|
|
84
85
|
| `duration` (video output) | video tools | `supported_durations`, else `min_output_duration`–`max_output_duration` | both null → omit and let server default |
|
|
85
|
-
| **input** video duration | `lipsync-video`, `generate_video_from_video` | `min_video_duration` – `max_video_duration` | outside range → reject |
|
|
86
|
+
| **input** video duration | `lipsync-video`, `generate_video_from_video`, `generate_elements` with video refs | `min_video_duration` – `max_video_duration` | outside range → reject |
|
|
86
87
|
| input audio duration | `generate_lipsync`, `generate_elements` audio | `min_audio_duration` – `max_audio_duration` (+ `audio_max_follows_video_duration` for lipsync) | outside range → reject |
|
|
87
88
|
| audio file format | any audio input | `supported_audio_formats` (e.g. `["mp3","wav","m4a"]`; empty = all) | pre-validate before upload |
|
|
88
89
|
| recording duration | `text_to_speech` recording UX | `min_recording_duration` – `max_recording_duration` | usually null for plain TTS |
|
|
@@ -92,7 +93,7 @@ The same conceptual slot (e.g. "max reference images") lives under **different f
|
|
|
92
93
|
| sound on/off | video tools | `sound_generation_type` (`"native"` vs `"none"`), `sound_enabled_by_default`, `sound_credit_multiplier` | not `"native"` → can't emit synced audio |
|
|
93
94
|
| capability gate | route decision | `supports_visual_dna`, `supports_first_last_frame`, `supports_audio_input` | `false` → the controller silently drops that param |
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
Normal cost formula: `final_cost = credit × output_seconds × resolution_multipliers[resolution] × (sound_enabled ? sound_credit_multiplier : 1)`, multiplied by `num_images` / `scene_count` as applicable. When `video_input_credit` applies, replace it with the combined input/output formula above and use `video_input_resolution_multipliers`.
|
|
96
97
|
|
|
97
98
|
## Decision Rule for Resolution
|
|
98
99
|
|
package/src/client.js
CHANGED
|
@@ -4,6 +4,34 @@ const os = require('os');
|
|
|
4
4
|
const progress = require('./progress');
|
|
5
5
|
const { rewriteTree } = require('./cdn');
|
|
6
6
|
|
|
7
|
+
const UPSTREAM_NAME = /\b(?:fal(?:\.ai)?|kie(?:\.ai)?|toapis?|kinovi|piapi|yike|goapi|wavespeed|openrouter|cometapi|dashscope|volcengine|laozhang)\b/i;
|
|
8
|
+
const UPSTREAM_LABEL = /(^|\|\s*)\s*(?:fal(?:\.ai)?|kie(?:\.ai)?|toapis?|kinovi|piapi|yike|goapi|wavespeed|openrouter|cometapi|dashscope|volcengine|laozhang|bytedance|replicate|pika)\s*:\s*/gi;
|
|
9
|
+
const UPSTREAM_TAG = /\[\s*(?:fal(?:\.ai)?|kie(?:\.ai)?|toapis?|kinovi|piapi|yike|goapi|wavespeed|openrouter|cometapi|dashscope|volcengine|laozhang|bytedance|replicate|pika)\s*\]\s*/gi;
|
|
10
|
+
|
|
11
|
+
function publicApiErrorMessage(value, fallback) {
|
|
12
|
+
if (typeof value !== 'string' || !value.trim()) return fallback;
|
|
13
|
+
const stripped = value.replace(UPSTREAM_LABEL, '$1').replace(UPSTREAM_TAG, '').replace(/^\s*\|\s*/, '').trim();
|
|
14
|
+
return (!stripped || UPSTREAM_NAME.test(stripped) || /\bpika\b(?!\s*\d)/i.test(stripped))
|
|
15
|
+
? fallback
|
|
16
|
+
: stripped;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function publicApiErrorData(data) {
|
|
20
|
+
if (!data || typeof data !== 'object') return null;
|
|
21
|
+
return {
|
|
22
|
+
...(data.code && { code: data.code }),
|
|
23
|
+
...(typeof data.retryable === 'boolean' && { retryable: data.retryable }),
|
|
24
|
+
...(data.category && { category: data.category }),
|
|
25
|
+
...(data.failure && typeof data.failure === 'object' && {
|
|
26
|
+
failure: {
|
|
27
|
+
...(data.failure.code && { code: data.failure.code }),
|
|
28
|
+
...(data.failure.category && { category: data.failure.category }),
|
|
29
|
+
...(typeof data.failure.retryable === 'boolean' && { retryable: data.failure.retryable }),
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
7
35
|
/**
|
|
8
36
|
* Kolbo API HTTP client wrapper
|
|
9
37
|
*
|
|
@@ -373,7 +401,10 @@ class KolboClient {
|
|
|
373
401
|
}
|
|
374
402
|
|
|
375
403
|
if (!response.ok || data.success === false) {
|
|
376
|
-
const message =
|
|
404
|
+
const message = publicApiErrorMessage(
|
|
405
|
+
data.error || data.message,
|
|
406
|
+
`The Kolbo request could not be completed (HTTP ${response.status}).`
|
|
407
|
+
);
|
|
377
408
|
const code = data.code || null;
|
|
378
409
|
let fullMessage = code ? `${message} [${code}]` : message;
|
|
379
410
|
if (response.status === 401) {
|
|
@@ -390,7 +421,7 @@ class KolboClient {
|
|
|
390
421
|
const apiError = new KolboApiError(fullMessage, {
|
|
391
422
|
code,
|
|
392
423
|
status: response.status,
|
|
393
|
-
data
|
|
424
|
+
data: publicApiErrorData(data)
|
|
394
425
|
});
|
|
395
426
|
if (response.status === 429) apiError.retryAfterSeconds = retryAfterSeconds(response);
|
|
396
427
|
throw apiError;
|
|
@@ -527,7 +558,10 @@ class KolboClient {
|
|
|
527
558
|
}
|
|
528
559
|
|
|
529
560
|
if (!response.ok || data.success === false) {
|
|
530
|
-
const message =
|
|
561
|
+
const message = publicApiErrorMessage(
|
|
562
|
+
data.error || data.message,
|
|
563
|
+
`The Kolbo request could not be completed (HTTP ${response.status}).`
|
|
564
|
+
);
|
|
531
565
|
const code = data.code || null;
|
|
532
566
|
let fullMessage = code ? `${message} [${code}]` : message;
|
|
533
567
|
if (response.status === 401) {
|
|
@@ -541,7 +575,7 @@ class KolboClient {
|
|
|
541
575
|
const apiError = new KolboApiError(fullMessage, {
|
|
542
576
|
code,
|
|
543
577
|
status: response.status,
|
|
544
|
-
data
|
|
578
|
+
data: publicApiErrorData(data)
|
|
545
579
|
});
|
|
546
580
|
if (response.status === 429) apiError.retryAfterSeconds = retryAfterSeconds(response);
|
|
547
581
|
throw apiError;
|
package/src/tools/generate.js
CHANGED
|
@@ -1254,12 +1254,12 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1254
1254
|
// ─── generate_elements ─────────────────────────────────────
|
|
1255
1255
|
server.tool(
|
|
1256
1256
|
'generate_elements',
|
|
1257
|
-
'Generate a video from reference elements (images, videos, and/or audio) + a text prompt. Use when the user wants to animate specific uploaded/referenced assets — e.g. "animate this product", "put these 3 characters into a scene". PRIMARY ROUTE FOR A DNA-ANCHORED MULTI-SHOT FILM: one call can carry the whole sequence — seedance-2-5 takes 4-30s, up to 30 shots and 20 Visual DNAs in a SINGLE generation (seedance-2: 4-15s, 9 DNAs) — instead of a stack of separate clips. DIALOGUE IS PERFORMED NATIVELY: quoted dialogue in the prompt comes back as synced voices with lip movement, room tone and the SFX named in the AUDIO block — never route scene dialogue to generate_speech or generate_lipsync. Write dialogue in ENGLISH; other languages (Hebrew included) do not perform reliably. COST: resolution is a multiplier. When list_models publishes `video_input_credit` and this call carries videos, charge that rate against
|
|
1257
|
+
'Generate a video from reference elements (images, videos, and/or audio) + a text prompt. Use when the user wants to animate specific uploaded/referenced assets — e.g. "animate this product", "put these 3 characters into a scene". PRIMARY ROUTE FOR A DNA-ANCHORED MULTI-SHOT FILM: one call can carry the whole sequence — seedance-2-5 takes 4-30s, up to 30 shots and 20 Visual DNAs in a SINGLE generation (seedance-2: 4-15s, 9 DNAs) — instead of a stack of separate clips. DIALOGUE IS PERFORMED NATIVELY: quoted dialogue in the prompt comes back as synced voices with lip movement, room tone and the SFX named in the AUDIO block — never route scene dialogue to generate_speech or generate_lipsync. Write dialogue in ENGLISH; other languages (Hebrew included) do not perform reliably. COST: resolution is a multiplier. When list_models publishes `video_input_credit` and this call carries videos, charge that rate against nominal input seconds + nominal output seconds; MP4 padding within 0.15s of an integer snaps to that integer and larger fractions round up. Otherwise use the normal output-second rate. PROMPT CONTRACT (Seedance / Elements): Locked Intro only — Total line, then [GLOBAL LOOK] / [CAST] / [LOCATION] / SHOT N. Do NOT write SCENE CONTEXT / OPTICS / ACTION department packs. Every Visual DNA in visual_dna_ids MUST also appear in the prompt as @ExactDNAName (e.g. "@Zohar walks…") — never "Zohar\'s" or "the man on the left" as a substitute. IMPORTANT: different models accept different numbers and durations of inputs — call list_models type="elements" and read elements_max_images / elements_max_videos / elements_max_audio plus min_video_duration / max_video_duration before generating. For text-only → video use generate_video instead. For animating a single still image use generate_video_from_image. Returns the final video URL when complete.',
|
|
1258
1258
|
{
|
|
1259
1259
|
prompt: z.string().describe('Locked Intro prompt (Seedance/Elements): Total line, [GLOBAL LOOK], [CAST] with @ExactDNAName for every visual_dna_ids entry, [LOCATION], then SHOT N. Not SCENE CONTEXT/OPTICS/ACTION packs. Never substitute "the left man" or "Zohar\'s" for @Name.'),
|
|
1260
1260
|
model: z.string().optional().describe('Model identifier. If the user already named a family (Grok / Kling / Veo / Seedance / …), pass THAT family — never default to Seedance because Elements often uses it. Use list_models type="elements" for exact ids and elements_max_* caps. Do NOT omit (omitting = Smart Select).'),
|
|
1261
1261
|
reference_images: z.array(z.string()).optional().describe('Array of image references (product shots, character references, etc.). Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Cap: pass at most `elements_max_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.**'),
|
|
1262
|
-
reference_videos: z.array(z.string()).optional().describe('Array of reference videos for models that accept video inputs. Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Cap: pass at most `elements_max_videos` URLs and keep every clip within `min_video_duration`-`max_video_duration` from list_models.** If `video_input_credit` is present, every attached video contributes
|
|
1262
|
+
reference_videos: z.array(z.string()).optional().describe('Array of reference videos for models that accept video inputs. Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Cap: pass at most `elements_max_videos` URLs and keep every clip within `min_video_duration`-`max_video_duration` from list_models.** If `video_input_credit` is present, every attached video contributes its nominal duration to combined-second billing: encoder padding within 0.15s of an integer snaps to it; larger fractions round up.'),
|
|
1263
1263
|
reference_audio_urls: z.array(z.string()).optional().describe('Array of reference audio tracks for models that accept audio inputs. Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Cap: pass at most `elements_max_audio` URLs from list_models.** `audio_url` remains supported as the legacy single-track form.'),
|
|
1264
1264
|
audio_url: z.string().optional().describe('A single reference audio track — legacy form of reference_audio_urls. Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Audio constraints: `elements_max_audio` from list_models gates whether audio is accepted at all; audio duration must fall within `min_audio_duration`-`max_audio_duration`; format must be in `supported_audio_formats` (if specified).**'),
|
|
1265
1265
|
files: z.array(z.string()).optional().describe('Untyped catch-all for mixed media — images, videos AND audio, each a URL or an absolute local path. The kind is detected from the file extension and the item is routed to the matching reference list, so a local .mp4 is sent as a video and a local .mp3 as audio. Prefer the typed lists (reference_images / reference_videos / reference_audio_urls) when you already know the kind; they accept local paths too. URLs given here are forwarded as URLs, never re-uploaded. **Caps still apply per kind: `elements_max_images` / `elements_max_videos` / `elements_max_audio` from list_models. Local uploads are capped at 200MB each.**'),
|
package/src/tools/models.js
CHANGED
|
@@ -116,7 +116,7 @@ function registerModelTools(server, client, options = {}) {
|
|
|
116
116
|
// ─── list_models ───────────────────────────────────────────
|
|
117
117
|
server.tool(
|
|
118
118
|
'list_models',
|
|
119
|
-
'List available AI models on Kolbo. Filter by `type` to narrow to a generation type, and pass `format: "json"` to enumerate the catalog with exact identifiers — `format: "json"` + `type` returns the full raw model documents (every constraint field, for programmatic comparison / cap validation before submitting a generation); `format: "json"` alone returns a compact index of EVERY model and its identifier. Default `format: "text"` returns the human-readable summary. NEVER guess a model identifier: call this tool. ⚠️ COST: video / firstlast / elements / motion_graphic / cast rates are normally per output second. If a model publishes `video_input_credit` and the request includes one or more input videos, use that alternate rate and bill
|
|
119
|
+
'List available AI models on Kolbo. Filter by `type` to narrow to a generation type, and pass `format: "json"` to enumerate the catalog with exact identifiers — `format: "json"` + `type` returns the full raw model documents (every constraint field, for programmatic comparison / cap validation before submitting a generation); `format: "json"` alone returns a compact index of EVERY model and its identifier. Default `format: "text"` returns the human-readable summary. NEVER guess a model identifier: call this tool. ⚠️ COST: video / firstlast / elements / motion_graphic / cast rates are normally per output second. If a model publishes `video_input_credit` and the request includes one or more input videos, use that alternate rate and bill nominal input seconds + nominal output seconds. Encoder padding within 0.15s of an integer snaps to it; larger fractions round up. A `flat_credit_by_resolution` model instead charges the flat tier regardless of duration. Every other model type (image, audio, 3D, per-token text) bills as its catalog fields state.',
|
|
120
120
|
{
|
|
121
121
|
type: z.string().optional().describe('Filter by DB type name. Generation: "text_to_img", "image_editing", "text_to_video", "img_to_video", "draw_to_video", "video_to_video", "elements", "firstlastgenerations", "lipsync-image", "lipsync-video", "music_gen", "text_to_speech", "text_to_sound", "stt", "text". Image-edit engines: "image_upscale", "image_reframe", "image_zoom_out", "inpaint", "erase", "face_swap", "background_remove", "background_replace", "skin_enhancer", "graphics_enhance". Video-edit engines: "video_upscale", "video_reframe", "video_background_removal", "video_to_sound", "video_face_swap", "video_watermark_removal", "video_extend", "video_inpaint", "video_retake". For edit_image/edit_video, query the operation-specific type and pass a CONCRETE returned identifier; never submit a kolbo_gateway_* row, because those are web-navigation aliases rather than AI engines. Legacy aliases also accepted: "image", "image_edit", "video", "video_from_image", "video_from_video", "music", "speech", "sound", "chat", "lipsync", "three_d", "first_last_frame", "transcription". Omit for all models.'),
|
|
122
122
|
format: z.enum(['text', 'json']).optional().describe('Output format. "text" (default) returns a human-readable summary with the most-used caps. "json" is the source of truth for identifiers and caps: with `type` it returns the raw model documents from the API (identifier, credit, supported_durations, supported_resolutions, supported_aspect_ratios, max_reference_images, max_visual_dna, max_video_duration, …) for EVERY model of that type; without `type` it returns a compact index of every model in the catalog and its exact identifier. Use it whenever you need an identifier you have not seen listed, or must verify a cap before passing a value that might exceed a model-specific limit.'),
|
|
@@ -219,7 +219,7 @@ function registerModelTools(server, client, options = {}) {
|
|
|
219
219
|
const tiers = Object.keys(vm).length
|
|
220
220
|
? ' · ' + Object.entries(vm).map(([r, mult]) => `${r} (${mult}×)`).join(' · ')
|
|
221
221
|
: '';
|
|
222
|
-
parts.push(`video_input_price: ${m.video_input_credit} credits/combined-second${tiers} · bill
|
|
222
|
+
parts.push(`video_input_price: ${m.video_input_credit} credits/combined-second${tiers} · bill nominal input + output seconds (≤0.15s encoder padding snaps)`);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
// Output durations (video gen output, not source video)
|