@kaydash9999/banker-plugins 0.21.0 → 0.23.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 (129) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +69 -0
  4. package/PRIVACY.md +8 -3
  5. package/README.md +78 -15
  6. package/bin/banker.js +154 -32
  7. package/bin/lib/codex-hooks.mjs +123 -20
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +204 -0
  10. package/bin/lib/command-skill.cjs +50 -0
  11. package/bin/lib/telemetry-config.mjs +2 -1
  12. package/codex/command-skills/front-qa/SKILL.md +56 -0
  13. package/codex/command-skills/setup/SKILL.md +87 -0
  14. package/codex/hooks.json +29 -0
  15. package/codex/manifest.json +18 -15
  16. package/codex/skills/graceful-pause/SKILL.md +2 -2
  17. package/codex/skills/progress/scripts/progress.mjs +44 -14
  18. package/codex/skills/teamwork/SKILL.md +137 -0
  19. package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
  20. package/codex/transform-matrix.md +5 -3
  21. package/commands/setup.md +2 -2
  22. package/hooks/obsidize-hook.mjs +75 -32
  23. package/hooks/telemetry-count-codex.mjs +303 -35
  24. package/hooks/update-fetch.mjs +2 -1
  25. package/hooks/update-notify.mjs +146 -13
  26. package/package.json +3 -1
  27. package/skills/3d-intro-build/SKILL.md +101 -20
  28. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  29. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  30. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  31. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  32. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  33. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  34. package/skills/3d-intro-build/references/prompts.md +51 -4
  35. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  36. package/skills/3d-intro-build/references/step-nav.css +29 -0
  37. package/skills/3d-intro-build/references/step-nav.js +277 -106
  38. package/skills/3d-intro-build/references/verify-intro.mjs +387 -32
  39. package/skills/3d-intro-build/references/verify.md +6 -0
  40. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  41. package/skills/3d-intro-setup/SKILL.md +20 -7
  42. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  43. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  44. package/skills/all-in-one/SKILL.md +125 -86
  45. package/skills/anti-drm/SKILL.md +1 -1
  46. package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
  47. package/skills/arch-diagram/build_pptx_template.py +47 -28
  48. package/skills/audit-web-page/SKILL.md +1 -1
  49. package/skills/audit-web-page/harness.py +0 -1
  50. package/skills/lineage/lineage.py +1435 -533
  51. package/skills/motion-graphic-make/SKILL.md +1 -1
  52. package/skills/obsidizer/SKILL.md +20 -5
  53. package/skills/omc-patch/SKILL.md +1 -1
  54. package/skills/payload-mon/SKILL.md +2 -2
  55. package/skills/play-qa/SKILL.md +7 -5
  56. package/skills/progress-mon/README.md +162 -0
  57. package/skills/progress-mon/SKILL.md +165 -0
  58. package/skills/progress-mon/assets/fixture/README.md +1 -0
  59. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  60. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  61. package/skills/progress-mon/assets/fixture/config.json +30 -0
  62. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  63. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  64. package/skills/progress-mon/assets/fixture/items.json +14 -0
  65. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  66. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  67. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  68. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  69. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  70. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  71. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  72. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  73. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  74. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  75. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  76. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  77. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  78. package/skills/progress-mon/references/arch.md +280 -0
  79. package/skills/progress-mon/references/authoring.md +199 -0
  80. package/skills/progress-mon/references/config.md +115 -0
  81. package/skills/progress-mon/references/flow.md +75 -0
  82. package/skills/progress-mon/references/inventory.md +58 -0
  83. package/skills/progress-mon/references/items.md +74 -0
  84. package/skills/progress-mon/references/req-spec.md +74 -0
  85. package/skills/progress-mon/references/reqs.md +30 -0
  86. package/skills/progress-mon/scripts/index.js +31 -0
  87. package/skills/progress-mon/scripts/pm-check.mjs +526 -0
  88. package/skills/progress-mon/scripts/pm-config.mjs +341 -0
  89. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  90. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  91. package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
  92. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  93. package/skills/progress-mon/scripts/pm-server.mjs +735 -0
  94. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  95. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  96. package/skills/ralph-qa/SKILL.md +105 -16
  97. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  98. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  99. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  100. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  101. package/skills/ralph-qa/references/seat-run.mjs +159 -155
  102. package/skills/remains/SKILL.md +2 -1
  103. package/skills/remains/scripts/boxes.mjs +6 -2
  104. package/skills/rfp-author/scripts/md2docx.py +63 -34
  105. package/skills/setup-bypass-permissions/SKILL.md +2 -2
  106. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  107. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
  108. package/skills/setup-mcp/SKILL.md +1 -1
  109. package/skills/setup-omc-hud/SKILL.md +2 -1
  110. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  111. package/skills/smart-compact/SKILL.md +1 -1
  112. package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
  113. package/skills/theme-selection/SKILL.md +1 -1
  114. package/skills/tone-compact/SKILL.md +1 -1
  115. package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
  116. package/skills/ultra-chain/SKILL.md +34 -16
  117. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  118. package/skills/ultra-init/SKILL.md +30 -22
  119. package/skills/ultra-ui-qa/SKILL.md +1 -1
  120. package/skills/ultraqa/NOTICE.md +34 -0
  121. package/skills/ultraqa/SKILL.md +196 -0
  122. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  123. package/skills/ultraqa/references/run-folder.mjs +299 -0
  124. package/skills/ultraqa/references/tmux-drive.md +52 -0
  125. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  126. package/skills/update-banker/SKILL.md +32 -7
  127. package/skills/vertical-pptx/SKILL.md +1 -1
  128. package/skills/vertical-pptx/build_a4p.js +20 -2
  129. package/skills/vertical-pptx/build_a4p.py +560 -359
@@ -28,7 +28,9 @@
28
28
  *
29
29
  * Pool state lives in ~/.config/banker/3d-intro/video-pool-state.json (0600). It holds
30
30
  * FINGERPRINTS (sha256 of endpoint+key+model), never a key, so a rotated key or a changed model
31
- * starts with a clean record. State is advisory: a failed write is reported, never fatal.
31
+ * starts with a clean record. A Sora deployment that answered 410 (retired) is recorded under the
32
+ * fingerprint of its endpoint origin and deployment name, and Sora is skipped for 24 h.
33
+ * State is advisory: a failed write is reported, never fatal.
32
34
  * Concurrent processes share the file last-write-wins; the per-key in-flight limit is per process.
33
35
  *
34
36
  * Runtime: Node >=18 builtins only. globalThis.fetch and cp.spawnSync are read at call time so
@@ -40,7 +42,7 @@ import os from 'node:os';
40
42
  import crypto from 'node:crypto';
41
43
  import cp from 'node:child_process';
42
44
  import {
43
- createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey, keyHeldBack,
45
+ createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey, keyHeldBack, probeSoraDeployment,
44
46
  API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft,
45
47
  } from './azure-adapter.mjs';
