@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +69 -0
- package/PRIVACY.md +8 -3
- package/README.md +78 -15
- package/bin/banker.js +154 -32
- package/bin/lib/codex-hooks.mjs +123 -20
- package/bin/lib/codex-names.mjs +4 -0
- package/bin/lib/codex-routes.mjs +204 -0
- package/bin/lib/command-skill.cjs +50 -0
- package/bin/lib/telemetry-config.mjs +2 -1
- package/codex/command-skills/front-qa/SKILL.md +56 -0
- package/codex/command-skills/setup/SKILL.md +87 -0
- package/codex/hooks.json +29 -0
- package/codex/manifest.json +18 -15
- package/codex/skills/graceful-pause/SKILL.md +2 -2
- package/codex/skills/progress/scripts/progress.mjs +44 -14
- package/codex/skills/teamwork/SKILL.md +137 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
- package/codex/transform-matrix.md +5 -3
- package/commands/setup.md +2 -2
- package/hooks/obsidize-hook.mjs +75 -32
- package/hooks/telemetry-count-codex.mjs +303 -35
- package/hooks/update-fetch.mjs +2 -1
- package/hooks/update-notify.mjs +146 -13
- package/package.json +3 -1
- package/skills/3d-intro-build/SKILL.md +101 -20
- package/skills/3d-intro-build/references/NOTICE.md +9 -2
- package/skills/3d-intro-build/references/assemble.mjs +113 -22
- package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-build/references/clip-post.mjs +443 -0
- package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
- package/skills/3d-intro-build/references/intro-fixes.css +11 -0
- package/skills/3d-intro-build/references/prompts.md +51 -4
- package/skills/3d-intro-build/references/scrub-engine.js +1 -0
- package/skills/3d-intro-build/references/step-nav.css +29 -0
- package/skills/3d-intro-build/references/step-nav.js +277 -106
- package/skills/3d-intro-build/references/verify-intro.mjs +387 -32
- package/skills/3d-intro-build/references/verify.md +6 -0
- package/skills/3d-intro-build/references/video-pool.mjs +142 -12
- package/skills/3d-intro-setup/SKILL.md +20 -7
- package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
- package/skills/all-in-one/SKILL.md +125 -86
- package/skills/anti-drm/SKILL.md +1 -1
- package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
- package/skills/arch-diagram/build_pptx_template.py +47 -28
- package/skills/audit-web-page/SKILL.md +1 -1
- package/skills/audit-web-page/harness.py +0 -1
- package/skills/lineage/lineage.py +1435 -533
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/obsidizer/SKILL.md +20 -5
- package/skills/omc-patch/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +2 -2
- package/skills/play-qa/SKILL.md +7 -5
- package/skills/progress-mon/README.md +162 -0
- package/skills/progress-mon/SKILL.md +165 -0
- package/skills/progress-mon/assets/fixture/README.md +1 -0
- package/skills/progress-mon/assets/fixture/arch.json +33 -0
- package/skills/progress-mon/assets/fixture/commits.json +7 -0
- package/skills/progress-mon/assets/fixture/config.json +30 -0
- package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
- package/skills/progress-mon/assets/fixture/flow.json +44 -0
- package/skills/progress-mon/assets/fixture/items.json +14 -0
- package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
- package/skills/progress-mon/assets/fixture/reqs.json +11 -0
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
- package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
- package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
- package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
- package/skills/progress-mon/references/arch.md +280 -0
- package/skills/progress-mon/references/authoring.md +199 -0
- package/skills/progress-mon/references/config.md +115 -0
- package/skills/progress-mon/references/flow.md +75 -0
- package/skills/progress-mon/references/inventory.md +58 -0
- package/skills/progress-mon/references/items.md +74 -0
- package/skills/progress-mon/references/req-spec.md +74 -0
- package/skills/progress-mon/references/reqs.md +30 -0
- package/skills/progress-mon/scripts/index.js +31 -0
- package/skills/progress-mon/scripts/pm-check.mjs +526 -0
- package/skills/progress-mon/scripts/pm-config.mjs +341 -0
- package/skills/progress-mon/scripts/pm-data.mjs +394 -0
- package/skills/progress-mon/scripts/pm-html.mjs +77 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
- package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
- package/skills/progress-mon/scripts/pm-server.mjs +735 -0
- package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
- package/skills/ralph-qa/SKILL.md +105 -16
- package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
- package/skills/ralph-qa/references/payload-scan.mjs +19 -2
- package/skills/ralph-qa/references/run-folder.mjs +299 -0
- package/skills/ralph-qa/references/seat-payload.mjs +60 -13
- package/skills/ralph-qa/references/seat-run.mjs +159 -155
- package/skills/remains/SKILL.md +2 -1
- package/skills/remains/scripts/boxes.mjs +6 -2
- package/skills/rfp-author/scripts/md2docx.py +63 -34
- package/skills/setup-bypass-permissions/SKILL.md +2 -2
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
- package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
- package/skills/setup-mcp/SKILL.md +1 -1
- package/skills/setup-omc-hud/SKILL.md +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
- package/skills/smart-compact/SKILL.md +1 -1
- package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
- package/skills/theme-selection/SKILL.md +1 -1
- package/skills/tone-compact/SKILL.md +1 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
- package/skills/ultra-chain/SKILL.md +34 -16
- package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
- package/skills/ultra-init/SKILL.md +30 -22
- package/skills/ultra-ui-qa/SKILL.md +1 -1
- package/skills/ultraqa/NOTICE.md +34 -0
- package/skills/ultraqa/SKILL.md +196 -0
- package/skills/ultraqa/references/gate-evidence.mjs +457 -0
- package/skills/ultraqa/references/run-folder.mjs +299 -0
- package/skills/ultraqa/references/tmux-drive.md +52 -0
- package/skills/ultraqa/references/uqa-run.mjs +339 -0
- package/skills/update-banker/SKILL.md +32 -7
- package/skills/vertical-pptx/SKILL.md +1 -1
- package/skills/vertical-pptx/build_a4p.js +20 -2
- 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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
어댑터의 프레임
|
|
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)
|
|
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
|
-
아래
|
|
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
|
|
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 프리플라이트를 재실행해
|
|
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 로 넘어감) ·
|
|
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
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
|
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 };
|