@kaydash9999/banker-plugins 0.11.0 → 0.13.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.
Files changed (32) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +52 -0
  4. package/README.md +28 -8
  5. package/codex/manifest.json +8 -1
  6. package/codex/transform-matrix.md +2 -2
  7. package/commands/setup.md +2 -0
  8. package/package.json +6 -1
  9. package/skills/3d-intro-build/SKILL.md +214 -0
  10. package/skills/3d-intro-build/references/LICENSE +21 -0
  11. package/skills/3d-intro-build/references/NOTICE.md +32 -0
  12. package/skills/3d-intro-build/references/assemble.mjs +252 -0
  13. package/skills/3d-intro-build/references/azure-adapter.mjs +495 -0
  14. package/skills/3d-intro-build/references/curate.mjs +301 -0
  15. package/skills/3d-intro-build/references/index-template.html +73 -0
  16. package/skills/3d-intro-build/references/prompts.md +171 -0
  17. package/skills/3d-intro-build/references/scrub-engine.js +448 -0
  18. package/skills/3d-intro-build/references/serve.mjs +123 -0
  19. package/skills/3d-intro-setup/SKILL.md +196 -0
  20. package/skills/3d-intro-setup/references/azure-adapter.mjs +495 -0
  21. package/skills/lineage/SKILL.md +126 -134
  22. package/skills/lineage/lineage.py +1041 -354
  23. package/skills/motion-graphic-make/SKILL.md +57 -0
  24. package/skills/motion-graphic-setup/SKILL.md +141 -0
  25. package/skills/ralph-qa/SKILL.md +253 -77
  26. package/skills/ralph-qa/references/verifier-probe.mjs +860 -0
  27. package/skills/refresh-readme/SKILL.md +3 -3
  28. package/skills/vertical-pptx/SKILL.md +409 -0
  29. package/skills/vertical-pptx/build_a4p.js +923 -0
  30. package/skills/vertical-pptx/build_a4p.py +916 -0
  31. package/skills/vertical-pptx/verify_a4p.mjs +2168 -0
  32. package/skills/vertical-pptx-setup/SKILL.md +223 -0