46
48
 
@@ -213,13 +215,17 @@ function updateEntry(file, entry, fn, emit) {
213
215
  }
214
216
  }
215
217
 
216
- /** Clear recorded state for all entries, or only for the given labels (e.g. ['WAN_2']). */
218
+ /**
219
+ * Clear recorded state for all entries, or only for the given labels (e.g. ['WAN_2']). 'AZURE_SORA'
220
+ * clears the Sora retirement record (every Sora deployment's, so creds are not needed for it).
221
+ */
217
222
  export function resetPoolState({ statePath = defaultStatePath(), creds, labels } = {}) {
218
223
  const state = loadPoolState(statePath);
219
224
  if (!labels) state.entries = {};
220
225
  else {
221
226
  const fps = new Set(loadWanPool(creds).filter((e) => labels.includes(e.label)).map((e) => e.fp));
222
- for (const fp of Object.keys(state.entries)) if (fps.has(fp)) delete state.entries[fp];
227
+ const sora = labels.includes(SORA_LABEL);
228
+ for (const [fp, st] of Object.entries(state.entries)) if (fps.has(fp) || (sora && st?.label === SORA_LABEL)) delete state.entries[fp];
223
229
  }
224
230
  return savePoolState(state, statePath);
225
231
  }
@@ -235,14 +241,62 @@ function entryStatus(st, t) {
235
241
  return { status: 'ok' };
236
242
  }
237
243
 
