@kaydash9999/banker-plugins 0.15.2 → 0.16.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 +48 -0
- package/README.md +31 -9
- package/codex/manifest.json +6 -3
- package/codex/transform-matrix.md +2 -0
- package/commands/progress.md +10 -0
- package/commands/setup.md +1 -1
- package/hooks/graceful-pause.mjs +31 -71
- package/hooks/hooks.json +11 -1
- package/hooks/progress-fallback.mjs +10 -0
- package/hooks/progress.mjs +427 -0
- package/hooks/register.mjs +82 -5
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +235 -122
- package/skills/3d-intro-build/references/NOTICE.md +2 -1
- package/skills/3d-intro-build/references/assemble.mjs +164 -108
- package/skills/3d-intro-build/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-build/references/curate.mjs +573 -267
- package/skills/3d-intro-build/references/intro-fixes.css +61 -0
- package/skills/3d-intro-build/references/panel-glass.css +55 -0
- package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
- package/skills/3d-intro-build/references/prompts.md +32 -9
- package/skills/3d-intro-build/references/serve.mjs +111 -103
- package/skills/3d-intro-build/references/step-nav.css +59 -0
- package/skills/3d-intro-build/references/step-nav.js +503 -0
- package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
- package/skills/3d-intro-build/references/verify.md +45 -0
- package/skills/3d-intro-build/references/video-pool.mjs +780 -0
- package/skills/3d-intro-setup/SKILL.md +49 -12
- package/skills/3d-intro-setup/references/azure-adapter.mjs +115 -29
- package/skills/3d-intro-setup/references/video-pool.mjs +780 -0
- package/skills/motion-graphic-make/SKILL.md +73 -28
- package/skills/motion-graphic-make/references/curate.mjs +607 -0
- package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
- package/skills/motion-graphic-make/references/serve.mjs +131 -0
- package/skills/omc-patch/SKILL.md +143 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
- package/skills/remains/SKILL.md +125 -0
- package/skills/remains/scripts/boxes.mjs +448 -0
- package/skills/trouble-shooting/SKILL.md +50 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: 3d-intro-setup
|
|
3
|
-
description: "`3d-intro-build` 에 필요한 Node 18 이상과 ffmpeg 를 갖추고 Azure
|
|
3
|
+
description: "`3d-intro-build` 에 필요한 Node 18 이상과 ffmpeg 를 갖추고 Azure 크레덴셜과 선택 항목인 WAN API 키 풀을 저장함. 크레덴셜은 직접 입력하거나 콘솔의 샘플 코드를 붙여 넣어 받고, 무과금 프리플라이트로 확인. 'setup-3d-intro'/'3d-intro-setup'/'3D 인트로 설정'/'Azure Sora 크레덴셜' 또는 `/banker:setup` 시 사용."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 3d-intro-setup — Azure Sora-2 / gpt-image-2 3D 인트로 제작 전제조건 설치
|
|
7
7
|
|
|
8
8
|
Azure Sora-2(영상 생성)·gpt-image-2(이미지 생성) 기반 3D 인트로 제작의 실행 전제조건을 설치·검증한다.
|
|
9
9
|
실제 인트로 제작(이미지·영상 생성, 과금 발생)은 `3d-intro-build` 스킬이 담당하고, 이 스킬은 Node/ffmpeg 확보와 크레덴셜 저장, **무과금** 연결 확인까지만 담당한다.
|
|
10
|
-
모든 Azure 호출은 이 스킬 디렉터리의 `references/azure-adapter.mjs
|
|
10
|
+
모든 Azure 호출은 이 스킬 디렉터리의 `references/azure-adapter.mjs`, WAN(Alibaba Model Studio) 호출은 `references/video-pool.mjs` 를 통해서만 수행한다.
|
|
11
|
+
두 파일 모두 수정 금지이며 `3d-intro-build` 와 byte-identical 공유 사본이다.
|
|
12
|
+
영상은 WAN 키 풀이 먼저 쓰이고, 풀의 키가 모두 소진되면 Sora-2 로 폴백한다. WAN 은 선택 항목이라 없으면 Sora 만 쓴다.
|
|
11
13
|
**OS·arch·기존 설치/크레덴셜 여부를 먼저 감지**해 알맞은 경로를 고른다.
|
|
12
14
|
설치는 **멱등**(이미 있으면 skip·검증만). 답변은 한글(기술 토큰 영문).
|
|
13
15
|
|
|
@@ -29,6 +31,7 @@ if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { (ffmpeg -version)[0] } e
|
|
|
29
31
|
```bash
|
|
30
32
|
SKILL_DIR="<이 SKILL.md 가 위치한 디렉터리의 절대경로>" # 예: .../skills/3d-intro-setup
|
|
31
33
|
ADAPTER="$SKILL_DIR/references/azure-adapter.mjs"
|
|
34
|
+
POOL="$SKILL_DIR/references/video-pool.mjs"
|
|
32
35
|
```
|
|
33
36
|
**이미 확보 확인**: `node -v` 가 18 이상이고 `ffmpeg` 가 있으면 §1~§2 는 건너뛴다.
|
|
34
37
|
크레덴셜도 이미 완전하면(§3 첫 단계) 프롬프트 없이 §4 프리플라이트로 직행한다.
|
|
@@ -86,14 +89,16 @@ node -e "
|
|
|
86
89
|
exit 0(완전한 크레덴셜 발견)이면 아래 입력 단계를 건너뛰고 **§4 프리플라이트로 직행**(검증만, 재프롬프트 금지).
|
|
87
90
|
프리플라이트가 401/403 으로 실패할 때만 아래로 돌아와 재입력(로테이션)한다.
|
|
88
91
|
|
|
89
|
-
###
|
|
90
|
-
Sora(영상)와 gpt-image
|
|
92
|
+
### 리소스 구성(Sora·Image 필수, WAN 풀·FLUX 선택)
|
|
93
|
+
Sora(영상)와 gpt-image(이미지)는 **보통 서로 다른 Azure 리소스**(엔드포인트·키가 다를 수 있음) — 두 세트를 각각 받는다.
|
|
94
|
+
WAN 영상 풀(§3-WAN)은 선택이지만, 있으면 영상이 WAN 으로 먼저 만들어진다.
|
|
91
95
|
FLUX.2-pro(대체 이미지 생성)는 **opt-in**: 필요 없으면 완전히 건너뛴다(§3-FLUX 참조).
|
|
92
96
|
|
|
93
97
|
### 모드 (a) — 직접 입력
|
|
94
98
|
사용자에게 리소스별로 순서대로 질문한다: endpoint(예: `https://<resource>.openai.azure.com` 또는 `https://<resource>.cognitiveservices.azure.com`), API key, 배포명(deployment).
|
|
95
99
|
- **Sora**: endpoint + key (+ 배포명, 기본 `sora-2` — 콘솔에서 다른 이름으로 배포했으면 그 이름).
|
|
96
|
-
- **Image(gpt-image
|
|
100
|
+
- **Image(gpt-image 계열)**: endpoint + key + 배포명(예: `gpt-image-2`, `gpt-image-2.5-sunburst`. 필수 — 생성 호출의 `model` 필드로 그대로 쓰인다).
|
|
101
|
+
AI Foundry 리소스(`https://<resource>.services.ai.azure.com`)도 그대로 쓴다. 콘솔이 `.../models` 를 붙여 보여줘도 저장은 origin 까지만 한다(이미지 경로는 `/openai/v1/images/...` 이고 `/models/images/...` 는 404).
|
|
97
102
|
|
|
98
103
|
### 모드 (b) — 콘솔 샘플 코드 붙여넣기
|
|
99
104
|
Azure AI Foundry/AI Hub 콘솔의 "Sample code"/"View code" 패널이 주는 curl 또는 Python 스니펫을 리소스별로 통째로 붙여넣게 한다.
|
|
@@ -118,17 +123,21 @@ node -e "
|
|
|
118
123
|
AZURE_SORA_ENDPOINT / AZURE_SORA_API_KEY / AZURE_SORA_DEPLOYMENT(기본 sora-2) / AZURE_SORA_API_VERSION(기본 preview)
|
|
119
124
|
AZURE_IMAGE_OPENAI_ENDPOINT / AZURE_IMAGE_API_KEY / AZURE_GPT_IMAGE_DEPLOYMENT / AZURE_GPT_IMAGE_API_VERSION(기본 preview)
|
|
120
125
|
```
|
|
126
|
+
`persistCreds` 는 파일 전체를 새로 쓴다. 이미 저장된 값(WAN 풀 등)을 잃지 않도록 기존 값에 덮어 합친다.
|
|
121
127
|
```bash
|
|
122
128
|
node -e "
|
|
123
129
|
(async () => {
|
|
124
|
-
const { persistCreds } = await import('$ADAPTER');
|
|
130
|
+
const { persistCreds, resolveCreds } = await import('$ADAPTER');
|
|
125
131
|
const path = require('path');
|
|
126
132
|
const target = path.join(process.cwd(), '.env.3d-intro.local'); // 프로젝트 전용(기본, 권장)
|
|
127
|
-
const
|
|
133
|
+
const prev = resolveCreds({ projectDir: process.cwd() });
|
|
134
|
+
const keep = prev._source === target ? prev : {}; // 같은 파일일 때만 기존 값 유지
|
|
135
|
+
const written = persistCreds({ ...keep,
|
|
128
136
|
AZURE_SORA_ENDPOINT: '<입력값>', AZURE_SORA_API_KEY: '<입력값>',
|
|
129
137
|
AZURE_SORA_DEPLOYMENT: '<입력값 또는 sora-2>', AZURE_SORA_API_VERSION: '<입력값 또는 preview>',
|
|
130
138
|
AZURE_IMAGE_OPENAI_ENDPOINT: '<입력값>', AZURE_IMAGE_API_KEY: '<입력값>',
|
|
131
139
|
AZURE_GPT_IMAGE_DEPLOYMENT: '<입력값>', AZURE_GPT_IMAGE_API_VERSION: '<입력값 또는 preview>',
|
|
140
|
+
// WAN 풀(선택): 키 수만큼 WAN_<n>_ENDPOINT / WAN_<n>_API_KEY, 필요하면 WAN_MODEL
|
|
132
141
|
}, { target });
|
|
133
142
|
console.log('저장:', written);
|
|
134
143
|
})();
|
|
@@ -140,14 +149,33 @@ node -e "
|
|
|
140
149
|
grep -qE '^\.env\.3d-intro\.local$|^\.env\.\*\.local$' .gitignore 2>/dev/null || echo '.env.3d-intro.local' >> .gitignore
|
|
141
150
|
```
|
|
142
151
|
|
|
152
|
+
### WAN 영상 풀 (선택, 권장)
|
|
153
|
+
Alibaba Cloud Model Studio 의 WAN 영상 모델(기본 `wan3.0-video-prime`)을 여러 API 키로 돌린다. 키(workspace)마다 할당량이 따로라 키가 많을수록 오래 쓴다.
|
|
154
|
+
키마다 아래 두 줄을 번호를 붙여 저장한다. 번호 개수 제한은 없다.
|
|
155
|
+
```
|
|
156
|
+
# endpoint 는 origin 만 (경로 제외)
|
|
157
|
+
WAN_1_ENDPOINT=https://ws-<workspace-id>.<region>.maas.aliyuncs.com
|
|
158
|
+
WAN_1_API_KEY=sk-ws-...
|
|
159
|
+
WAN_2_ENDPOINT=...
|
|
160
|
+
WAN_2_API_KEY=...
|
|
161
|
+
# 선택. 키 하나만 모델이 다르면 WAN_<n>_MODEL
|
|
162
|
+
WAN_MODEL=wan3.0-video-prime
|
|
163
|
+
```
|
|
164
|
+
- 콘솔 샘플(curl)을 붙여넣으면 `parseConsoleSample()` 로 endpoint 를 뽑을 수 있다. 키는 샘플에 실제 값(`Bearer sk-ws-...`)이 있을 때만 뽑히고, `$DASHSCOPE_API_KEY` 같은 변수면 따로 입력받는다. JSON 본문의 `"model"` 은 뽑히지 않으니 `WAN_MODEL` 로 따로 넣는다.
|
|
165
|
+
- 엔드포인트, 키, 모델의 리전이 같아야 한다. 다르면 `401 InvalidApiKey` 가 난다.
|
|
166
|
+
- 값 뒤의 주석은 `#` 앞에 공백이 있어야 주석으로 읽힌다(`값 # 메모`). 공백 없이 붙은 `#` 는 값의 일부다. 값에 ` #` 이 들어가야 하면 따옴표로 감싼다.
|
|
167
|
+
- endpoint 가 http(s) URL 이 아니거나 모델이 한 id 가 아닌 항목은 build 가 건너뛴다. 모든 WAN 항목이 그렇다면 build 는 Sora 로 넘어가지 않고 멈춘다.
|
|
168
|
+
- 선택 설정(모두 기본값이 있음): `VIDEO_PROVIDER_ORDER`(기본 `wan,sora`), `WAN_RESOLUTION`(기본은 출력 크기에서 계산), `WAN_PROMPT_EXTEND`(기본 `false`), `WAN_MAX_CONCURRENT`(기본 5), `WAN_EXHAUSTED_TTL_HOURS`(기본 24).
|
|
169
|
+
- 소진·쿨다운 기록은 `~/.config/banker/3d-intro/video-pool-state.json` 에 지문(엔드포인트, 키, 모델)으로만 남는다. 키나 모델을 바꾸면 지문이 바뀌어 기록이 새로 시작된다.
|
|
170
|
+
|
|
143
171
|
### FLUX.2-pro (opt-in)
|
|
144
172
|
필요할 때만: endpoint 하나 추가(`AZURE_IMAGE_SERVICES_ENDPOINT`) — 같은 리소스에 배포된 경우가 많아 키는 기본적으로 `AZURE_IMAGE_API_KEY` 를 재사용하고, 다른 리소스면 별도 key 를 물어본다.
|
|
145
173
|
FLUX 는 이 스킬의 §4 프리플라이트 대상이 아니다(무과금으로 확인할 별도 엔드포인트가 없음).
|
|
146
174
|
저장만 하고 실제 연결 확인은 생략한다(graceful skip) — 첫 실제 사용 시점(3d-intro-build)으로 미룬다.
|
|
147
175
|
|
|
148
176
|
## 4. 프리플라이트 (무과금)
|
|
149
|
-
**여기서는 어떤 유료 생성도 실행하지 않는다** —
|
|
150
|
-
아래
|
|
177
|
+
**여기서는 어떤 유료 생성도 실행하지 않는다** — 이미지 생성, Sora-2·WAN 영상 생성 어느 쪽도 호출하지 않는다.
|
|
178
|
+
아래 세 가지만 수행한다: (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 실측).
|
|
151
179
|
```bash
|
|
152
180
|
node -e "
|
|
153
181
|
(async () => {
|
|
@@ -160,18 +188,25 @@ node -e "
|
|
|
160
188
|
const imgUrl = c.AZURE_IMAGE_OPENAI_ENDPOINT + '/openai/v1/images/generations?api-version=' + imgv;
|
|
161
189
|
const img = await azFetch(imgUrl, { key: c.AZURE_IMAGE_API_KEY, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: c.AZURE_GPT_IMAGE_DEPLOYMENT }) });
|
|
162
190
|
console.log('images.badreq ->', img.status, '(' + img.authStyle + ')');
|
|
163
|
-
const
|
|
191
|
+
const { probeWanPool } = await import('$POOL');
|
|
192
|
+
const wan = await probeWanPool({ creds: c });
|
|
193
|
+
for (const w of wan) console.log('wan.probe', w.label, w.host, w.model, '->', w.status, w.code || '', '| key', w.key);
|
|
194
|
+
if (!wan.length) console.log('wan.probe -> WAN 항목 없음 (영상은 Sora 만 사용)');
|
|
195
|
+
const pass = sora.status === 200 && img.status === 400 && wan.every((w) => w.authOk);
|
|
164
196
|
console.log(pass ? 'PREFLIGHT PASS -- \$0 과금' : 'PREFLIGHT FAIL');
|
|
165
197
|
console.log('source:', c._source, '| Sora key', redact(c.AZURE_SORA_API_KEY), '| Image key', redact(c.AZURE_IMAGE_API_KEY));
|
|
166
198
|
process.exit(pass ? 0 : 1);
|
|
167
199
|
})();
|
|
168
200
|
"
|
|
169
201
|
```
|
|
170
|
-
**판정 기준(
|
|
202
|
+
**판정 기준(모두 충족해야 PASS)**:
|
|
171
203
|
| 체크 | 기대 | 의미 |
|
|
172
204
|
|---|---|---|
|
|
173
205
|
| `sora.listVideos` | HTTP 200 | Sora 엔드포인트 살아있음 + 키 인증됨 |
|
|
174
206
|
| `images.badreq` | HTTP 400 (`missing_required_parameter`) | Image 엔드포인트 살아있음 + 키 인증됨 + **배포명 라우팅됨**(프롬프트 누락이라 생성 전 거부) |
|
|
207
|
+
| `wan.probe` (항목마다) | HTTP 200 | WAN workspace 엔드포인트 + 키 인증됨. 남은 할당량은 이 방법으로 알 수 없다 |
|
|
208
|
+
| `wan.probe` 가 401 `InvalidApiKey` | - | 키가 틀렸거나 엔드포인트와 리전이 다름 — 그 `WAN_<n>` 을 재입력 |
|
|
209
|
+
| `wan.probe` 가 `BadEndpoint` / `BadModel` | - | 값이 URL 이나 모델 id 형식이 아님(URL 이 아닌 endpoint, 공백이 든 모델, 모델 칸에 붙인 키 등). 원래 값 대신 `(invalid endpoint)`, `(invalid model)` 로 표시된다 — 그 줄을 고침 |
|
|
175
210
|
| `images.badreq` 가 404 (`DeploymentNotFound`) | - | 엔드포인트·인증은 OK 지만 **배포명이 틀림** — §3 에서 `AZURE_GPT_IMAGE_DEPLOYMENT` 를 콘솔의 실제 배포명으로 재확인 |
|
|
176
211
|
| 둘 중 하나라도 401/403 | - | 인증 실패 — `azFetch` 가 `api-key`→`Bearer` 두 헤더 스타일을 이미 자동 재시도하므로, 그래도 401/403 이면 헤더 스타일이 아니라 키·엔드포인트 자체가 틀린 것(§3 재입력) |
|
|
177
212
|
| `images.badreq` 가 200 | - | 있을 수 없음(프롬프트 없이 생성이 성공할 리 없다) — 어댑터·API 버전 불일치 의심, 정직히 보고 |
|
|
@@ -183,13 +218,15 @@ node -v # 18 이상 기대
|
|
|
183
218
|
command -v ffmpeg >/dev/null && ffmpeg -version | head -1 || echo "시스템 ffmpeg 없음 -- resolveFfmpeg() 폴백 확인(§2)"
|
|
184
219
|
# 위 §4 프리플라이트를 재실행해 PASS 문자열 + 두 status 코드를 그대로 증거로 남긴다
|
|
185
220
|
```
|
|
186
|
-
4-field 보고: 변경(Node/ffmpeg 확보 방식 · 크레덴셜 저장 경로 — 파일 내용 아님) / Evidence(§0 감지 출력 · §4
|
|
221
|
+
4-field 보고: 변경(Node/ffmpeg 확보 방식 · 크레덴셜 저장 경로 — 파일 내용 아님) / Evidence(§0 감지 출력 · §4 status 코드 전부) / 검증(`listVideos`=200 **AND** `images.badreq`=400 **AND** WAN 항목 전부 200 이어야 PASS) / Unknown(FLUX 미검증 · WAN 키별 남은 할당량(조회 API 없음 — 소진은 첫 생성에서 드러나고 풀이 자동으로 다음 키·Sora 로 넘어감) · 배포명이 실제 존재하는지는 이 무과금 프리플라이트로 확인 불가하며 첫 실제 생성 시점에만 드러남 · 관리자 권한 제약으로 ffmpeg 시스템 설치 불가 등, 가짜 성공 금지).
|
|
187
222
|
크레덴셜은 어떤 필드도 원문으로 보고하지 않고 항상 `redact()` 출력만 남긴다.
|
|
188
223
|
|
|
189
224
|
## 함정
|
|
190
225
|
- **키·엔드포인트가 진짜 다른 이유**: Sora 와 gpt-image-2 는 같은 Azure OpenAI 리소스에 함께 배포될 수도, 완전히 별도 리소스일 수도 있다 — 하나로 퉁치지 말고 두 세트를 각각 확인한다.
|
|
191
226
|
- **`azFetch` 는 이미 이중 인증을 시도한다**: `api-key` 헤더로 먼저 시도하고 401/403 이면 자동으로 `Authorization: Bearer` 로 재시도한다 — 그래도 실패하면 헤더 스타일 문제가 아니라 키·엔드포인트 자체가 틀린 것이다.
|
|
192
227
|
- **콘솔 샘플 붙여넣기의 스크롤백 잔존**: 터미널 히스토리·세션 로그에 원문 키가 남을 수 있다(플러그인 통제 밖) — 공유 터미널이거나 로깅되는 환경이면 붙여넣기 후 Azure 콘솔에서 키 로테이션을 권한다.
|
|
228
|
+
- **WAN 키 검증은 GET task 로만**: 존재하지 않는 task 조회는 과금이 없고 키 유효성(200 vs 401)을 가른다. 생성 요청(POST)은 과금될 수 있으니 점검용으로 쓰지 않는다.
|
|
229
|
+
- **이미지 배포 429**: 낮은 티어(S0) 이미지 배포는 연속 호출에 `429 RateLimitReached` 를 낸다. 프리플라이트를 짧은 간격으로 반복하지 않는다.
|
|
193
230
|
- **RHEL8/Rocky8 ffmpeg 미제공**: BaseOS/AppStream 에 없음(라이선스) → motion-graphic-setup 과 동일하게 RPM Fusion 또는 `ffmpeg-static` 폴백.
|
|
194
231
|
- **ffmpeg 바이너리 미번들 원칙**: 위 폴백은 사용자 프로젝트 `node_modules` 한정 — banker 플러그인 배포물에는 절대 포함하지 않는다.
|
|
195
232
|
- **프리플라이트는 배포명 실존까지는 증명 못 한다**: `images.badreq` 400 은 "리소스가 살아있고 키가 맞다"는 뜻이지 `AZURE_GPT_IMAGE_DEPLOYMENT` 로 지정한 배포가 실제 존재·활성 상태인지는 증명하지 않는다 — 무과금으로는 확인할 방법이 없어 첫 실제 생성(3d-intro-build)에서만 드러난다.
|
|
@@ -101,17 +101,31 @@ export function parseConsoleSample(text) {
|
|
|
101
101
|
return out;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Parse a KEY=VALUE .env body into a plain object, skipping comment lines. A quoted value keeps
|
|
106
|
+
* everything inside its quotes. In an unquoted value, a # that follows whitespace starts a comment
|
|
107
|
+
* (also right after `=`), so a note after a value never becomes part of an endpoint or a key.
|
|
108
|
+
*/
|
|
105
109
|
export function parseEnvFile(text) {
|
|
106
110
|
const env = {};
|
|
107
111
|
for (const line of String(text || '').split(/\r?\n/)) {
|
|
108
112
|
if (line.trimStart().startsWith('#')) continue;
|
|
109
|
-
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s
|
|
110
|
-
if (m) env[m[1]] = m[2]
|
|
113
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=([^\n]*)$/);
|
|
114
|
+
if (m) env[m[1]] = envValue(m[2]);
|
|
111
115
|
}
|
|
112
116
|
return env;
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
// `raw` is everything after `=`, so the whitespace before a leading # is still there. A stray
|
|
120
|
+
// trailing \r (a \r\r\n line end) is whitespace too.
|
|
121
|
+
function envValue(raw) {
|
|
122
|
+
const v = raw.trim();
|
|
123
|
+
const q = v[0];
|
|
124
|
+
const end = q === '"' || q === "'" ? v.indexOf(q, 1) : -1;
|
|
125
|
+
if (end > 0) return v.slice(1, end);
|
|
126
|
+
return raw.trimEnd().replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '');
|
|
127
|
+
}
|
|
128
|
+
|
|
115
129
|
/**
|
|
116
130
|
* Load creds from `.env.3d-intro.local` in projectDir, else `~/.config/banker/3d-intro/env`.
|
|
117
131
|
* Returns the parsed KEY=VALUE map plus `_source` (the file used, or null if none found).
|
|
@@ -135,7 +149,7 @@ export function persistCreds(creds, { target } = {}) {
|
|
|
135
149
|
const lines = [];
|
|
136
150
|
for (const [k, v] of Object.entries(creds || {})) {
|
|
137
151
|
if (k.startsWith('_') || v == null) continue;
|
|
138
|
-
lines.push(`${k}=${String(v)}`);
|
|
152
|
+
lines.push(`${k}=${envLiteral(String(v))}`);
|
|
139
153
|
}
|
|
140
154
|
const body = lines.length ? `${lines.join('\n')}\n` : '';
|
|
141
155
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
@@ -144,6 +158,12 @@ export function persistCreds(creds, { target } = {}) {
|
|
|
144
158
|
return target;
|
|
145
159
|
}
|
|
146
160
|
|
|
161
|
+
// Quote a value that parseEnvFile would otherwise cut at " #", trim, or strip of its quotes.
|
|
162
|
+
function envLiteral(v) {
|
|
163
|
+
if (!/\s#|^["'\s]|["'\s]$/.test(v)) return v;
|
|
164
|
+
return v.includes('"') ? `'${v}'` : `"${v}"`;
|
|
165
|
+
}
|
|
166
|
+
|
|
147
167
|
/** Redact a secret to `abcd…wxyz(len)`; short (<=8) secrets are fully masked; falsy -> "(missing)". */
|
|
148
168
|
export function redact(s) {
|
|
149
169
|
if (!s) return '(missing)';
|
|
@@ -156,10 +176,23 @@ export function redact(s) {
|
|
|
156
176
|
// HTTP
|
|
157
177
|
// ---------------------------------------------------------------------------
|
|
158
178
|
|
|
179
|
+
// Retry-After / retry-after-ms response header -> milliseconds (null when absent or unparsable).
|
|
180
|
+
function retryAfterMsOf(headers) {
|
|
181
|
+
const ms = Number(headers.get('retry-after-ms'));
|
|
182
|
+
if (headers.get('retry-after-ms') && Number.isFinite(ms)) return Math.max(0, ms);
|
|
183
|
+
const h = headers.get('retry-after');
|
|
184
|
+
if (!h) return null;
|
|
185
|
+
const sec = Number(h);
|
|
186
|
+
if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
|
|
187
|
+
const at = Date.parse(h);
|
|
188
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
159
191
|
/**
|
|
160
192
|
* Fetch an Azure URL trying the `api-key` header first and, on 401/403, retrying with
|
|
161
193
|
* `Authorization: Bearer`. Never throws on HTTP status; a network error yields {status:0}.
|
|
162
|
-
*
|
|
194
|
+
* `retryAfterMs` carries the server's Retry-After hint (null when absent).
|
|
195
|
+
* @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, retryAfterMs:number|null, error?:Error}>}
|
|
163
196
|
*/
|
|
164
197
|
export async function azFetch(url, { key, binary = false, headers = {}, method = 'GET', body } = {}) {
|
|
165
198
|
const styles = key ? [{ 'api-key': key }, { Authorization: `Bearer ${key}` }] : [{}];
|
|
@@ -179,7 +212,7 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
|
|
|
179
212
|
if (binary) parsed = Buffer.from(await r.arrayBuffer());
|
|
180
213
|
else if (ct.includes('json')) parsed = await r.json().catch(() => null);
|
|
181
214
|
else parsed = await r.text().catch(() => null);
|
|
182
|
-
last = { status: r.status, ok: r.ok, ct, body: parsed, authStyle: Object.keys(auth)[0] || null };
|
|
215
|
+
last = { status: r.status, ok: r.ok, ct, body: parsed, authStyle: Object.keys(auth)[0] || null, retryAfterMs: retryAfterMsOf(r.headers) };
|
|
183
216
|
if (r.status !== 401 && r.status !== 403) return last; // accepted (or a non-auth error)
|
|
184
217
|
// else: this auth style was rejected — fall through and try the next
|
|
185
218
|
}
|
|
@@ -190,26 +223,40 @@ export async function azFetch(url, { key, binary = false, headers = {}, method =
|
|
|
190
223
|
// Images
|
|
191
224
|
// ---------------------------------------------------------------------------
|
|
192
225
|
|
|
226
|
+
// POST JSON, retrying HTTP 429 up to `retries` times. Waits the server's Retry-After when given,
|
|
227
|
+
// else 10s, 20s, 40s…; either way at most 60s per wait. Low-tier (S0) image deployments hit this.
|
|
228
|
+
async function postJsonWith429Retry(url, { key, body, retries, onNote }) {
|
|
229
|
+
for (let i = 0; ; i++) {
|
|
230
|
+
const r = await azFetch(url, { key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
|
|
231
|
+
if (r.status !== 429 || i >= retries) return r;
|
|
232
|
+
const ms = Math.min(60000, r.retryAfterMs ?? 10000 * 2 ** i);
|
|
233
|
+
if (typeof onNote === 'function') onNote(`HTTP 429; retry ${i + 1}/${retries} in ${ms} ms`);
|
|
234
|
+
await sleep(ms);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
193
238
|
/**
|
|
194
239
|
* Generate a still via the v1 images path; on failure fall back to the classic
|
|
195
240
|
* `deployments/<dep>` path @2025-04-01-preview (discrete size). Returns a PNG Buffer.
|
|
196
|
-
*
|
|
241
|
+
* HTTP 429 is retried on the same path first (`retry429` times) — the classic path shares the
|
|
242
|
+
* deployment's rate limit, so falling back on 429 would only fail again.
|
|
243
|
+
* `onNote(msg)` (optional) is invoked on 429 waits and when the classic fallback is taken.
|
|
197
244
|
*/
|
|
198
245
|
export async function generateImage({
|
|
199
246
|
endpoint, key, deployment, apiVersion = 'preview',
|
|
200
|
-
prompt, size = '720x1280', quality = 'low', n = 1, onNote,
|
|
247
|
+
prompt, size = '720x1280', quality = 'low', n = 1, onNote, retry429 = 3,
|
|
201
248
|
} = {}) {
|
|
202
249
|
const genBody = { model: deployment, prompt, size, quality, n };
|
|
203
|
-
let r = await
|
|
204
|
-
key,
|
|
250
|
+
let r = await postJsonWith429Retry(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, {
|
|
251
|
+
key, body: JSON.stringify(genBody), retries: retry429, onNote,
|
|
205
252
|
});
|
|
206
253
|
let note = 'v1';
|
|
207
|
-
|
|
254
|
+
// no classic retry on 429 (shared limit) or on a lost connection (the v1 call may have billed)
|
|
255
|
+
if (!(r.ok && r.body?.data?.[0]?.b64_json) && r.status !== 429 && r.status !== 0) {
|
|
208
256
|
note = 'classic-fallback(2025-04-01-preview)';
|
|
209
257
|
if (typeof onNote === 'function') onNote(`generateImage: v1 path failed (HTTP ${r.status}); trying ${note}`);
|
|
210
|
-
r = await
|
|
211
|
-
key,
|
|
212
|
-
body: JSON.stringify({ ...genBody, size: classicSize(size) }),
|
|
258
|
+
r = await postJsonWith429Retry(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, {
|
|
259
|
+
key, body: JSON.stringify({ ...genBody, size: classicSize(size) }), retries: retry429, onNote,
|
|
213
260
|
});
|
|
214
261
|
}
|
|
215
262
|
const b64 = r.body?.data?.[0]?.b64_json;
|
|
@@ -273,30 +320,49 @@ export async function createVideo({
|
|
|
273
320
|
fd.append('input_reference', new Blob([inputReferencePng], { type: 'image/png' }), 'input_reference.png');
|
|
274
321
|
}
|
|
275
322
|
const r = await azFetch(`${endpoint}/openai/v1/videos?api-version=${apiVersion}`, { key, method: 'POST', body: fd });
|
|
276
|
-
if (!(r.ok && r.body?.id))
|
|
323
|
+
if (!(r.ok && r.body?.id)) {
|
|
324
|
+
// status 0 = network error; errno tells a refused connection (nothing sent) from a lost one
|
|
325
|
+
const err = new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
|
|
326
|
+
err.status = r.status;
|
|
327
|
+
err.errno = r.error?.cause?.code || r.error?.code || null;
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
277
330
|
return r.body;
|
|
278
331
|
}
|
|
279
332
|
|
|
333
|
+
const POLL_DEFAULTS = { apiVersion: 'preview', intervalMs: 5000, maxTicks: 180 };
|
|
334
|
+
const POLL_RETRY_4XX = new Set([408, 429]);
|
|
335
|
+
const withoutUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
336
|
+
|
|
280
337
|
/**
|
|
281
338
|
* Poll a Sora job until a terminal status (or maxTicks). Polls first, then sleeps, so a job
|
|
282
|
-
* that is already terminal returns without waiting. `onTick(status, i)` is optional
|
|
339
|
+
* that is already terminal returns without waiting. `onTick(status, i)` is optional; `sleep`
|
|
340
|
+
* replaces the wait between polls. 408, 429, 5xx and network errors keep polling. Any other
|
|
341
|
+
* 4xx throws at once with `err.status` and `err.code`: a job the server no longer knows, or a
|
|
342
|
+
* key it refuses, never turns terminal.
|
|
283
343
|
* @returns the last status object seen.
|
|
284
344
|
*/
|
|
285
|
-
export async function pollVideo({
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const url = `${endpoint}/openai/v1/videos/${id}?api-version=${apiVersion}`;
|
|
345
|
+
export async function pollVideo(opts = {}) {
|
|
346
|
+
const o = { ...POLL_DEFAULTS, sleep, ...withoutUndefined(opts) };
|
|
347
|
+
const url = `${o.endpoint}/openai/v1/videos/${o.id}?api-version=${o.apiVersion}`;
|
|
289
348
|
let st = null;
|
|
290
|
-
for (let i = 0; i < maxTicks; i++) {
|
|
291
|
-
const r = await azFetch(url, { key, method: 'GET' });
|
|
292
|
-
|
|
293
|
-
if (
|
|
349
|
+
for (let i = 0; i < o.maxTicks; i++) {
|
|
350
|
+
const r = await azFetch(url, { key: o.key, method: 'GET' });
|
|
351
|
+
throwIfRefused(r, o.id);
|
|
352
|
+
if (r.ok) st = r.body || st;
|
|
353
|
+
if (typeof o.onTick === 'function') o.onTick(st, i);
|
|
294
354
|
if (st && TERMINAL_STATUS.has(st.status)) break;
|
|
295
|
-
await sleep(intervalMs);
|
|
355
|
+
await o.sleep(o.intervalMs);
|
|
296
356
|
}
|
|
297
357
|
return st;
|
|
298
358
|
}
|
|
299
359
|
|
|
360
|
+
function throwIfRefused(r, id) {
|
|
361
|
+
if (r.status < 400 || r.status >= 500 || POLL_RETRY_4XX.has(r.status)) return;
|
|
362
|
+
const code = r.body?.error?.code || null;
|
|
363
|
+
throw Object.assign(new Error(`pollVideo: job ${id} answered HTTP ${r.status}${code ? ` ${code}` : ''}`), { status: r.status, code });
|
|
364
|
+
}
|
|
365
|
+
|
|
300
366
|
/** Download finished video bytes. Returns an mp4 Buffer; throws on non-200 / empty body. */
|
|
301
367
|
export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview' } = {}) {
|
|
302
368
|
const r = await azFetch(`${endpoint}/openai/v1/videos/${id}/content?api-version=${apiVersion}`, {
|
|
@@ -397,6 +463,22 @@ export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
|
|
|
397
463
|
return out;
|
|
398
464
|
}
|
|
399
465
|
|
|
466
|
+
/**
|
|
467
|
+
* A still as a short clip: the hold of the hold-and-flight mode. The PNG repeats for `seconds`,
|
|
468
|
+
* encoded as the scrub engine wants (H.264 yuv420p, a keyframe every 8 frames, faststart, no
|
|
469
|
+
* audio). No generation call, so no cost. Every frame is the still, so a flight whose first or last
|
|
470
|
+
* frame is this still meets the hold exactly. `size` ('720x1280') scales; even sizes only. Returns out.
|
|
471
|
+
*/
|
|
472
|
+
export async function stillToClip(png, out, { ffmpeg, seconds = 1, fps = 24, size } = {}) {
|
|
473
|
+
const bin = ffmpeg || await resolveFfmpeg();
|
|
474
|
+
fs.rmSync(out, { force: true }); // a file from an earlier run must not pass for this one
|
|
475
|
+
const vf = `${size ? `scale=${String(size).replace('x', ':')},` : ''}format=yuv420p`;
|
|
476
|
+
const r = runFf(bin, ['-y', '-loop', '1', '-i', png, '-t', String(seconds), '-r', String(fps), '-vf', vf,
|
|
477
|
+
'-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out]);
|
|
478
|
+
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`stillToClip failed: ${tail(r.stderr)}`);
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
481
|
+
|
|
400
482
|
/** Extract the FIRST frame of an mp4 to a PNG. Returns out. */
|
|
401
483
|
export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
|
|
402
484
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
@@ -481,15 +563,19 @@ const VIDEO_USD_PER_SEC = 0.10;
|
|
|
481
563
|
const IMAGE_USD_EACH = 0.02;
|
|
482
564
|
|
|
483
565
|
/**
|
|
484
|
-
* Rough budget for an intro of `nScenes` scenes. Video billed at $0.10/sec/clip
|
|
485
|
-
* per scene (two per scene in two-image
|
|
566
|
+
* Rough budget for an intro of `nScenes` scenes. Video billed at $0.10/sec/clip (the Sora rate:
|
|
567
|
+
* WAN rates cannot be read through an API); one image gen per scene (two per scene in two-image
|
|
568
|
+
* mode). mode 'chain' (default) makes one clip of `seconds` per scene; 'holdFlight' makes one
|
|
569
|
+
* flight of `flightSeconds` between each pair of scenes, the holds being encoded stills (free).
|
|
570
|
+
* Returns per-category USD sub-costs and the total.
|
|
486
571
|
* @returns {{images:number, videos:number, usd:number}}
|
|
487
572
|
*/
|
|
488
|
-
export function estimateCost({ nScenes, seconds = 4, twoImage = false } = {}) {
|
|
573
|
+
export function estimateCost({ nScenes, seconds = 4, twoImage = false, mode = 'chain', flightSeconds = 5 } = {}) {
|
|
489
574
|
const n = Number(nScenes) || 0;
|
|
490
575
|
const imageCount = twoImage ? n * 2 : n;
|
|
491
576
|
const images = round2(imageCount * IMAGE_USD_EACH);
|
|
492
|
-
const
|
|
577
|
+
const clipSeconds = mode === 'holdFlight' ? Math.max(0, n - 1) * flightSeconds : n * seconds;
|
|
578
|
+
const videos = round2(clipSeconds * VIDEO_USD_PER_SEC);
|
|
493
579
|
const usd = round2(images + videos);
|
|
494
580
|
return { images, videos, usd };
|
|
495
581
|
}
|