@@ -0,0 +1,495 @@
1
+ /*
2
+ * Shared Azure OpenAI / Sora adapter for the 3d-intro skills (build + setup).
3
+ *
4
+ * CANONICAL COPY: skills/3d-intro-build/references/azure-adapter.mjs
5
+ * skills/3d-intro-setup/references/azure-adapter.mjs is a byte-identical mirror kept in
6
+ * sync by scripts/sync-adapter.js (run `node scripts/sync-adapter.js --check` in CI).
7
+ * Edit the build copy only; never the setup copy.
8
+ *
9
+ * Runtime: Node >=18 builtins ONLY — global fetch/FormData/Blob, node:fs/path/os/child_process.
10
+ * The one optional dependency, ffmpeg-static, is resolved LAZILY at runtime via dynamic
11
+ * import() inside resolveFfmpeg(); it is never a hard top-level import, so the module loads
12
+ * (and every non-ffmpeg export works) even when ffmpeg-static is not installed.
13
+ *
14
+ * Testability: azFetch() calls globalThis.fetch and the ffmpeg helpers call cp.spawnSync
15
+ * (property access at call time, not a destructured binding), so unit tests can stub both by
16
+ * assigning globalThis.fetch / cp.spawnSync. No hardcoded secrets, keys, or endpoints.
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import os from 'node:os';
21
+ // Default import (the CJS module.exports object) so ffmpeg helpers can read cp.spawnSync at
22
+ // call time — a destructured `import { spawnSync }` snapshots the binding and cannot be stubbed.
23
+ import cp from 'node:child_process';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // small internal helpers (not exported)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+ const round2 = (x) => Math.round(x * 100) / 100;
31
+
32
+ // Scheme+host of a URL, dropping any path/query — Azure endpoints are the origin only.
33
+ function originOf(url) {
34
+ const raw = String(url || '');
35
+ try {
36
+ const u = new URL(raw);
37
+ return `${u.protocol}//${u.host}`;
38
+ } catch {
39
+ const m = raw.match(/^(https?:\/\/[^/]+)/i);
40
+ return m ? m[1] : raw.replace(/\/+$/, '');
41
+ }
42
+ }
43
+
44
+ // Compact any value to a short one-line string for error messages (never throws).
45
+ function truncate(x, n = 300) {
46
+ let s;
47
+ if (typeof x === 'string') s = x;
48
+ else { try { s = JSON.stringify(x); } catch { s = String(x); } }
49
+ if (!s) return '';
50
+ return s.length > n ? `${s.slice(0, n)}…(+${s.length - n})` : s;
51
+ }
52
+
53
+ // Last n chars of ffmpeg stderr (the useful part) for error messages.
54
+ const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
55
+
56
+ // Map an arbitrary WxH request to the nearest discrete size the classic image API accepts.
57
+ function classicSize(size) {
58
+ const m = String(size || '').match(/^(\d+)x(\d+)$/);
59
+ if (!m) return '1024x1024';
60
+ const w = +m[1], h = +m[2];
61
+ if (w < h) return '1024x1536';
62
+ if (w > h) return '1536x1024';
63
+ return '1024x1024';
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Credentials
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Extract Azure connection fields from a pasted console sample — works on BOTH a `curl`
72
+ * command and a Python SDK snippet. Missing fields come back as null (never throws).
73
+ * @returns {{endpoint:string|null, deployment:string|null, apiKey:string|null, apiVersion:string|null}}
74
+ */
75
+ export function parseConsoleSample(text) {
76
+ const s = String(text || '');
77
+ const out = { endpoint: null, deployment: null, apiKey: null, apiVersion: null };
78
+
79
+ // endpoint: an explicit kwarg (python/JS SDK) wins; else the origin of the first URL (curl).
80
+ let m = s.match(/(?:azure_endpoint|azure_openai_endpoint|base_url|endpoint)\s*[=:]\s*["']([^"']+)["']/i);
81
+ if (m) out.endpoint = originOf(m[1]);
82
+ else { m = s.match(/https?:\/\/[^\s"'`)]+/); if (m) out.endpoint = originOf(m[0]); }
83
+
84
+ // api-version: URL query (?api-version=...) or SDK kwarg (api_version="...").
85
+ m = s.match(/api[-_]version["']?\s*[=:]\s*["']?([A-Za-z0-9._-]+)/i);
86
+ if (m) out.apiVersion = m[1];
87
+
88
+ // deployment: URL path /deployments/<dep>/ wins; else a model/deployment kwarg.
89
+ m = s.match(/\/deployments\/([^/?"'\s]+)/i);
90
+ if (m) out.deployment = decodeURIComponent(m[1]);
91
+ else {
92
+ m = s.match(/(?:azure_deployment|deployment_name|deployment|model)\s*[=:]\s*["']([^"']+)["']/i);
93
+ if (m) out.deployment = m[1];
94
+ }
95
+
96
+ // key: Authorization: Bearer <k> | api-key: <k> (curl header) | api_key="<k>" (python/JS SDK).
97
+ m = s.match(/Authorization["']?\s*[:=]\s*["']?\s*Bearer\s+([A-Za-z0-9._-]+)/i);
98
+ if (!m) m = s.match(/api[-_]?key["']?\s*[:=]\s*["']?\s*([A-Za-z0-9._-]+)/i);
99
+ if (m) out.apiKey = m[1];
100
+
101
+ return out;
102
+ }
103
+
104
+ /** Parse a KEY=VALUE .env body into a plain object (strips surrounding quotes, skips comments). */
105
+ export function parseEnvFile(text) {
106
+ const env = {};
107
+ for (const line of String(text || '').split(/\r?\n/)) {
108
+ if (line.trimStart().startsWith('#')) continue;
109
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
110
+ if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
111
+ }
112
+ return env;
113
+ }
114
+
115
+ /**
116
+ * Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
117
+ * Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found).
118
+ */
119
+ export function resolveCreds({ projectDir } = {}) {
120
+ const candidates = [];
121
+ if (projectDir) candidates.push(path.join(projectDir, '.env.3d-intro.local'));
122
+ candidates.push(path.join(os.homedir(), '.config', 'banker', '3d-intro', 'env'));
123
+ for (const p of candidates) {
124
+ if (fs.existsSync(p)) return { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
125
+ }
126
+ return { _source: null };
127
+ }
128
+
129
+ /**
130
+ * Persist creds to a KEY=VALUE env file, chmod 0600 where supported. Keys starting with `_`
131
+ * and null/undefined values are skipped. Returns the written path.
132
+ */
133
+ export function persistCreds(creds, { target } = {}) {
134
+ if (!target) throw new Error('persistCreds: target path required');
135
+ const lines = [];
136
+ for (const [k, v] of Object.entries(creds || {})) {
137
+ if (k.startsWith('_') || v == null) continue;
138
+ lines.push(`${k}=${String(v)}`);
139
+ }
140
+ const body = lines.length ? `${lines.join('\n')}\n` : '';
141
+ fs.mkdirSync(path.dirname(target), { recursive: true });
142
+ fs.writeFileSync(target, body, { mode: 0o600 });
143
+ try { fs.chmodSync(target, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
144
+ return target;
145
+ }
146
+
147
+ /** Redact a secret to `abcd…wxyz(len)`; short (<=8) secrets are fully masked; falsy -> "(missing)". */
148
+ export function redact(s) {
149
+ if (!s) return '(missing)';
150
+ const str = String(s);
151
+ if (str.length <= 8) return `…(${str.length})`;
152
+ return `${str.slice(0, 4)}…${str.slice(-4)}(${str.length})`;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // HTTP
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Fetch an Azure URL trying the `api-key` header first and, on 401/403, retrying with
161
+ * `Authorization: Bearer`. Never throws on HTTP status; a network error yields {status:0}.
162
+ * @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, error?:Error}>}
163
+ */
164
+ export async function azFetch(url, { key, binary = false, headers = {}, method = 'GET', body } = {}) {
165
+ const styles = key ? [{ 'api-key': key }, { Authorization: `Bearer ${key}` }] : [{}];
166
+ let last = { status: 0, ok: false, ct: '', body: null, authStyle: null };
167
+ for (const auth of styles) {
168
+ const opts = { method, headers: { ...headers, ...auth } };
169
+ if (body !== undefined) opts.body = body;
170
+ let r;
171
+ try {
172
+ r = await globalThis.fetch(url, opts);
173
+ } catch (e) {
174
+ last = { status: 0, ok: false, ct: '', body: null, authStyle: null, error: e };
175
+ break; // network error is not an auth problem — do not retry other styles
176
+ }
177
+ const ct = r.headers.get('content-type') || '';
178
+ let parsed;
179
+ if (binary) parsed = Buffer.from(await r.arrayBuffer());
180
+ else if (ct.includes('json')) parsed = await r.json().catch(() => null);
181
+ else parsed = await r.text().catch(() => null);
182
+ last = { status: r.status, ok: r.ok, ct, body: parsed, authStyle: Object.keys(auth)[0] || null };
183
+ if (r.status !== 401 && r.status !== 403) return last; // accepted (or a non-auth error)
184
+ // else: this auth style was rejected — fall through and try the next
185
+ }
186
+ return last;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Images
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Generate a still via the v1 images path; on failure fall back to the classic
195
+ * `deployments/<dep>` path @2025-04-01-preview (discrete size). Returns a PNG Buffer.
196
+ * `onNote(msg)` (optional) is invoked when the classic fallback is taken.
197
+ */
198
+ export async function generateImage({
199
+ endpoint, key, deployment, apiVersion = 'preview',
200
+ prompt, size = '720x1280', quality = 'low', n = 1, onNote,
201
+ } = {}) {
202
+ const genBody = { model: deployment, prompt, size, quality, n };
203
+ let r = await azFetch(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, {
204
+ key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(genBody),
205
+ });
206
+ let note = 'v1';
207
+ if (!(r.ok && r.body?.data?.[0]?.b64_json)) {
208
+ note = 'classic-fallback(2025-04-01-preview)';
209
+ if (typeof onNote === 'function') onNote(`generateImage: v1 path failed (HTTP ${r.status}); trying ${note}`);
210
+ r = await azFetch(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, {
211
+ key, method: 'POST', headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ ...genBody, size: classicSize(size) }),
213
+ });
214
+ }
215
+ const b64 = r.body?.data?.[0]?.b64_json;
216
+ if (!(r.ok && b64)) throw new Error(`generateImage failed (${note}): HTTP ${r.status} ${truncate(r.body)}`);
217
+ return Buffer.from(b64, 'base64');
218
+ }
219
+
220
+ /** Edit an image (optional mask) via the v1 images/edits multipart path. Returns a PNG Buffer. */
221
+ export async function editImage({
222
+ endpoint, key, deployment, apiVersion = 'preview', prompt, imageBuf, maskBuf, size,
223
+ } = {}) {
224
+ const fd = new FormData();
225
+ fd.append('model', deployment);
226
+ fd.append('prompt', prompt);
227
+ if (size) fd.append('size', size);
228
+ fd.append('image', new Blob([imageBuf], { type: 'image/png' }), 'image.png');
229
+ if (maskBuf) fd.append('mask', new Blob([maskBuf], { type: 'image/png' }), 'mask.png');
230
+ const r = await azFetch(`${endpoint}/openai/v1/images/edits?api-version=${apiVersion}`, {
231
+ key, method: 'POST', body: fd,
232
+ });
233
+ const b64 = r.body?.data?.[0]?.b64_json;
234
+ if (!(r.ok && b64)) throw new Error(`editImage failed: HTTP ${r.status} ${truncate(r.body)}`);
235
+ return Buffer.from(b64, 'base64');
236
+ }
237
+
238
+ /** Generate a still via the Black Forest Labs FLUX provider path. Returns a PNG Buffer. */
239
+ export async function generateImageFlux({ endpoint, key, prompt, width, height, apiVersion = 'preview' } = {}) {
240
+ const r = await azFetch(`${endpoint}/providers/blackforestlabs/v1/flux-2-pro?api-version=${apiVersion}`, {
241
+ key, method: 'POST', headers: { 'Content-Type': 'application/json' },
242
+ body: JSON.stringify({ prompt, width, height }),
243
+ });
244
+ const b64 = r.body?.data?.[0]?.b64_json || r.body?.b64_json || r.body?.image;
245
+ if (!(r.ok && b64)) throw new Error(`generateImageFlux failed: HTTP ${r.status} ${truncate(r.body)}`);
246
+ return Buffer.from(b64, 'base64');
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Video (Sora)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ const TERMINAL_STATUS = new Set(['completed', 'succeeded', 'failed', 'cancelled', 'canceled', 'error']);
254
+
255
+ /** List Sora video jobs. Returns the raw azFetch result ({status, ok, ct, body}). */
256
+ export async function listVideos({ endpoint, key, apiVersion = 'preview' } = {}) {
257
+ return azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'GET' });
258
+ }
259
+
260
+ /**
261
+ * Create a Sora video from ONE input_reference frame (forward-chaining seam). Returns the
262
+ * job object ({id, status, ...}); throws if the API did not return an id.
263
+ */
264
+ export async function createVideo({
265
+ endpoint, key, model = 'sora-2', prompt, size = '720x1280', seconds = 4, inputReferencePng, apiVersion = 'preview',
266
+ } = {}) {
267
+ const fd = new FormData();
268
+ fd.append('model', model);
269
+ fd.append('prompt', prompt);
270
+ fd.append('size', size);
271
+ fd.append('seconds', String(seconds));
272
+ if (inputReferencePng) {
273
+ fd.append('input_reference', new Blob([inputReferencePng], { type: 'image/png' }), 'input_reference.png');
274
+ }
275
+ const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
276
+ if (!(r.ok && r.body?.id)) throw new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
277
+ return r.body;
278
+ }
279
+
280
+ /**
281
+ * Poll a Sora job until a terminal status (or maxTicks). Polls first, then sleeps, so a job
282
+ * that is already terminal returns without waiting. `onTick(status, i)` is optional.
283
+ * @returns the last status object seen.
284
+ */
285
+ export async function pollVideo({
286
+ endpoint, key, id, onTick, apiVersion = 'preview', intervalMs = 5000, maxTicks = 180,
287
+ } = {}) {
288
+ const url = `${endpoint}/openai/v1/videos/${id}?api-version=${apiVersion}`;
289
+ let st = null;
290
+ for (let i = 0; i < maxTicks; i++) {
291
+ const r = await azFetch(url, { key, method: 'GET' });
292
+ st = r.body || st;
293
+ if (typeof onTick === 'function') onTick(st, i);
294
+ if (st && TERMINAL_STATUS.has(st.status)) break;
295
+ await sleep(intervalMs);
296
+ }
297
+ return st;
298
+ }
299
+
300
+ /** Download finished video bytes. Returns an mp4 Buffer; throws on non-200 / empty body. */
301
+ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview' } = {}) {
302
+ const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
303
+ key, method: 'GET', binary: true,
304
+ });
305
+ if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}`);
306
+ return r.body;
307
+ }
308
+
309
+ /**
310
+ * Probe whether the legacy two-image jobs API is available. Never throws.
311
+ * @returns {Promise<boolean>} true when the legacy jobs endpoint exists and is authorized.
312
+ */
313
+ export async function detectTwoImageSupport({ endpoint, key, apiVersion = 'preview' } = {}) {
314
+ try {
315
+ const r = await azFetch(`${endpoint}/openai/v1/video/generations/jobs?api-version=${apiVersion}`, {
316
+ key, method: 'GET',
317
+ });
318
+ // 200 = the collection lists; 400 = present but wants params. 404/401/403/0 = not usable.
319
+ return r.ok || r.status === 400;
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * UNVALIDATED: legacy two-image (first+last frame) generation via the jobs API with
327
+ * inpaint_items. This path is documented but NOT exercised by our smoke tests — field names
328
+ * and shapes may drift. Gate on detectTwoImageSupport() and fall back to single-reference
329
+ * chaining (createVideo) on any failure. Returns the raw job object on success.
330
+ */
331
+ export async function createVideoTwoImage({
332
+ endpoint, key, model = 'sora-2', prompt = '', firstPng, lastPng, size = '720x1280', seconds = 4, apiVersion = 'preview',
333
+ } = {}) {
334
+ const fd = new FormData();
335
+ fd.append('model', model);
336
+ fd.append('prompt', prompt);
337
+ fd.append('size', size);
338
+ fd.append('n_seconds', String(seconds));
339
+ // inpaint_items pins the generation to the first (frame 0) and last (frame -1) reference frames.
340
+ fd.append('inpaint_items', JSON.stringify([
341
+ { frame_index: 0, type: 'image', file_name: 'first.png' },
342
+ { frame_index: -1, type: 'image', file_name: 'last.png' },
343
+ ]));
344
+ fd.append('files', new Blob([firstPng], { type: 'image/png' }), 'first.png');
345
+ fd.append('files', new Blob([lastPng], { type: 'image/png' }), 'last.png');
346
+ const r = await azFetch(`${endpoint}/openai/v1/video/generations/jobs?api-version=${apiVersion}`, {
347
+ key, method: 'POST', body: fd,
348
+ });
349
+ const id = r.body?.id || r.body?.job_id;
350
+ if (!(r.ok && id)) throw new Error(`createVideoTwoImage (UNVALIDATED) failed: HTTP ${r.status} ${truncate(r.body)}`);
351
+ return r.body;
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // ffmpeg (lazy binary resolution + frame/seam helpers)
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Locate an ffmpeg binary without ever invoking a shell:
360
+ * 1) $FFMPEG_PATH (if it exists)
361
+ * 2) scan $PATH entries (split on path.delimiter) for ffmpeg / ffmpeg.exe
362
+ * 3) the optional ffmpeg-static package (dynamic import — may be absent)
363
+ * Throws an actionable error if none is found.
364
+ */
365
+ export async function resolveFfmpeg() {
366
+ const fromEnv = process.env.FFMPEG_PATH;
367
+ if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
368
+
369
+ const names = process.platform === 'win32' ? ['ffmpeg.exe', 'ffmpeg'] : ['ffmpeg'];
370
+ for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
371
+ for (const name of names) {
372
+ const candidate = path.join(dir, name);
373
+ if (fs.existsSync(candidate)) return candidate;
374
+ }
375
+ }
376
+
377
+ try {
378
+ const mod = await import('ffmpeg-static');
379
+ const p = mod?.default || mod;
380
+ if (typeof p === 'string' && fs.existsSync(p)) return p;
381
+ } catch { /* ffmpeg-static not installed — fall through to the error */ }
382
+
383
+ throw new Error('ffmpeg not found; run motion-graphic-setup or 3d-intro-setup');
384
+ }
385
+
386
+ // Run ffmpeg with an ARGUMENT ARRAY (never a shell string). Returns {code, stdout, stderr, args}.
387
+ function runFf(bin, args, cwd) {
388
+ const r = cp.spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28 });
389
+ return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '', args };
390
+ }
391
+
392
+ /** Extract the exact LAST frame of an mp4 to a PNG (reverse + first-frame trick). Returns out. */
393
+ export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
394
+ const bin = ffmpeg || await resolveFfmpeg();
395
+ const r = runFf(bin, ['-y', '-i', mp4, '-vf', 'reverse', '-frames:v', '1', out]);
396
+ if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractLastFrame failed: ${tail(r.stderr)}`);
397
+ return out;
398
+ }
399
+
400
+ /** Extract the FIRST frame of an mp4 to a PNG. Returns out. */
401
+ export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
402
+ const bin = ffmpeg || await resolveFfmpeg();
403
+ const r = runFf(bin, ['-y', '-i', mp4, '-frames:v', '1', out]);
404
+ if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractFirstFrame failed: ${tail(r.stderr)}`);
405
+ return out;
406
+ }
407
+
408
+ /**
409
+ * Tone/color continuity pass on srcPng. Stock ffmpeg has no true "match A's histogram to B"
410
+ * filter, so this uses histeq — per-channel histogram equalization — the simplest robust
411
+ * histogram normalization, which keeps a chained clip's first frame from drifting in
412
+ * contrast/tone. refPng is accepted for API symmetry and a future CLUT-based exact match; it
413
+ * is not fed to ffmpeg today (an unmapped extra input would make ffmpeg error). Returns out.
414
+ */
415
+ export async function colorMatch(srcPng, refPng, out, { ffmpeg } = {}) {
416
+ const bin = ffmpeg || await resolveFfmpeg();
417
+ void refPng; // reserved (see doc comment)
418
+ const r = runFf(bin, ['-y', '-i', srcPng, '-vf', 'format=rgb24,histeq', '-frames:v', '1', out]);
419
+ if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`colorMatch failed: ${tail(r.stderr)}`);
420
+ return out;
421
+ }
422
+
423
+ /**
424
+ * Crossfade clip A into clip B with an `xfade` transition of `sec` seconds. `offset` (opts) is
425
+ * where the fade starts in A; when omitted it is derived from A's probed duration (dur - sec).
426
+ * Returns out.
427
+ */
428
+ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } = {}) {
429
+ const bin = ffmpeg || await resolveFfmpeg();
430
+ let off = offset;
431
+ if (off == null) {
432
+ const dur = probeDurationSec(bin, aMp4);
433
+ off = dur != null ? Math.max(0, dur - sec) : 0;
434
+ }
435
+ const filter = `[0:v][1:v]xfade=transition=fade:duration=${sec}:offset=${off}`;
436
+ const args = ['-y', '-i', aMp4, '-i', bMp4, '-filter_complex', filter, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', out];
437
+ const r = runFf(bin, args);
438
+ if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`crossfade failed: ${tail(r.stderr)}`);
439
+ return out;
440
+ }
441
+
442
+ /**
443
+ * Concatenate mp4 clips into one continuous video via the concat filter (robust to differing
444
+ * codecs; normalizes fps/scale/sar/pix_fmt first). Returns out.
445
+ */
446
+ export async function concatClips(mp4List, out, { ffmpeg, fps = 30, width = 720, height = 1280 } = {}) {
447
+ const bin = ffmpeg || await resolveFfmpeg();
448
+ const list = Array.isArray(mp4List) ? mp4List : [];
449
+ if (list.length === 0) throw new Error('concatClips: no input clips');
450
+ const args = ['-y'];
451
+ for (const f of list) args.push('-i', f);
452
+ const norm = list.map((_, i) => `[${i}:v]fps=${fps},scale=${width}:${height},setsar=1,format=yuv420p[v${i}]`);
453
+ const chain = `${list.map((_, i) => `[v${i}]`).join('')}concat=n=${list.length}:v=1[v]`;
454
+ args.push('-filter_complex', `${norm.join(';')};${chain}`, '-map', '[v]', out);
455
+ const r = runFf(bin, args);
456
+ if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`concatClips failed: ${tail(r.stderr)}`);
457
+ return out;
458
+ }
459
+
460
+ // Probe an mp4's duration (seconds) by parsing ffmpeg's stderr banner. Returns null if absent.
461
+ function probeDurationSec(bin, mp4) {
462
+ const r = cp.spawnSync(bin, ['-i', mp4], { encoding: 'utf8' });
463
+ const text = `${r.stderr || ''}${r.stdout || ''}`;
464
+ const m = text.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
465
+ if (!m) return null;
466
+ return (+m[1]) * 3600 + (+m[2]) * 60 + parseFloat(m[3]);
467
+ }
468
+
469
+ /** Read a PNG's dimensions from its IHDR bytes (width@16, height@20) — no ffprobe. */
470
+ export function probeDims(png) {
471
+ const buf = Buffer.isBuffer(png) ? png : fs.readFileSync(png);
472
+ if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47) throw new Error('probeDims: not a PNG');
473
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Cost
478
+ // ---------------------------------------------------------------------------
479
+
480
+ const VIDEO_USD_PER_SEC = 0.10;
481
+ const IMAGE_USD_EACH = 0.02;
482
+
483
+ /**
484
+ * Rough budget for an intro of `nScenes` scenes. Video billed at $0.10/sec/clip; one image gen
485
+ * per scene (two per scene in two-image mode). Returns per-category USD sub-costs and the total.
486
+ * @returns {{images:number, videos:number, usd:number}}
487
+ */
488
+ export function estimateCost({ nScenes, seconds = 4, twoImage = false } = {}) {
489
+ const n = Number(nScenes) || 0;
490
+ const imageCount = twoImage ? n * 2 : n;
491
+ const images = round2(imageCount * IMAGE_USD_EACH);
492
+ const videos = round2(n * seconds * VIDEO_USD_PER_SEC);
493
+ const usd = round2(images + videos);
494
+ return { images, videos, usd };
495
+ }