238
- /** Per-entry availability for reporting. Never includes a key, nor a malformed endpoint or model. */
244
+ /**
245
+ * Per-entry availability for reporting: one row per WAN entry (provider 'wan'), then one Sora row
246
+ * (label AZURE_SORA, provider 'sora', status ok | retired | unset | malformed). Never includes a key,
247
+ * nor a malformed endpoint or model.
248
+ */
239
249
  export function poolSummary({ creds = {}, statePath = defaultStatePath(), now = Date.now() } = {}) {
240
250
  const state = loadPoolState(statePath);
241
- return loadWanPool(creds).map((e) => {
251
+ const wan = loadWanPool(creds).map((e) => {
242
252
  const st = state.entries[e.fp];
243
253
  const field = malformedField(e);
244
- return { ...shownEntry(e), okCount: st?.okCount || 0, ...(field ? { status: 'malformed', reason: field } : entryStatus(st, now)) };
254
+ return { ...shownEntry(e), provider: 'wan', okCount: st?.okCount || 0, ...(field ? { status: 'malformed', reason: field } : entryStatus(st, now)) };
245
255
  });
256
+ return [...wan, soraSummary(creds, state, now)];
257
+ }
258
+
259
+ // The Sora row of poolSummary.
260
+ function soraSummary(creds, state, t) {
261
+ const conn = soraConn(creds);
262
+ const base = { label: SORA_LABEL, provider: 'sora', model: isModelId(conn.model) ? conn.model : '(invalid model)' };
263
+ if (!conn.endpoint || !conn.key) return { ...base, host: null, status: 'unset' };
264
+ if (!originOf(conn.endpoint)) return { ...base, host: '(invalid endpoint)', status: 'malformed', reason: 'endpoint' };
265
+ const rec = retiredIn(state, conn, t);
266
+ const status = rec ? { status: 'retired', reason: rec.reason, retiredAt: rec.retiredAt, until: rec.retiredAt + SORA_RETIRED_TTL_MS } : { status: 'ok' };
267
+ return { ...base, host: hostOf(originOf(conn.endpoint)), ...status };
268
+ }
269
+
270
+ /**
271
+ * The CLIP_MODE a run starts with (FREE, nothing is sent): dive without exits on any provider (the user
272
+ * found it the most natural of the three on the sora-2 comparison page). Returns { mode, exit, provider,
273
+ * measured, reason }: provider is the first one generateClip would really use (VIDEO_PROVIDER_ORDER), and
274
+ * measured is false on WAN (dive on WAN is not measured; say so when the user picks). mode is null when
275
+ * no leg could be made: every WAN entry refused (generateClip stops there and does not use Sora), a Sora
276
+ * endpoint that is not https, or nothing set up (no WAN entry, Sora unset or recorded retired).
277
+ */
278
+ export function defaultClipMode(creds = {}, { statePath = defaultStatePath(), now = Date.now() } = {}) {
279
+ for (const p of parseProviderOrder(creds.VIDEO_PROVIDER_ORDER)) {
280
+ const r = p === 'wan' ? wanMode(creds) : soraMode(creds, loadPoolState(statePath), now);
281
+ if (r) return r;
282
+ }
283
+ return { mode: null, provider: null, reason: 'no video provider: no WAN entry, and Sora is unset or recorded retired' };
284
+ }
285
+
286
+ function wanMode(creds) {
287
+ const pool = loadWanPool(creds);
288
+ if (!pool.length) return null;
289
+ if (pool.every((e) => entryRefusal(e))) return { mode: null, provider: 'wan', reason: 'every WAN entry is malformed or held back: generateClip stops there and does not use Sora' };
290
+ return { mode: 'dive', exit: false, provider: 'wan', measured: false, reason: 'the video runs on WAN: dive without exits (dive on WAN is not measured)' };
291
+ }
292
+
293
+ function soraMode(creds, state, t) {
294
+ const conn = soraConn(creds);
295
+ if (!conn.endpoint || !conn.key) return null;
296
+ const origin = originOf(conn.endpoint);
297
+ if (!origin || proxyHolds(origin)) return { mode: null, provider: 'sora', reason: 'AZURE_SORA_ENDPOINT is not an https URL (or the env proxy holds the key back): the leg stops before sending' };
298
+ if (retiredIn(state, conn, t)) return null;
299
+ return { mode: 'dive', exit: false, provider: 'sora', measured: true, reason: 'the video runs on Sora: dive without exits (measured on sora-2 2025-12-08)' };
246
300
  }
247
301
 
248
302
  // In-process in-flight counter per fingerprint (the documented limit is per key).
@@ -728,6 +782,60 @@ function soraConn(creds) {
728
782
  };
729
783
  }
730
784
 
785
+ // ---------------------------------------------------------------------------
786
+ // Sora retirement (HTTP 410). A 410 on the submit is the model's retirement at once. A 410 on a poll
787
+ // or a download may also be a job that expired, so it counts only when the free deployment probe says
788
+ // retired. The record keeps Sora from being sent the next legs for 24 h.
789
+ // ---------------------------------------------------------------------------
790
+
791
+ const SORA_LABEL = 'AZURE_SORA';
792
+ const SORA_RETIRED_TTL_MS = 24 * 3_600_000;
793
+ // The Sora record's name in the state file: endpoint origin and deployment, never the key.
794
+ const soraFp = (conn) => `sora:${crypto.createHash('sha256').update(`${originOf(conn.endpoint)}\n${conn.model}`).digest('hex').slice(0, 16)}`;
795
+
796
+ function retiredIn(state, conn, t) {
797
+ const st = state.entries[soraFp(conn)];
798
+ return st?.status === 'retired' && t - st.retiredAt < SORA_RETIRED_TTL_MS ? st : null;
799
+ }
800
+
801
+ function recordSoraRetired(ctx, conn, stage) {
802
+ const t = ctx.now();
803
+ updateEntry(ctx.statePath, { fp: soraFp(conn), label: SORA_LABEL }, (st) => ({ ...st, status: 'retired', retiredAt: t, reason: `HTTP 410 (${stage})` }), ctx.emit);
804
+ ctx.emit({ type: 'fail', provider: 'sora', label: SORA_LABEL, kind: 'retired', status: 410, stage });
805
+ }
806
+
807
+ const soraWhere = (conn) => `Sora deployment ${isModelId(conn.model) ? conn.model : '(invalid model)'} on ${hostOf(originOf(conn.endpoint))}`;
808
+ const SORA_RETIRED_NEXT = "add a WAN key (3d-intro-setup), or deploy a newer Sora model and clear the record with resetPoolState({ labels: ['AZURE_SORA'] })";
809
+
810
+ function soraRetiredStop(conn, stage, taskId) {
811
+ const job = taskId ? `job ${taskId} may have billed` : 'no job was made';
812
+ return stopError('retired', `${soraWhere(conn)} is retired (HTTP 410 on the ${stage}); ${job}; Sora is skipped for 24 h — ${SORA_RETIRED_NEXT}`, { provider: 'sora', label: SORA_LABEL, status: 410, ...(taskId ? { taskId } : {}) });
813
+ }
814
+
815
+ // Sora was skipped for its record, and no other provider took the leg.
816
+ function soraSkippedStop(conn, rec) {
817
+ return stopError('retired', `${soraWhere(conn)} was recorded retired (${rec.reason}) at ${new Date(rec.retiredAt).toISOString()}, and no WAN key could take the leg; nothing was sent — ${SORA_RETIRED_NEXT}`, { provider: 'sora', label: SORA_LABEL, retiredAt: rec.retiredAt });
818
+ }
819
+
820
+ // One free probe per process and deployment.
821
+ const soraProbes = new Map();
822
+ function probeSoraOnce(conn, now) {
823
+ const fp = soraFp(conn);
824
+ if (!soraProbes.has(fp)) {
825
+ soraProbes.set(fp, probeSoraDeployment({ endpoint: conn.endpoint, key: conn.key, deployment: conn.model, now }).catch(() => ({ state: 'unknown', status: null })));
826
+ }
827
+ return soraProbes.get(fp);
828
+ }
829
+
830
+ // The retired stop for a 410 on a poll or a download when the probe confirms it, else null.
831
+ async function confirmRetired(ctx, conn, stage, jobId) {
832
+ const p = await probeSoraOnce(conn, ctx.now);
833
+ ctx.emit({ type: 'probe', provider: 'sora', label: SORA_LABEL, stage, state: p.state, status: p.status ?? null });
834
+ if (p.state !== 'retired') return null;
835
+ recordSoraRetired(ctx, conn, stage);
836
+ return soraRetiredStop(conn, stage, jobId);
837
+ }
838
+
731
839
  // Poll an accepted Sora job to a downloaded clip; same stop rules as finishWanTask.
732
840
  async function finishSoraJob(ctx, jobId, secs) {
733
841
  const conn = soraConn(ctx.creds);
@@ -737,13 +845,14 @@ async function finishSoraJob(ctx, jobId, secs) {
737
845
  if (!st || !SORA_DONE.has(st.status)) {
738
846
  throw stopError('timeout', `Sora job ${jobId} still ${st?.status ?? 'unknown'} after polling for ${ctx.now() - t0} ms; resume with resumeClip`, { provider: 'sora', taskId: jobId, label: 'AZURE_SORA', seconds: secs });
739
847
  }
740
- const mp4 = await downloadSoraClip(conn, jobId, secs);
848
+ const mp4 = await downloadSoraClip(ctx, conn, jobId, secs);
741
849
  ctx.emit({ type: 'done', provider: 'sora', label: 'AZURE_SORA', taskId: jobId });
742
850
  return { mp4, provider: 'sora', label: 'AZURE_SORA', model: conn.model, seconds: secs, taskId: jobId, usage: null };
743
851
  }
744
852
 
745
853
  // pollVideo throws on a 4xx other than 408/429 (the job is gone, or the key is refused): such a job
746
- // never turns terminal, so the leg ends at once as task_lost instead of polling to the deadline.
854
+ // never turns terminal, so the leg ends at once as task_lost instead of polling to the deadline. A 410
855
+ // the deployment probe calls retired ends it as retired.
747
856
  async function pollSoraJob(ctx, conn, jobId) {
748
857
  const interval = Math.max(0, ctx.pollIntervalMs);
749
858
  try {
@@ -754,6 +863,8 @@ async function pollSoraJob(ctx, conn, jobId) {
754
863
  });
755
864
  } catch (e) {
756
865
  if (!e.status) throw e;
866
+ const retired = e.status === 410 && await confirmRetired(ctx, conn, 'poll', jobId);
867
+ if (retired) throw retired;
757
868
  throw stopError('task_lost', `Sora poll for job ${jobId} failed: HTTP ${e.status}${e.code ? ` ${e.code}` : ''}; check the job in Azure before resubmitting`, { provider: 'sora', taskId: jobId, label: 'AZURE_SORA', status: e.status, code: e.code });
758
869
  }
759
870
  }
@@ -764,10 +875,12 @@ function soraFailure(st, jobId) {
764
875
  return stopError('failed', `Sora job ${jobId} ended ${st.status}: ${JSON.stringify(st.error || '')}`, { provider: 'sora', taskId: jobId, code });
765
876
  }
766
877
 
767
- async function downloadSoraClip(conn, jobId, secs) {
878
+ async function downloadSoraClip(ctx, conn, jobId, secs) {
768
879
  try {
769
880
  return await downloadVideo({ endpoint: conn.endpoint, key: conn.key, id: jobId, apiVersion: conn.apiVersion });
770
881
  } catch (err) {
882
+ const retired = err.status === 410 && await confirmRetired(ctx, conn, 'download', jobId);
883
+ if (retired) throw retired;
771
884
  throw stopError('download', `${err.message} for Sora job ${jobId}; resume with resumeClip`, { provider: 'sora', taskId: jobId, label: 'AZURE_SORA', seconds: secs });
772
885
  }
773
886
  }
@@ -784,7 +897,8 @@ const SORA_SUBMIT_TRIES = 5;
784
897
 
785
898
  // A 429 is a refusal, so retrying it cannot double-bill; a lost connection or a bare 5xx can. Each
786
899
  // wait is the server's Retry-After, else 30 s doubling, from 1 s to 10 min. A Retry-After longer
787
- // than maxRateWaitMs is not waited out: the leg stops as `rate` at once, as WAN's waits do.
900
+ // than maxRateWaitMs is not waited out: the leg stops as `rate` at once, as WAN's waits do. A 410 is
901
+ // the deployment's retirement: recorded, and the leg stops as `retired`.
788
902
  async function submitSora(ctx, conn, secs) {
789
903
  for (let i = 0; ; i++) {
790
904
  try {
@@ -793,6 +907,7 @@ async function submitSora(ctx, conn, secs) {
793
907
  endpoint: conn.endpoint, key: conn.key, model: conn.model, prompt: ctx.prompt, size: ctx.size, seconds: secs, inputReferencePng: ctx.firstFramePng, apiVersion: conn.apiVersion,
794
908
  });
795
909
  } catch (e) {
910
+ if (e.status === 410) { recordSoraRetired(ctx, conn, 'submit'); throw soraRetiredStop(conn, 'submit'); }
796
911
  if (e.status !== 429 || i >= SORA_SUBMIT_TRIES - 1) throw soraSubmitError(e);
797
912
  const want = e.retryAfterMs ?? RATE_BASE_MS * 2 ** i;
798
913
  if (want > ctx.maxRateWaitMs) throw soraRateStop(want, ctx.maxRateWaitMs, e.retryAfterMs != null);
@@ -815,6 +930,10 @@ function soraSubmitError(e) {
815
930
  if (e.status === 429) {
816
931
  return stopError('rate', `Sora still answered HTTP 429 after ${SORA_SUBMIT_TRIES} tries; no job was made — wait, then generate the leg again`, { provider: 'sora', status: 429 });
817
932
  }
933
+ if (e.status === 404) {
934
+ // only the status and the code field are read: a message is no contract
935
+ return stopError('invalid_request', `${e.message}; the deployment name may be wrong, or the deployment was removed after its model retired — check AZURE_SORA_DEPLOYMENT (3d-intro-setup §4 checks it for free)`, { provider: 'sora', status: 404, code: e.code || null });
936
+ }
818
937
  if (soraMayHaveJob(e)) {
819
938
  const answer = e.status === 0 ? e.errno || 'network' : `HTTP ${e.status}`;
820
939
  return stopError('submit_unknown', `Sora submit got no usable answer (${answer}); the job may exist — check Azure before resubmitting`, { provider: 'sora', status: e.status });
@@ -851,6 +970,10 @@ const DEFAULTS = { size: '720x1280', seconds: 5, pollIntervalMs: 15_000, pollTim
851
970
  * kind 'unreachable' Sora host refused the connection (nothing was sent)
852
971
  * kind 'rate' Sora kept answering 429, or asked (Retry-After, err.retryAfterMs) for a
853
972
  * wait past maxRateWaitMs (no job was made); wait, then call again
973
+ * kind 'retired' the Sora deployment answered 410 (on the submit, or on a poll or a
974
+ * download the free deployment probe confirmed), or was recorded so in
975
+ * the last 24 h and no WAN key could take the leg; add a WAN key, or
976
+ * deploy a newer model and resetPoolState({ labels: ['AZURE_SORA'] })
854
977
  * @returns {Promise<{mp4:Buffer, provider:'wan'|'sora', label:string, model:string, seconds:number, taskId:string, usage:object|null}>}
855
978
  */
856
979
  export async function generateClip(opts = {}) {
@@ -867,17 +990,24 @@ export async function generateClip(opts = {}) {
867
990
  const clip = await tryProvider(ctx, p, tried);
868
991
  if (clip) return clip;
869
992
  }
993
+ if (ctx.soraRetired) throw soraSkippedStop(soraConn(o.creds), ctx.soraRetired);
870
994
  throw new Error(`generateClip: no video provider available (tried: ${tried.join(', ') || 'none'})`);
871
995
  }
872
996
 
873
997
  // One provider's go at the leg: the clip, or null to go on to the next. Sora, once it runs, ends the
874
- // leg (with a clip or a throw).
998
+ // leg (with a clip or a throw). A Sora deployment recorded retired is skipped before anything is sent.
875
999
  async function tryProvider(ctx, p, tried) {
876
1000
  if (p === 'wan') {
877
1001
  tried.push('wan');
878
1002
  return runWan(ctx);
879
1003
  }
880
1004
  if (p !== 'sora' || !soraReady(ctx)) return null;
1005
+ const rec = retiredIn(loadPoolState(ctx.statePath), soraConn(ctx.creds), ctx.now());
1006
+ if (rec) {
1007
+ ctx.emit({ type: 'skip', provider: 'sora', label: SORA_LABEL, reason: 'retired', retiredAt: rec.retiredAt });
1008
+ ctx.soraRetired = rec;
1009
+ return null;
1010
+ }
881
1011
  tried.push('sora');
882
1012
  ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
883
1013
  return runSora(ctx);
@@ -69,7 +69,7 @@ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https:/
69
69
  OS별 상세 설치 절차(Ubuntu NodeSource·RHEL8/Rocky8 dnf 모듈·Windows winget·PATH 충돌 등)는 반복하지 않고 **`setup-node` 스킬로 위임**한다 — 이 스킬은 결과(`node -v` ≥18)만 확인한다.
70
70
 
71
71
  ## 2. ffmpeg 확보
72
- 어댑터의 프레임 추출·컬러 매치·크로스페이드·이어붙이기(`extractLastFrame`·`colorMatch`·`crossfade`·`concatClips`, forward-chaining 이음새 처리)가 `ffmpeg` 바이너리를 요구한다.
72
+ 어댑터의 프레임 추출·크로스페이드·이어붙이기(`extractLastFrame`·`crossfade`·`concatClips`, forward-chaining 이음새 처리)와 build 의 클립 후처리(`clip-post.mjs` 의 `reverseClip`·`landOnStill`·`cameraMotion`)가 `ffmpeg` 바이너리를 요구한다.
73
73
  설치 절차는 `motion-graphic-setup` §2 와 같으므로 반복하지 않고 요약만 남긴다(전체 함정은 그 스킬 참조. 마지막 폴백의 설치 위치만 다르다):
74
74
  - macOS: `brew install ffmpeg`
75
75
  - Ubuntu/Debian: `sudo apt-get install -y ffmpeg`
@@ -110,7 +110,10 @@ node -e "
110
110
  const c = resolveCreds({ projectDir: process.cwd() });
111
111
  const need = ['AZURE_SORA_ENDPOINT','AZURE_SORA_API_KEY','AZURE_IMAGE_OPENAI_ENDPOINT','AZURE_IMAGE_API_KEY','AZURE_GPT_IMAGE_DEPLOYMENT'];
112
112
  const missing = need.filter(k => !c[k]);
113
- if (c._tracked) console.log('주의: 이 프로젝트 키 파일은 git 이 추적한다(' + c._source + '). 키를 적으면 커밋에 실린다. git rm --cached 로 추적을 풀거나 홈 키 파일을 쓴다');
113
+ if (c._tracked) { // 값을 하나도 내주지 않으므로 '누락' 보다 먼저 알리고 멈춘다
114
+ console.log('주의: 이 프로젝트 키 파일은 git 이 추적한다(' + c._source + '). 저장소와 함께 온 값일 수 있어 읽지 않았다(적힌 이름: ' + c._names.join(',') + '). 키를 적으면 커밋에도 실린다. git rm --cached 로 추적을 풀거나 홈 키 파일을 쓴다');
115
+ process.exit(1);
116
+ }
114
117
  if (c._source && missing.length === 0) {
115
118
  console.log('기존 크레덴셜 발견:', c._source, '| Sora key', redact(c.AZURE_SORA_API_KEY), '| Image key', redact(c.AZURE_IMAGE_API_KEY));
116
119
  process.exit(0);
@@ -243,11 +246,11 @@ FLUX 는 이 스킬의 §4 프리플라이트 대상이 아니다(무과금으
243
246
 
244
247
  ## 4. 프리플라이트 (무과금)
245
248
  **여기서는 어떤 유료 생성도 실행하지 않는다** — 이미지 생성, Sora-2·WAN 영상 생성 어느 쪽도 호출하지 않는다.
246
- 아래 세 가지만 수행한다: (1) Sora `listVideos` GET(목록 조회는 무료) (2) Image 는 `azFetch` 로 **body `{model:<배포명>}` (프롬프트 누락) POST** — 배포로 라우팅된 뒤 프롬프트 누락으로 생성 전 `400 missing_required_parameter` 로 거부되므로 과금 0이고, 엔드포인트·인증에 더해 **배포명까지** 무료로 검증된다. (빈 `{}` 는 model 이 없어 `404 DeploymentNotFound` 로 갈려 배포명을 확인하지 못한다.) (3) WAN 은 키마다 존재하지 않는 task 를 GET(`probeWanPool`) — 맞는 키는 200(`task_status: UNKNOWN`), 틀린 키는 `401 InvalidApiKey` 다(2026-10-09 실측).
249
+ 아래 네 가지만 수행한다: (1) Sora `listVideos` GET(목록 조회는 무료) (2) Image 는 `azFetch` 로 **body `{model:<배포명>}` (프롬프트 누락) POST** — 배포로 라우팅된 뒤 프롬프트 누락으로 생성 전 `400 missing_required_parameter` 로 거부되므로 과금 0이고, 엔드포인트·인증에 더해 **배포명까지** 무료로 검증된다. (빈 `{}` 는 model 이 없어 `404 DeploymentNotFound` 로 갈려 배포명을 확인하지 못한다.) (3) WAN 은 키마다 존재하지 않는 task 를 GET(`probeWanPool`) — 맞는 키는 200(`task_status: UNKNOWN`), 틀린 키는 `401 InvalidApiKey` 다(2026-10-09 실측). (4) Sora 배포는 `probeSoraDeployment` 의 GET 두 번 — `GET /openai/deployments/<배포명>?api-version=2022-12-01`(있으면 200 과 `status`, 없으면 404 `notFound`)과 `GET /openai/v1/models?api-version=preview` 의 그 모델 항목(`lifecycle_status`, `deprecation.inference` 퇴역일). Sora 는 이미지와 달리 프롬프트 없는 POST 로 배포를 가르지 못한다. `/openai/v1/videos` 는 모든 인자를 배포 확인보다 먼저 검사해, 없는 배포명에도 같은 400 을 준다(2026-10-29 실측). 인자 검사를 통과하는 POST 는 실제 생성(과금)이다.
247
250
  ```bash
248
251
  node -e "
249
252
  (async () => {
250
- const { resolveCreds, listVideos, azFetch, redact } = await import('$ADAPTER');
253
+ const { resolveCreds, listVideos, azFetch, redact, probeSoraDeployment } = await import('$ADAPTER');
251
254
  const c = resolveCreds({ projectDir: process.cwd() });
252
255
  if (!c._source) { console.log('크레덴셜 없음 -- §3 먼저 실행'); process.exit(2); }
253
256
  const how = (r) => (r.error ? r.errno || r.error.name : r.authStyle); // status 0 이면 이유(ENOTFOUND, TimeoutError 등)
@@ -261,7 +264,13 @@ node -e "
261
264
  const wan = await probeWanPool({ creds: c });
262
265
  for (const w of wan) console.log('wan.probe', w.label, w.host, w.model, '->', w.status, w.code || '', '| key', w.key);
263
266
  if (!wan.length) console.log('wan.probe -> WAN 항목 없음 (영상은 Sora 만 사용)');
264
- const pass = sora.status === 200 && img.status === 400 && wan.every((w) => w.authOk);
267
+ const dep = await probeSoraDeployment({ endpoint: c.AZURE_SORA_ENDPOINT, key: c.AZURE_SORA_API_KEY, deployment: c.AZURE_SORA_DEPLOYMENT || 'sora-2' });
268
+ const until = dep.retiresAt ? new Date(dep.retiresAt).toISOString().slice(0, 10) : '-';
269
+ console.log('sora.deploy ->', dep.status, dep.state, '| model', dep.modelVersion || dep.model || '-', dep.versionInferred ? '(판 추정)' : '', '| 퇴역', until);
270
+ if (dep.state === 'retired') console.log('경고: Sora 퇴역 -- 영상은 WAN 키로만 만든다(§3 WAN 영상 풀)');
271
+ if (dep.soon) console.log('안내: Sora 모델이 ' + until + ' 에 퇴역 예정(' + dep.daysLeft + '일 남음) -- 그 뒤에는 WAN 키나 새 Sora 배포가 필요');
272
+ const soraOk = dep.state === 'deployed' || (dep.state === 'retired' && wan.length > 0);
273
+ const pass = sora.status === 200 && soraOk && img.status === 400 && wan.every((w) => w.authOk);
265
274
  console.log(pass ? 'PREFLIGHT PASS -- \$0 과금' : 'PREFLIGHT FAIL');
266
275
  console.log('source:', c._source, '| Sora key', redact(c.AZURE_SORA_API_KEY), '| Image key', redact(c.AZURE_IMAGE_API_KEY));
267
276
  process.exit(pass ? 0 : 1);
@@ -272,6 +281,10 @@ node -e "
272
281
  | 체크 | 기대 | 의미 |
273
282
  |---|---|---|
274
283
  | `sora.listVideos` | HTTP 200 | Sora 엔드포인트 살아있음 + 키 인증됨 |
284
+ | `sora.deploy` | HTTP 200, `deployed` | 그 배포명이 이 리소스에 있음(GET 이라 과금 0). 모델 판과 퇴역일을 함께 보인다. 배포가 모델 이름만 알려 주면 가장 새 판을 쓴다고 보고 `(판 추정)` 을 붙인다 |
285
+ | `sora.deploy` 가 404 `no_deployment` | - | 배포명이 틀렸거나, 모델 퇴역 뒤 배포가 지워짐 — Azure 포털의 배포 목록과 `AZURE_SORA_DEPLOYMENT` 를 대조 |
286
+ | `sora.deploy` 가 `retired` | - | 배포가 410 으로 답했거나 퇴역일이 지남. WAN 항목이 있으면 경고만 하고 통과(영상은 WAN 으로), 없으면 FAIL. 새 Sora 배포를 만들거나 WAN 키를 넣는다 |
287
+ | `안내: ... 퇴역 예정` | - | 퇴역일이 14일 안. 그 날짜 뒤에는 Sora 가 410 을 준다(Foundry 모델 수명 문서: 서비스에서 빠진 모델은 410 Gone). 날짜는 리소스마다 다르니 프로브가 준 값만 쓴다 |
275
288
  | `images.badreq` | HTTP 400 (`missing_required_parameter`) | Image 엔드포인트 살아있음 + 키 인증됨 + **배포명 라우팅됨**(프롬프트 누락이라 생성 전 거부) |
276
289
  | `wan.probe` (항목마다) | HTTP 200 | WAN workspace 엔드포인트 + 키 인증됨. 남은 할당량은 이 방법으로 알 수 없다 |
277
290
  | `wan.probe` 가 401 `InvalidApiKey` | - | 키가 틀렸거나 엔드포인트와 리전이 다름 — 그 `WAN_<n>` 을 재입력 |
@@ -287,9 +300,9 @@ node -e "
287
300
  ```bash
288
301
  node -v # 18 이상 기대
289
302
  command -v ffmpeg >/dev/null && ffmpeg -version | head -1 || echo "시스템 ffmpeg 없음 -- resolveFfmpeg() 폴백 확인(§2)"
290
- # 위 §4 프리플라이트를 재실행해 PASS 문자열 + 두 status 코드를 그대로 증거로 남긴다
303
+ # 위 §4 프리플라이트를 재실행해 출력한 확인 줄 전부(listVideos, sora.deploy, images.badreq, wan)를 그대로 증거로 남긴다
291
304
  ```
292
- 4-field 보고: 변경(Node/ffmpeg 확보 방식 · 크레덴셜 저장 경로 — 파일 내용 아님) / Evidence(§0 감지 출력 · §4 status 코드 전부) / 검증(`listVideos`=200 **AND** `images.badreq`=400 **AND** WAN 항목 전부 200 이어야 PASS) / Unknown(FLUX 미검증 · WAN 키별 남은 할당량(조회 API 없음 — 소진은 첫 생성에서 드러나고 풀이 자동으로 다음 키·Sora 로 넘어감) · 배포명이 실제 존재하는지는 이 무과금 프리플라이트로 확인 불가하며 첫 실제 생성 시점에만 드러남 · 관리자 권한 제약으로 ffmpeg 시스템 설치 불가 등, 가짜 성공 금지).
305
+ 4-field 보고: 변경(Node/ffmpeg 확보 방식 · 크레덴셜 저장 경로 — 파일 내용 아님) / Evidence(§0 감지 출력 · §4 status 코드 전부) / 검증(`listVideos`=200 **AND** `sora.deploy`=`deployed`(WAN 이 있으면 `retired` 도 경고와 함께 허용) **AND** `images.badreq`=400 **AND** WAN 항목 전부 200 이어야 PASS) / Unknown(FLUX 미검증 · WAN 키별 남은 할당량(조회 API 없음 — 소진은 첫 생성에서 드러나고 풀이 자동으로 다음 키·Sora 로 넘어감) · Sora 배포가 실제로 생성하는지(배포 GET 은 있음과 퇴역일까지만 알려 줌)는 첫 실제 생성 시점에만 드러남 · 관리자 권한 제약으로 ffmpeg 시스템 설치 불가 등, 가짜 성공 금지).
293
306
  크레덴셜은 어떤 필드도 원문으로 보고하지 않고 항상 `redact()` 출력(길이만)만 남긴다.
294
307
 
295
308
  ## 함정
@@ -173,6 +173,9 @@ const MACHINE_ONLY = ['FFMPEG_PATH'];
173
173
  * Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
174
174
  * Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found). From a
175
175
  * project file, FFMPEG_PATH is left out and returned under `_ignored` so the caller can say why.
176
+ * A project file git tracks gives no value at all: only `_source`, `_tracked: true` and `_names`
177
+ * (the names it sets). It may have come with the repository, so its endpoint would receive the
178
+ * prompts, the frames and the key; the calls refuse creds without an endpoint and a key.
176
179
  */
177
180
  export function resolveCreds({ projectDir } = {}) {
178
181
  const home = homeCredsPath();
@@ -183,7 +186,14 @@ export function resolveCreds({ projectDir } = {}) {
183
186
  if (!p) return { _source: null };
184
187
  const creds = { ...parseEnvFile(fs.readFileSync(p, 'utf8')), _source: p };
185
188
  if (p === home) return creds;
186
- return trackedByGit(p) ? { ...withoutMachineOnly(creds), _tracked: true } : withoutMachineOnly(creds);
189
+ if (trackedByGit(p)) return { _source: p, _tracked: true, _names: Object.keys(creds).filter((k) => k !== '_source') };
190
+ return withoutMachineOnly(creds);
191
+ }
192
+
193
+ // The paid and listing calls need both; resolveCreds gives neither from a project key file git tracks.
194
+ function requireConn(fn, endpoint, key) {
195
+ if (endpoint && key) return;
196
+ throw new Error(`${fn}: endpoint and key are required; nothing was sent (resolveCreds gives no value from a project key file git tracks: untrack it with git rm --cached, or use the home key file)`);
187
197
  }
188
198
 
189
199
  function withoutMachineOnly(creds) {
@@ -615,6 +625,7 @@ export async function generateImage({
615
625
  endpoint, key, deployment, apiVersion = 'preview',
616
626
  prompt, size = '720x1280', quality = 'low', n = 1, onNote, retry429 = 3,
617
627
  } = {}) {
628
+ requireConn('generateImage', endpoint, key);
618
629
  const genBody = { model: deployment, prompt, size, quality, n };
619
630
  const post = (url, body) => postJsonWith429Retry(url, { key, body: JSON.stringify(body), retries: retry429, onNote });
620
631
  let r = await post(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, genBody);
@@ -641,6 +652,7 @@ function pngFrom(r, b64, failed) {
641
652
  export async function editImage({
642
653
  endpoint, key, deployment, apiVersion = 'preview', prompt, imageBuf, maskBuf, size,
643
654
  } = {}) {
655
+ requireConn('editImage', endpoint, key);
644
656
  const r = await azFetch(`${endpoint}/openai/v1/images/edits?api-version=${apiVersion}`, {
645
657
  key, method: 'POST', body: editForm({ deployment, prompt, imageBuf, maskBuf, size }), timeoutMs: IMAGE_TIMEOUT_MS,
646
658
  });
@@ -659,6 +671,7 @@ function editForm({ deployment, prompt, imageBuf, maskBuf, size }) {
659
671
 
660
672
  /** Generate a still via the Black Forest Labs FLUX provider path. Returns a PNG Buffer. */
661
673
  export async function generateImageFlux({ endpoint, key, prompt, width, height, apiVersion = 'preview' } = {}) {
674
+ requireConn('generateImageFlux', endpoint, key);
662
675
  const r = await azFetch(`${endpoint}/providers/blackforestlabs/v1/flux-2-pro?api-version=${apiVersion}`, {
663
676
  key, method: 'POST', headers: { 'Content-Type': 'application/json' },
664
677
  body: JSON.stringify({ prompt, width, height }), timeoutMs: IMAGE_TIMEOUT_MS,
@@ -684,6 +697,7 @@ export async function listVideos({ endpoint, key, apiVersion = 'preview' } = {})
684
697
  export async function createVideo({
685
698
  endpoint, key, model = 'sora-2', prompt, size = '720x1280', seconds = 4, inputReferencePng, apiVersion = 'preview',
686
699
  } = {}) {
700
+ requireConn('createVideo', endpoint, key);
687
701
  const body = videoForm({ model, prompt, size, seconds, inputReferencePng });
688
702
  const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body });
689
703
  if (!(r.ok && r.body?.id)) throw createVideoError(r);
@@ -704,11 +718,14 @@ function videoForm({ model, prompt, size, seconds, inputReferencePng }) {
704
718
 
705
719
  // status 0 = network error; errno tells a refused connection (nothing sent) from a lost one.
706
720
  // retryAfterMs is the server's wait for a 429.
721
+ // code is the answer's error.code (DeploymentNotFound and the like), for a 404 or 410 the caller reads.
707
722
  function createVideoError(r) {
708
723
  const err = new Error(`createVideo failed: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
709
- return Object.assign(err, { status: r.status, errno: r.errno || null, retryAfterMs: r.retryAfterMs ?? null });
724
+ return Object.assign(err, { status: r.status, errno: r.errno || null, retryAfterMs: r.retryAfterMs ?? null, code: errorCodeOf(r) });
710
725
  }
711
726
 
727
+ const errorCodeOf = (r) => (typeof r.body?.error?.code === 'string' ? r.body.error.code : null);
728
+
712
729
  const POLL_DEFAULTS = { apiVersion: 'preview', intervalMs: 5000, maxTicks: 180, now: Date.now };
713
730
  const POLL_RETRY_4XX = new Set([408, 429]);
714
731
  const withoutUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
@@ -758,15 +775,87 @@ function throwIfRefused(r, id) {
758
775
  throw Object.assign(new Error(`pollVideo: job ${id} answered HTTP ${r.status}${code ? ` ${code}` : ''}`), { status: r.status, code });
759
776
  }
760
777
 
761
- /** Download finished video bytes. Returns an mp4 Buffer; throws on non-200 / empty body. */
778
+ /** Download finished video bytes. Returns an mp4 Buffer; throws on non-200 / empty body, with err.status. */
762
779
  export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview' } = {}) {
763
780
  const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
764
781
  key, method: 'GET', binary: true,
765
782
  });
766
- if (!(r.status === 200 && r.body?.length)) throw new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`);
783
+ if (!(r.status === 200 && r.body?.length)) throw Object.assign(new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`), { status: r.status });
767
784
  return r.body;
768
785
  }
769
786
 
787
+ // ---------------------------------------------------------------------------
788
+ // Sora deployment probe (FREE: two GETs, nothing is generated)
789
+ // ---------------------------------------------------------------------------
790
+
791
+ const DEPLOYMENT_API_VERSION = '2022-12-01';
792
+ /** A retirement date this many days away or nearer is reported (`soon`). */
793
+ export const RETIRE_SOON_DAYS = 14;
794
+ const DAY_MS = 86_400_000;
795
+ const RETIRED_LIFECYCLE = new Set(['retired', 'removed']);
796
+
797
+ /**
798
+ * FREE check of a Sora deployment that makes no job. POST /openai/v1/videos checks every parameter
799
+ * before it looks the deployment up (a deployment that does not exist answers the same 400 as one
800
+ * that does, measured 2026-10-29), and a POST that passes the checks is a paid generation. So the
801
+ * check is two GETs:
802
+ * GET /openai/deployments/<name>?api-version=2022-12-01 200 {model, status}, or 404 when there is none
803
+ * GET /openai/v1/models?api-version=preview the model's lifecycle_status and deprecation.inference
804
+ * state: deployed | no_deployment | retired | auth | unreachable | unknown | unset (no endpoint or key: nothing sent). `retired` means the
805
+ * deployment answered 410, the model list calls the model retired, or its inference retirement date
806
+ * has passed. `retiresAt` (ms) and `daysLeft` come from the model list; when the deployment names the
807
+ * model without its version, the newest dated version is taken (`versionInferred`). `soon` is true
808
+ * within RETIRE_SOON_DAYS. The result holds no key and no URL.
809
+ */
810
+ export async function probeSoraDeployment({ endpoint, key, deployment = 'sora-2', now = Date.now } = {}) {
811
+ // no endpoint or key (unset, or creds from a key file git tracks, which give no values): nothing is sent
812
+ if (!endpoint || !key) return { deployment, status: null, code: null, errno: null, state: 'unset' };
813
+ const d = await azFetch(`${endpoint}/openai/deployments/${encodeURIComponent(deployment)}?api-version=${DEPLOYMENT_API_VERSION}`, { key, method: 'GET' });
814
+ const base = { deployment, status: d.status, code: errorCodeOf(d), errno: d.errno || null };
815
+ if (d.status !== 200) return { ...base, state: deploymentState(d.status) };
816
+ const { model, deploymentStatus } = deploymentOf(d.body);
817
+ const life = model ? await modelLifecycle(endpoint, key, model) : {};
818
+ return withSchedule({ ...base, deploymentStatus, model, ...life }, now());
819
+ }
820
+
821
+ const deploymentOf = (b) => ({ model: typeof b?.model === 'string' ? b.model : null, deploymentStatus: b?.status ?? null });
822
+
823
+ const DEPLOYMENT_STATE = new Map([[404, 'no_deployment'], [410, 'retired'], [401, 'auth'], [403, 'auth'], [0, 'unreachable']]);
824
+ const deploymentState = (status) => DEPLOYMENT_STATE.get(status) || 'unknown';
825
+
826
+ // The model's entry in the resource's model list: its lifecycle and inference retirement date.
827
+ async function modelLifecycle(endpoint, key, model) {
828
+ const r = await azFetch(`${endpoint}/openai/v1/models?api-version=preview`, { key, method: 'GET' });
829
+ const m = r.ok ? modelEntry(r.body?.data, model) : null;
830
+ if (!m) return { modelVersion: null, lifecycle: null, retiresAt: null };
831
+ return { modelVersion: m.entry.id, versionInferred: m.inferred, lifecycle: m.entry.lifecycle_status ?? null, retiresAt: msOf(m.entry.deprecation?.inference) };
832
+ }
833
+
834
+ // The entry named exactly `model`, else the newest `<model>-YYYY-MM-DD` one (a deployment names only the model).
835
+ function modelEntry(list, model) {
836
+ if (!Array.isArray(list)) return null;
837
+ const exact = list.find((x) => x?.id === model);
838
+ if (exact) return { entry: exact, inferred: false };
839
+ const dated = (id) => typeof id === 'string' && id.startsWith(`${model}-`) && /^\d{4}-\d{2}-\d{2}$/.test(id.slice(model.length + 1));
840
+ const versions = list.filter((x) => dated(x?.id)).sort((a, b) => b.id.localeCompare(a.id));
841
+ return versions.length ? { entry: versions[0], inferred: true } : null;
842
+ }
843
+
844
+ // deprecation.inference is unix seconds; an ISO date is read too.
845
+ function msOf(v) {
846
+ if (typeof v === 'number' && Number.isFinite(v)) return v * 1000;
847
+ const t = typeof v === 'string' ? Date.parse(v) : NaN;
848
+ return Number.isFinite(t) ? t : null;
849
+ }
850
+
851
+ function withSchedule(r, t) {
852
+ const daysLeft = r.retiresAt == null ? null : Math.ceil((r.retiresAt - t) / DAY_MS);
853
+ const gone = (r.retiresAt != null && r.retiresAt <= t) || RETIRED_LIFECYCLE.has(r.lifecycle);
854
+ const ready = !r.deploymentStatus || r.deploymentStatus === 'succeeded';
855
+ const state = gone ? 'retired' : ready ? 'deployed' : 'unknown';
856
+ return { ...r, state, daysLeft, soon: !gone && daysLeft != null && daysLeft <= RETIRE_SOON_DAYS };
857
+ }
858
+
770
859
  /**
771
860
  * Probe whether the legacy two-image jobs API is available. Never throws.
772
861
  * @returns {Promise<boolean>} true when the legacy jobs endpoint exists and is authorized.
@@ -792,6 +881,7 @@ export async function detectTwoImageSupport({ endpoint, key, apiVersion = 'previ
792
881
  export async function createVideoTwoImage({
793
882
  endpoint, key, model = 'sora-2', prompt = '', firstPng, lastPng, size = '720x1280', seconds = 4, apiVersion = 'preview',
794
883
  } = {}) {
884
+ requireConn('createVideoTwoImage', endpoint, key);
795
885
  const r = await azFetch(`${endpoint}/openai/v1/video/generations/jobs?api-version=${apiVersion}`, {
796
886
  key, method: 'POST', body: twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }),
797
887
  });
@@ -900,21 +990,6 @@ export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
900
990
  return out;
901
991
  }
902
992
 
903
- /**
904
- * Tone/color continuity pass on srcPng. Stock ffmpeg has no true "match A's histogram to B"
905
- * filter, so this uses histeq — per-channel histogram equalization — the simplest robust
906
- * histogram normalization, which keeps a chained clip's first frame from drifting in
907
- * contrast/tone. refPng is accepted for API symmetry and a future CLUT-based exact match; it
908
- * is not fed to ffmpeg today (an unmapped extra input would make ffmpeg error). Returns out.
909
- */
910
- export async function colorMatch(srcPng, refPng, out, { ffmpeg } = {}) {
911
- const bin = ffmpeg || await resolveFfmpeg();
912
- void refPng; // reserved (see doc comment)
913
- const r = runFf(bin, ['-y', '-i', srcPng, '-vf', 'format=rgb24,histeq', '-frames:v', '1', out]);
914
- if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`colorMatch failed: ${tail(r.stderr)}`);
915
- return out;
916
- }
917
-
918
993
  /**
919
994
  * Crossfade clip A into clip B with an `xfade` transition of `sec` seconds. `offset` (opts) is
920
995
  * where the fade starts in A; when omitted it is derived from A's probed duration (dur - sec).
@@ -988,15 +1063,26 @@ const IMAGE_USD_EACH = 0.02;
988
1063
  * Rough budget for an intro of `nScenes` scenes. Video billed at $0.10/sec/clip (the Sora rate:
989
1064
  * WAN rates cannot be read through an API); one image gen per scene (two per scene in two-image
990
1065
  * mode). mode 'chain' (default) makes one clip of `seconds` per scene; 'holdFlight' makes one
991
- * flight of `flightSeconds` between each pair of scenes, the holds being encoded stills (free).
1066
+ * flight of `flightSeconds` between each pair of scenes, the holds being encoded stills (free);
1067
+ * 'dive' makes one clip of `diveSeconds` per scene (the dives and the finale) and, with `exit`, one
1068
+ * exit of `exitSeconds` between each pair of scenes (6 scenes: $4.8 without, the default, $6.8 with `exit: true`).
992
1069
  * Returns per-category USD sub-costs and the total.
993
1070
  * @returns {{images:number, videos:number, usd:number}}
994
1071
  */
995
- export function estimateCost({ nScenes, seconds = 4, twoImage = false, mode = 'chain', flightSeconds = 5 } = {}) {
996
- const n = Number(nScenes) || 0;
997
- const imageCount = twoImage ? n * 2 : n;
1072
+ const COST_DEFAULTS = { seconds: 4, twoImage: false, mode: 'chain', flightSeconds: 5, diveSeconds: 8, exitSeconds: 4, exit: false };
1073
+ // The clip seconds an intro bills, by mode.
1074
+ const CLIP_SECONDS = {
1075
+ chain: (n, o) => n * o.seconds,
1076
+ holdFlight: (n, o) => Math.max(0, n - 1) * o.flightSeconds,
1077
+ dive: (n, o) => n * o.diveSeconds + (o.exit ? Math.max(0, n - 1) * o.exitSeconds : 0),
1078
+ };
1079
+
1080
+ export function estimateCost(opts = {}) {
1081
+ const o = { ...COST_DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== undefined)) };
1082
+ const n = Number(o.nScenes) || 0;
1083
+ const imageCount = o.twoImage ? n * 2 : n;
998
1084
  const images = round2(imageCount * IMAGE_USD_EACH);
999
- const clipSeconds = mode === 'holdFlight' ? Math.max(0, n - 1) * flightSeconds : n * seconds;
1085
+ const clipSeconds = (CLIP_SECONDS[o.mode] || CLIP_SECONDS.chain)(n, o);
1000
1086
  const videos = round2(clipSeconds * VIDEO_USD_PER_SEC);
1001
1087
  const usd = round2(images + videos);
1002
1088
  return { images, videos, usd };