@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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +48 -0
  4. package/README.md +31 -9
  5. package/codex/manifest.json +6 -3
  6. package/codex/transform-matrix.md +2 -0
  7. package/commands/progress.md +10 -0
  8. package/commands/setup.md +1 -1
  9. package/hooks/graceful-pause.mjs +31 -71
  10. package/hooks/hooks.json +11 -1
  11. package/hooks/progress-fallback.mjs +10 -0
  12. package/hooks/progress.mjs +427 -0
  13. package/hooks/register.mjs +82 -5
  14. package/package.json +1 -1
  15. package/skills/3d-intro-build/SKILL.md +235 -122
  16. package/skills/3d-intro-build/references/NOTICE.md +2 -1
  17. package/skills/3d-intro-build/references/assemble.mjs +164 -108
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +115 -29
  19. package/skills/3d-intro-build/references/curate.mjs +573 -267
  20. package/skills/3d-intro-build/references/intro-fixes.css +61 -0
  21. package/skills/3d-intro-build/references/panel-glass.css +55 -0
  22. package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
  23. package/skills/3d-intro-build/references/prompts.md +32 -9
  24. package/skills/3d-intro-build/references/serve.mjs +111 -103
  25. package/skills/3d-intro-build/references/step-nav.css +59 -0
  26. package/skills/3d-intro-build/references/step-nav.js +503 -0
  27. package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
  28. package/skills/3d-intro-build/references/verify.md +45 -0
  29. package/skills/3d-intro-build/references/video-pool.mjs +780 -0
  30. package/skills/3d-intro-setup/SKILL.md +49 -12
  31. package/skills/3d-intro-setup/references/azure-adapter.mjs +115 -29
  32. package/skills/3d-intro-setup/references/video-pool.mjs +780 -0
  33. package/skills/motion-graphic-make/SKILL.md +73 -28
  34. package/skills/motion-graphic-make/references/curate.mjs +607 -0
  35. package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
  36. package/skills/motion-graphic-make/references/serve.mjs +131 -0
  37. package/skills/omc-patch/SKILL.md +143 -0
  38. package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
  39. package/skills/remains/SKILL.md +125 -0
  40. package/skills/remains/scripts/boxes.mjs +448 -0
  41. 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 크레덴셜을 저장함. 크레덴셜은 직접 입력하거나 Azure 콘솔의 샘플 코드를 붙여 넣어 받고, 무과금 프리플라이트로 확인. 'setup-3d-intro'/'3d-intro-setup'/'3D 인트로 설정'/'Azure Sora 크레덴셜' 또는 `/banker:setup` 시 사용."
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`(수정 금지, `3d-intro-build`와 byte-identical 공유 사본)를 통해서만 수행한다.
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
- ### 리소스는 최대 3개(Sora·Image·FLUX)
90
- Sora(영상)와 gpt-image-2(이미지)는 **보통 서로 다른 Azure 리소스**(엔드포인트·키가 다를 수 있음) — 두 세트를 각각 받는다.
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-2)**: endpoint + key + 배포명(예: `gpt-image-2`, 필수 — 생성 호출의 `model` 필드로 그대로 쓰인다).
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 written = persistCreds({
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
- **여기서는 어떤 유료 생성도 실행하지 않는다** — gpt-image-2 이미지 생성, Sora-2 영상 생성 어느 쪽도 호출하지 않는다.
150
- 아래 두 호출만 수행한다: (1) Sora `listVideos` GET(목록 조회는 무료) (2) Image 는 `azFetch` 로 **body `{model:<배포명>}` (프롬프트 누락) POST** — 배포로 라우팅된 뒤 프롬프트 누락으로 생성 전 `400 missing_required_parameter` 로 거부되므로 과금 0이고, 엔드포인트·인증에 더해 **배포명까지** 무료로 검증된다. (빈 `{}` 는 model 이 없어 `404 DeploymentNotFound` 로 갈려 배포명을 확인하지 못한다.)
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 pass = sora.status === 200 && img.status === 400;
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
- **판정 기준(둘 다 충족해야 PASS)**:
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 두 status 코드) / 검증(`listVideos`=200 **AND** `images.badreq`=400 모두 충족해야 PASS) / Unknown(FLUX 미검증 · 배포명이 실제 존재하는지는 이 무과금 프리플라이트로 확인 불가하며 첫 실제 생성 시점에만 드러남 · 관리자 권한 제약으로 ffmpeg 시스템 설치 불가 등, 가짜 성공 금지).
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
- /** Parse a KEY=VALUE .env body into a plain object (strips surrounding quotes, skips comments). */
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*=\s*(.*?)\s*$/);
110
- if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, '');
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
- * @returns {Promise<{status:number, ok:boolean, ct:string, body:any, authStyle:string|null, error?:Error}>}
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
- * `onNote(msg)` (optional) is invoked when the classic fallback is taken.
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 azFetch(`${endpoint}/openai/v1/images/generations?api-version=${apiVersion}`, {
204
- key, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(genBody),
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
- if (!(r.ok && r.body?.data?.[0]?.b64_json)) {
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 azFetch(`${endpoint}/openai/deployments/${deployment}/images/generations?api-version=2025-04-01-preview`, {
211
- key, method: 'POST', headers: { 'Content-Type': 'application/json' },
212
- body: JSON.stringify({ ...genBody, size: classicSize(size) }),
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)) throw new Error(`createVideo failed: HTTP ${r.status} ${truncate(r.body)}`);
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
- endpoint, key, id, onTick, apiVersion = 'preview', intervalMs = 5000, maxTicks = 180,
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
- st = r.body || st;
293
- if (typeof onTick === 'function') onTick(st, i);
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; one image gen
485
- * per scene (two per scene in two-image mode). Returns per-category USD sub-costs and the total.
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 videos = round2(n * seconds * VIDEO_USD_PER_SEC);
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
  }