@kaydash9999/banker-plugins 0.16.0 → 0.16.2
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 +30 -0
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +8 -4
- package/skills/3d-intro-build/references/azure-adapter.mjs +173 -44
- package/skills/3d-intro-build/references/verify-intro.mjs +27 -15
- package/skills/3d-intro-build/references/verify.md +1 -1
- package/skills/3d-intro-build/references/video-pool.mjs +58 -21
- package/skills/3d-intro-setup/SKILL.md +94 -53
- package/skills/3d-intro-setup/references/azure-adapter.mjs +173 -44
- package/skills/3d-intro-setup/references/video-pool.mjs +58 -21
|
@@ -37,7 +37,7 @@ import path from 'node:path';
|
|
|
37
37
|
import os from 'node:os';
|
|
38
38
|
import crypto from 'node:crypto';
|
|
39
39
|
import cp from 'node:child_process';
|
|
40
|
-
import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact } from './azure-adapter.mjs';
|
|
40
|
+
import { createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey } from './azure-adapter.mjs';
|
|
41
41
|
|
|
42
42
|
export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
|
|
43
43
|
const WAN_CREATE_PATH = '/api/v1/services/aigc/video-generation/video-synthesis';
|
|
@@ -50,6 +50,8 @@ const PROBE_TASK_ID = '00000000-0000-0000-0000-000000000000';
|
|
|
50
50
|
|
|
51
51
|
const RATE_BASE_MS = 30_000;
|
|
52
52
|
const RATE_MAX_MS = 10 * 60_000;
|
|
53
|
+
// A server Retry-After is held to this range: 0 would retry at once, a huge value rest a key for years.
|
|
54
|
+
const RETRY_MIN_MS = 1000;
|
|
53
55
|
const QUOTA_STRIKES_TO_EXHAUST = 3;
|
|
54
56
|
const QUOTA_STRIKE_TTL_MS = 3_600_000;
|
|
55
57
|
const TRANSIENT_BASE_MS = 15_000;
|
|
@@ -72,7 +74,9 @@ const TRANSIENT_CODES = new Set([
|
|
|
72
74
|
'InternalError', 'InternalError.Timeout', 'ServiceUnavailable', 'ModelUnavailable', 'Throttling.ServiceOverloaded',
|
|
73
75
|
]);
|
|
74
76
|
// Errors that mean the request never left this machine, so resubmitting elsewhere cannot double-bill.
|
|
75
|
-
|
|
77
|
+
// EINSECURE is fetchGuarded refusing a key over http before sending. Its redirect codes are not here:
|
|
78
|
+
// a redirect answers a request that was sent.
|
|
79
|
+
const PRE_SEND_ERRORS = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL', 'EINSECURE']);
|
|
76
80
|
|
|
77
81
|
/**
|
|
78
82
|
* Map a WAN failure to a pool action.
|
|
@@ -115,11 +119,13 @@ function codeKind(code) {
|
|
|
115
119
|
|
|
116
120
|
// Endpoints are origins: a pasted console URL with a path would otherwise 404 every call. A value
|
|
117
121
|
// new URL() rejects (a bare host, a value with spaces, a key pasted into the wrong field) gives
|
|
118
|
-
// null. The raw value is never echoed, because it may be a key.
|
|
122
|
+
// null. The raw value is never echoed, because it may be a key. The key goes only over https: an
|
|
123
|
+
// http:// endpoint (one typo) would send it in the clear, so it counts as malformed too, except on
|
|
124
|
+
// this machine (a local mock or proxy). safeForKey is the same rule fetchGuarded enforces.
|
|
119
125
|
function originOf(url) {
|
|
120
126
|
try {
|
|
121
127
|
const u = new URL(String(url).trim());
|
|
122
|
-
return
|
|
128
|
+
return safeForKey(u) && u.host ? `${u.protocol}//${u.host}` : null;
|
|
123
129
|
} catch { return null; }
|
|
124
130
|
}
|
|
125
131
|
const fingerprint = (endpoint, key, model) => crypto.createHash('sha256').update(`${endpoint}\n${key}\n${model}`).digest('hex').slice(0, 16);
|
|
@@ -136,8 +142,8 @@ const malformedField = (e) => (e.badEndpoint ? 'endpoint' : e.badModel ? 'model'
|
|
|
136
142
|
|
|
137
143
|
/**
|
|
138
144
|
* Build the WAN pool from creds: every WAN_<n>_ENDPOINT with a matching WAN_<n>_API_KEY, by n.
|
|
139
|
-
* An entry whose endpoint is not an
|
|
140
|
-
* (`badModel`) is kept so the preflight can report it; generateClip skips it.
|
|
145
|
+
* An entry whose endpoint is not an https URL (`badEndpoint`; http only to this machine) or whose
|
|
146
|
+
* model is not one model id (`badModel`) is kept so the preflight can report it; generateClip skips it.
|
|
141
147
|
*/
|
|
142
148
|
export function loadWanPool(creds = {}) {
|
|
143
149
|
const pool = [];
|
|
@@ -171,11 +177,20 @@ export function loadPoolState(file = defaultStatePath()) {
|
|
|
171
177
|
return { version: 1, entries: {} };
|
|
172
178
|
}
|
|
173
179
|
|
|
180
|
+
// Written through a fresh random name opened exclusively ('wx'), so a link someone planted at a
|
|
181
|
+
// guessable name is never followed; the folder is private when this creates it. A failed write or
|
|
182
|
+
// rename removes the temp file.
|
|
174
183
|
export function savePoolState(state, file = defaultStatePath()) {
|
|
175
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
176
|
-
const tmp = `${file}.${
|
|
177
|
-
fs.
|
|
178
|
-
|
|
184
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
185
|
+
const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
|
186
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
187
|
+
try {
|
|
188
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`); } finally { fs.closeSync(fd); }
|
|
189
|
+
fs.renameSync(tmp, file);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
fs.rmSync(tmp, { force: true });
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
179
194
|
try { fs.chmodSync(file, 0o600); } catch { /* chmod unsupported (e.g. Windows) — best effort */ }
|
|
180
195
|
return file;
|
|
181
196
|
}
|
|
@@ -258,9 +273,9 @@ function pickEntry(pool, state, t, limit) {
|
|
|
258
273
|
function parseRetryAfter(h) {
|
|
259
274
|
if (!h) return null;
|
|
260
275
|
const sec = Number(h);
|
|
261
|
-
if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
|
|
262
276
|
const at = Date.parse(h);
|
|
263
|
-
|
|
277
|
+
const ms = Number.isFinite(sec) ? sec * 1000 : Number.isFinite(at) ? at - Date.now() : null;
|
|
278
|
+
return ms === null ? null : Math.min(RATE_MAX_MS, Math.max(RETRY_MIN_MS, ms));
|
|
264
279
|
}
|
|
265
280
|
|
|
266
281
|
const WAN_FETCH_DEFAULTS = { method: 'GET', binary: false, timeoutMs: API_TIMEOUT_MS };
|
|
@@ -269,7 +284,7 @@ async function wanFetch(url, opts = {}) {
|
|
|
269
284
|
const o = { ...WAN_FETCH_DEFAULTS, ...defined(opts) };
|
|
270
285
|
let r;
|
|
271
286
|
try {
|
|
272
|
-
r = await
|
|
287
|
+
r = await fetchGuarded(url, {
|
|
273
288
|
method: o.method, headers: wanHeaders(o), body: o.body === undefined ? undefined : JSON.stringify(o.body), signal: AbortSignal.timeout(o.timeoutMs),
|
|
274
289
|
});
|
|
275
290
|
} catch (e) {
|
|
@@ -391,7 +406,13 @@ export async function wanPollTask({ entry, taskId, intervalMs = 15_000, timeoutM
|
|
|
391
406
|
}
|
|
392
407
|
|
|
393
408
|
/** Download a finished WAN video (signed URL, no auth header). Returns an mp4 Buffer. */
|
|
409
|
+
// The video URL comes from the provider's answer: only https is fetched.
|
|
410
|
+
function httpsOnly(url) {
|
|
411
|
+
if (!/^https:\/\//i.test(String(url))) throw new Error('WAN video download refused: the video_url is not https');
|
|
412
|
+
}
|
|
413
|
+
|
|
394
414
|
export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {}) {
|
|
415
|
+
httpsOnly(url);
|
|
395
416
|
let last;
|
|
396
417
|
for (let i = 0; i < tries; i++) {
|
|
397
418
|
last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
|
@@ -436,9 +457,12 @@ export class ContentRejectedError extends Error {
|
|
|
436
457
|
|
|
437
458
|
// An error that must NOT be retried by resubmitting the leg: the task may exist and bill.
|
|
438
459
|
// kind: timeout | submit_unknown | download | no_video | task_lost. Carries taskId/label when known.
|
|
439
|
-
|
|
460
|
+
// A stop the caller can act on. `props` are plain properties (they show in logs); `hidden` ones (a
|
|
461
|
+
// signed download URL) read as err.<name> but stay out of JSON.stringify and console.error.
|
|
462
|
+
function stopError(kind, message, props = {}, hidden = {}) {
|
|
440
463
|
const err = new Error(message);
|
|
441
464
|
Object.assign(err, { kind, ...props });
|
|
465
|
+
for (const [k, v] of Object.entries(hidden)) Object.defineProperty(err, k, { value: v, enumerable: false });
|
|
442
466
|
return err;
|
|
443
467
|
}
|
|
444
468
|
|
|
@@ -530,8 +554,8 @@ async function succeededClip(ctx, e, taskId, p, seconds) {
|
|
|
530
554
|
try {
|
|
531
555
|
mp4 = await downloadWanVideo(videoUrl, { sleep: ctx.sleep });
|
|
532
556
|
} catch (err) {
|
|
533
|
-
// videoUrl is a signed URL:
|
|
534
|
-
throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label,
|
|
557
|
+
// videoUrl is a signed URL (valid 24 h): readable as err.videoUrl for recovery, never in the message or a log
|
|
558
|
+
throw stopError('download', `${err.message} for WAN task ${taskId} (${e.label}); resume with resumeClip within 24 h`, { provider: 'wan', taskId, label: e.label, seconds }, { videoUrl });
|
|
535
559
|
}
|
|
536
560
|
ctx.emit({ type: 'done', provider: 'wan', label: e.label, taskId });
|
|
537
561
|
return { mp4, provider: 'wan', label: e.label, model: shownModel(e), seconds: seconds ?? p.usage?.duration ?? null, taskId, usage: p.usage };
|
|
@@ -563,7 +587,7 @@ async function runWan(ctx) {
|
|
|
563
587
|
function usablePool(ctx) {
|
|
564
588
|
const all = loadWanPool(ctx.creds);
|
|
565
589
|
const bad = all.filter(malformedField);
|
|
566
|
-
for (const b of bad) ctx.emit({ type: 'skip', provider: 'wan', label: b.label, reason: `${malformedField(b)} is malformed (not an
|
|
590
|
+
for (const b of bad) ctx.emit({ type: 'skip', provider: 'wan', label: b.label, reason: `${malformedField(b)} is malformed (not an https URL / not one model id)` });
|
|
567
591
|
if (all.length && bad.length === all.length) {
|
|
568
592
|
throw stopError('invalid_request', `every WAN entry is malformed (${bad.map((b) => `${b.label} ${malformedField(b)}`).join(', ')}); fix the env file — nothing was sent, and Sora was not used`, { provider: 'wan' });
|
|
569
593
|
}
|
|
@@ -722,7 +746,8 @@ const DEFAULTS = { size: '720x1280', seconds: 5, pollIntervalMs: 15_000, pollTim
|
|
|
722
746
|
*
|
|
723
747
|
* Throws (never resubmitting the leg) when retrying could pay twice or cannot help:
|
|
724
748
|
* ContentRejectedError moderation — change the prompt/frame
|
|
725
|
-
* kind 'invalid_request' malformed request, ModelNotFound
|
|
749
|
+
* kind 'invalid_request' malformed request, ModelNotFound, every WAN entry malformed or a
|
|
750
|
+
* Sora endpoint that is not https (config)
|
|
726
751
|
* kind 'timeout' | 'download' the task exists — call resumeClip({ ..., provider, label, taskId })
|
|
727
752
|
* kind 'submit_unknown' | 'task_lost' | 'no_video' | 'failed' outcome unknown — check the console
|
|
728
753
|
* ('failed': the task ended for a reason the pool does not know)
|
|
@@ -741,7 +766,7 @@ export async function generateClip(opts = {}) {
|
|
|
741
766
|
const r = await runWan(ctx);
|
|
742
767
|
if (r) return r;
|
|
743
768
|
} else if (p === 'sora') {
|
|
744
|
-
if (!
|
|
769
|
+
if (!soraReady(ctx)) continue;
|
|
745
770
|
tried.push('sora');
|
|
746
771
|
ctx.emit({ type: 'fallback', provider: 'sora', from: tried.slice(0, -1) });
|
|
747
772
|
return runSora(ctx);
|
|
@@ -750,6 +775,18 @@ export async function generateClip(opts = {}) {
|
|
|
750
775
|
throw new Error(`generateClip: no video provider available (tried: ${tried.join(', ') || 'none'})`);
|
|
751
776
|
}
|
|
752
777
|
|
|
778
|
+
// Whether Sora can take the leg. Not set up: skipped with an event. An endpoint that is set but not
|
|
779
|
+
// https (http only to this machine) stops the leg before the key is sent in the clear.
|
|
780
|
+
function soraReady(ctx) {
|
|
781
|
+
const { AZURE_SORA_ENDPOINT: endpoint, AZURE_SORA_API_KEY: key } = ctx.creds;
|
|
782
|
+
if (!endpoint || !key) {
|
|
783
|
+
ctx.emit({ type: 'skip', provider: 'sora', reason: 'AZURE_SORA_ENDPOINT/API_KEY missing' });
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
if (!originOf(endpoint)) throw stopError('invalid_request', 'AZURE_SORA_ENDPOINT is not an https URL; fix the env file — nothing was sent', { provider: 'sora' });
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
|
|
753
790
|
/**
|
|
754
791
|
* Finish a clip whose task already exists (after a 'timeout' or 'download' error) without
|
|
755
792
|
* submitting anything new. FREE except for the work already billed. Same return shape as
|
|
@@ -763,7 +800,7 @@ export async function resumeClip(opts = {}) {
|
|
|
763
800
|
const ctx = makeCtx(o);
|
|
764
801
|
if (o.provider !== 'sora') return resumeWan(ctx, o);
|
|
765
802
|
if (!originOf(o.creds.AZURE_SORA_ENDPOINT) || !o.creds.AZURE_SORA_API_KEY) {
|
|
766
|
-
throw stopError('invalid_request', 'resumeClip: resuming a Sora job needs AZURE_SORA_ENDPOINT as an
|
|
803
|
+
throw stopError('invalid_request', 'resumeClip: resuming a Sora job needs AZURE_SORA_ENDPOINT as an https URL and AZURE_SORA_API_KEY (run 3d-intro-setup); nothing was sent', { provider: 'sora', taskId: o.taskId });
|
|
767
804
|
}
|
|
768
805
|
return finishSoraJob(ctx, o.taskId, o.seconds == null ? null : snapSoraSeconds(o.seconds));
|
|
769
806
|
}
|
|
@@ -772,7 +809,7 @@ async function resumeWan(ctx, o) {
|
|
|
772
809
|
const e = loadWanPool(o.creds).find((x) => x.label === o.label);
|
|
773
810
|
if (!e) throw new Error(`resumeClip: ${o.label} is not in the WAN pool (was the key removed?)`);
|
|
774
811
|
if (e.badEndpoint) {
|
|
775
|
-
throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an
|
|
812
|
+
throw stopError('invalid_request', `resumeClip: ${o.label} endpoint is not an https URL; fix the env file — nothing was sent`, { provider: 'wan', label: o.label, taskId: o.taskId });
|
|
776
813
|
}
|
|
777
814
|
const out = await finishWanTask(ctx, e, o.taskId, { duration: Math.round(Number(o.seconds) || 0) || null });
|
|
778
815
|
if (out.clip) return out.clip;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: 3d-intro-setup
|
|
3
|
-
description: "`3d-intro-build` 에 필요한 Node 18 이상과 ffmpeg 를 갖추고 Azure 크레덴셜과 선택 항목인 WAN API 키 풀을 저장함.
|
|
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 인트로 제작 전제조건 설치
|
|
@@ -43,28 +43,33 @@ OS별 상세 설치 절차(Ubuntu NodeSource·RHEL8/Rocky8 dnf 모듈·Windows w
|
|
|
43
43
|
|
|
44
44
|
## 2. ffmpeg 확보
|
|
45
45
|
어댑터의 프레임 추출·컬러 매치·크로스페이드·이어붙이기(`extractLastFrame`·`colorMatch`·`crossfade`·`concatClips`, forward-chaining 이음새 처리)가 `ffmpeg` 바이너리를 요구한다.
|
|
46
|
-
설치 절차는 `motion-graphic-setup` §2 와
|
|
46
|
+
설치 절차는 `motion-graphic-setup` §2 와 같으므로 반복하지 않고 요약만 남긴다(전체 함정은 그 스킬 참조. 마지막 폴백의 설치 위치만 다르다):
|
|
47
47
|
- macOS: `brew install ffmpeg`
|
|
48
48
|
- Ubuntu/Debian: `sudo apt-get install -y ffmpeg`
|
|
49
49
|
- RHEL8/Rocky8: BaseOS/AppStream 미포함(라이선스) → RPM Fusion 후 `dnf install -y ffmpeg`
|
|
50
50
|
- Windows: `winget install --id Gyan.FFmpeg -e`
|
|
51
|
-
- 전부 막히면(권한/오프라인)
|
|
51
|
+
- 전부 막히면(권한/오프라인) 홈 폴더의 전용 폴더에 `ffmpeg-static` 을 받는다. 프로젝트 안에서 npm 을 돌리면 그 프로젝트의 `.npmrc` 와 스크립트가 끼어든다:
|
|
52
|
+
`(cd ~ && npm install --prefix ~/.config/banker/3d-intro/tools ffmpeg-static)` 후 `export FFMPEG_PATH="$(cd ~ && node -p "require(require('path').resolve('.config/banker/3d-intro/tools/node_modules/ffmpeg-static'))")"`
|
|
52
53
|
|
|
53
|
-
**★ banker 플러그인 자체(스킬 파일·npm 패키지 배포물)에는 ffmpeg 바이너리를 절대 번들하지 않는다** — 위 설치는 전부 사용자
|
|
54
|
+
**★ banker 플러그인 자체(스킬 파일·npm 패키지 배포물)에는 ffmpeg 바이너리를 절대 번들하지 않는다** — 위 설치는 전부 사용자 시스템(홈 폴더 포함)에만 적용된다(motion-graphic-setup과 동일 원칙).
|
|
54
55
|
|
|
55
|
-
검증은 `ffmpeg -version` 대신 **어댑터가 실제 쓰는 해석 로직**(`resolveFfmpeg`: `$FFMPEG_PATH` → `PATH`
|
|
56
|
-
`FFMPEG_PATH`(export)
|
|
56
|
+
검증은 `ffmpeg -version` 대신 **어댑터가 실제 쓰는 해석 로직**(`resolveFfmpeg`: 셸의 `$FFMPEG_PATH` → 홈 설정 파일 `~/.config/banker/3d-intro/settings.env` 의 `FFMPEG_PATH` → 예전 판이 홈 키 파일 `env` 에 남긴 `FFMPEG_PATH` → `PATH` 의 절대 경로 항목 → `ffmpeg-static` 순. 경로는 모두 절대 경로만)으로 한다 — 이게 통과해야 3d-intro-build 가 실제로 ffmpeg 를 찾는다.
|
|
57
|
+
셸의 `FFMPEG_PATH`(export, 절대 경로만 받는다)로만 찾았으면 셸 세션 한정이라, 그 경로를 **홈 설정 파일 `settings.env` 에 `FFMPEG_PATH=` 로 영속**해 다른 세션의 3d-intro-build 도 찾게 한다. 시스템 `ffmpeg` 가 `PATH` 에 있으면 영속은 필요 없다. 이 파일에는 키를 두지 않아, 키 파일을 어디에 만들지 정하는 §3 에 영향을 주지 않는다.
|
|
58
|
+
실행할 프로그램의 경로는 프로젝트 키 파일(`.env.3d-intro.local`)에 두지 않고, 거기 적힌 값은 읽지 않는다. 그 파일은 저장소와 함께 올 수 있어, 남이 만든 저장소가 이 기기에서 실행할 프로그램을 정하게 되기 때문이다.
|
|
57
59
|
```bash
|
|
58
60
|
node -e "
|
|
59
61
|
(async () => {
|
|
60
|
-
const { resolveFfmpeg, resolveCreds, persistCreds } = await import('$ADAPTER');
|
|
62
|
+
const { resolveFfmpeg, resolveCreds, persistCreds, readSettings, homeSettingsPath } = await import('$ADAPTER');
|
|
63
|
+
const proj = resolveCreds({ projectDir: process.cwd() });
|
|
64
|
+
if (proj._ignored) console.log('프로젝트 키 파일에서 쓰지 않는 값:', Object.keys(proj._ignored).join(', ')); // 값은 출력하지 않는다
|
|
61
65
|
const ff = await resolveFfmpeg().catch((e) => { console.log('ffmpeg 미확보:', e.message); process.exit(1); });
|
|
62
66
|
console.log('ffmpeg 확보:', ff);
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
67
|
+
const s = readSettings(); // 홈 설정 파일(키는 두지 않음)
|
|
68
|
+
if (ff === process.env.FFMPEG_PATH && s.FFMPEG_PATH !== ff) { persistCreds({ ...s, FFMPEG_PATH: ff }, { target: homeSettingsPath() }); console.log('FFMPEG_PATH 영속 ->', homeSettingsPath()); }
|
|
65
69
|
})();
|
|
66
70
|
"
|
|
67
71
|
```
|
|
72
|
+
`프로젝트 키 파일에서 쓰지 않는 값` 이 보이면 예전 판이 그 파일에 남긴 ffmpeg 경로다. 값은 출력하지 않으니 사람이 파일을 열어 그 경로가 맞는지 확인하고, 맞으면 셸에서 `export FFMPEG_PATH=<그 경로>` 한 뒤 이 명령을 다시 돌려 홈 설정 파일에 남긴다.
|
|
68
73
|
|
|
69
74
|
## 3. 크레덴셜 입력
|
|
70
75
|
**★시크릿(API key)은 사용자만 입력 — 채팅 로그·커밋·리포트에 원문 노출 금지, 항상 `redact()` 결과만 표기한다.**
|
|
@@ -94,64 +99,97 @@ Sora(영상)와 gpt-image(이미지)는 **보통 서로 다른 Azure 리소스**
|
|
|
94
99
|
WAN 영상 풀(§3-WAN)은 선택이지만, 있으면 영상이 WAN 으로 먼저 만들어진다.
|
|
95
100
|
FLUX.2-pro(대체 이미지 생성)는 **opt-in**: 필요 없으면 완전히 건너뛴다(§3-FLUX 참조).
|
|
96
101
|
|
|
97
|
-
###
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
AI Foundry 리소스(`https://<resource>.services.ai.azure.com`)도 그대로 쓴다. 콘솔이 `.../models` 를 붙여 보여줘도 저장은 origin 까지만 한다(이미지 경로는 `/openai/v1/images/...` 이고 `/models/images/...` 는 404).
|
|
102
|
+
### 키는 사람이 파일에 적는다
|
|
103
|
+
**API key 는 채팅으로 받지 않고, 에이전트가 실행하는 명령에도 넣지 않는다.** `node -e "..."` 인자는 `ps` 출력과 대화 기록에 그대로 남는다.
|
|
104
|
+
키는 사람이 아래 비공개 키 파일에 편집기로 직접 적거나(모드 a), 콘솔 샘플을 파일로 저장해 에이전트가 화면에 내지 않고 합친다(모드 b).
|
|
105
|
+
엔드포인트, 배포명, API 버전은 비밀이 아니라 대화로 받아도 된다.
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
붙여넣은 원문은 세션 스크래치 임시 파일에 저장 → `parseConsoleSample()` 로 파싱 → **파싱 직후 임시 파일 즉시 삭제**(원문에 raw key 가 그대로 들어있다).
|
|
107
|
+
키 파일은 먼저 비공개로 만든다(이미 있으면 그대로 둔다). `echo >>` 로 새 파일을 만들면 0644 로 남는다.
|
|
108
|
+
홈 키 파일(아래 fallback)이 이미 있으면 프로젝트 파일을 만들지 않는다. 빈 프로젝트 파일이 먼저 읽혀 홈의 키가 가려진다.
|
|
106
109
|
```bash
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const fs = require('fs');
|
|
110
|
-
const { parseConsoleSample, redact } = await import('$ADAPTER');
|
|
111
|
-
const text = fs.readFileSync(process.argv[1], 'utf8');
|
|
112
|
-
const p = parseConsoleSample(text);
|
|
113
|
-
console.log(JSON.stringify({ endpoint: p.endpoint, deployment: p.deployment, apiVersion: p.apiVersion, apiKey: redact(p.apiKey) }));
|
|
114
|
-
})();
|
|
115
|
-
" "<붙여넣은 샘플을 저장한 임시 파일 절대경로로 치환>"
|
|
110
|
+
[ -e ~/.config/banker/3d-intro/env ] || (umask 077 && touch .env.3d-intro.local)
|
|
111
|
+
ls -l .env.3d-intro.local ~/.config/banker/3d-intro/env 2>/dev/null || true # 있는 파일이 -rw------- 면 된다
|
|
116
112
|
```
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
### 저장
|
|
121
|
-
아래 필드로 합쳐 `persistCreds()` 로 저장한다(0600 자동 적용, 원문 재출력 안 함):
|
|
113
|
+
여러 프로젝트에서 재사용하려면 프로젝트 파일 대신 홈 폴더 fallback 을 쓴다. `resolveCreds` 가 project-local 다음으로 찾는 경로다.
|
|
114
|
+
```bash
|
|
115
|
+
mkdir -p ~/.config/banker/3d-intro && chmod 700 ~/.config/banker/3d-intro && (umask 077 && touch ~/.config/banker/3d-intro/env)
|
|
122
116
|
```
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
그 프로젝트의 `.gitignore` 에 무시 규칙이 있는지 확인하고 없으면 추가한다(이 저장소는 `.env.*.local` 로 이미 커버된다):
|
|
118
|
+
```bash
|
|
119
|
+
grep -qE '^\.env\.3d-intro\.local$|^\.env\.\*\.local$' .gitignore 2>/dev/null || echo '.env.3d-intro.local' >> .gitignore
|
|
125
120
|
```
|
|
126
|
-
|
|
121
|
+
|
|
122
|
+
### 모드 (a) — 직접 입력
|
|
123
|
+
엔드포인트와 배포명을 리소스별로 대화로 묻는다: endpoint(예: `https://<resource>.openai.azure.com` 또는 `https://<resource>.cognitiveservices.azure.com`), 배포명(deployment).
|
|
124
|
+
- **Sora**: endpoint (+ 배포명, 기본 `sora-2` — 콘솔에서 다른 이름으로 배포했으면 그 이름).
|
|
125
|
+
- **Image(gpt-image 계열)**: endpoint + 배포명(예: `gpt-image-2`, `gpt-image-2.5-sunburst`. 필수 — 생성 호출의 `model` 필드로 그대로 쓰인다).
|
|
126
|
+
AI Foundry 리소스(`https://<resource>.services.ai.azure.com`)도 그대로 쓴다. 콘솔이 `.../models` 를 붙여 보여줘도 저장은 origin 까지만 한다(이미지 경로는 `/openai/v1/images/...` 이고 `/models/images/...` 는 404).
|
|
127
|
+
|
|
128
|
+
비밀이 아닌 값은 에이전트가 `persistCreds()` 로 합친다(파일 전체를 새로 쓰되 기존 값, 사람이 적은 키 포함, 은 그대로 둔다. 0600 자동 적용). 키 칸은 이 명령에 넣지 않는다.
|
|
129
|
+
쓰는 곳은 `resolveCreds` 가 이미 읽는 키 파일(홈 fallback 포함)이고, 없을 때만 프로젝트 파일이다. 홈 파일을 쓰는 사람에게 프로젝트 파일을 새로 만들면 그 파일이 먼저 읽혀 홈의 키가 가려진다:
|
|
127
130
|
```bash
|
|
128
131
|
node -e "
|
|
129
132
|
(async () => {
|
|
130
133
|
const { persistCreds, resolveCreds } = await import('$ADAPTER');
|
|
131
134
|
const path = require('path');
|
|
132
|
-
const target = path.join(process.cwd(), '.env.3d-intro.local'); // 프로젝트 전용(기본, 권장)
|
|
133
135
|
const prev = resolveCreds({ projectDir: process.cwd() });
|
|
134
|
-
const
|
|
135
|
-
const written = persistCreds({ ...
|
|
136
|
-
AZURE_SORA_ENDPOINT: '<입력값>',
|
|
137
|
-
|
|
138
|
-
AZURE_IMAGE_OPENAI_ENDPOINT: '<입력값>', AZURE_IMAGE_API_KEY: '<입력값>',
|
|
139
|
-
AZURE_GPT_IMAGE_DEPLOYMENT: '<입력값>', AZURE_GPT_IMAGE_API_VERSION: '<입력값 또는 preview>',
|
|
140
|
-
// WAN 풀(선택): 키 수만큼 WAN_<n>_ENDPOINT / WAN_<n>_API_KEY, 필요하면 WAN_MODEL
|
|
136
|
+
const target = prev._source || path.join(process.cwd(), '.env.3d-intro.local');
|
|
137
|
+
const written = persistCreds({ ...prev,
|
|
138
|
+
AZURE_SORA_ENDPOINT: '<입력값>', AZURE_SORA_DEPLOYMENT: '<입력값 또는 sora-2>', AZURE_SORA_API_VERSION: '<입력값 또는 preview>',
|
|
139
|
+
AZURE_IMAGE_OPENAI_ENDPOINT: '<입력값>', AZURE_GPT_IMAGE_DEPLOYMENT: '<입력값>', AZURE_GPT_IMAGE_API_VERSION: '<입력값 또는 preview>',
|
|
141
140
|
}, { target });
|
|
142
141
|
console.log('저장:', written);
|
|
143
142
|
})();
|
|
144
143
|
"
|
|
145
144
|
```
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
그다음 사람에게 키 파일을 편집기로 열어 키 줄을 직접 적어 달라고 한다(값은 대화에 붙이지 않는다):
|
|
146
|
+
```
|
|
147
|
+
AZURE_SORA_API_KEY=<Sora 리소스 키>
|
|
148
|
+
AZURE_IMAGE_API_KEY=<Image 리소스 키>
|
|
149
|
+
```
|
|
150
|
+
적었다고 하면 위 "이미 있으면" 확인을 다시 돌린다. 키는 길이로만 표시된다.
|
|
151
|
+
|
|
152
|
+
### 모드 (b) — 콘솔 샘플 파일
|
|
153
|
+
Azure AI Foundry/AI Hub 콘솔의 "Sample code"/"View code" 패널이 주는 curl 또는 Python 스니펫에는 원문 키가 들어 있다. 사람이 이것을 **채팅에 붙이지 않고** 비공개 파일에 저장한다:
|
|
148
154
|
```bash
|
|
149
|
-
|
|
155
|
+
mkdir -p ~/.config/banker/3d-intro && (umask 077 && touch ~/.config/banker/3d-intro/sample.txt) # 사람이 이 파일에 샘플을 붙여 저장
|
|
156
|
+
```
|
|
157
|
+
에이전트는 그 파일을 읽어 키를 화면에 내지 않고 키 파일에 바로 합친 뒤 샘플 파일을 지운다. 리소스마다 마지막 인자를 `sora`, `image`, `wan:<n>` 중 하나로 준다:
|
|
158
|
+
```bash
|
|
159
|
+
node -e "
|
|
160
|
+
(async () => {
|
|
161
|
+
const fs = require('fs'), path = require('path');
|
|
162
|
+
const { parseConsoleSample, persistCreds, resolveCreds, redact } = await import('$ADAPTER');
|
|
163
|
+
const [file, kind] = process.argv.slice(1);
|
|
164
|
+
const p = parseConsoleSample(fs.readFileSync(file, 'utf8'));
|
|
165
|
+
const names = kind === 'sora' ? { endpoint: 'AZURE_SORA_ENDPOINT', apiKey: 'AZURE_SORA_API_KEY', deployment: 'AZURE_SORA_DEPLOYMENT', apiVersion: 'AZURE_SORA_API_VERSION' }
|
|
166
|
+
: kind === 'image' ? { endpoint: 'AZURE_IMAGE_OPENAI_ENDPOINT', apiKey: 'AZURE_IMAGE_API_KEY', deployment: 'AZURE_GPT_IMAGE_DEPLOYMENT', apiVersion: 'AZURE_GPT_IMAGE_API_VERSION' }
|
|
167
|
+
: { endpoint: 'WAN_' + kind.slice(4) + '_ENDPOINT', apiKey: 'WAN_' + kind.slice(4) + '_API_KEY' };
|
|
168
|
+
const prev = resolveCreds({ projectDir: process.cwd() });
|
|
169
|
+
const target = prev._source || path.join(process.cwd(), '.env.3d-intro.local');
|
|
170
|
+
const add = Object.fromEntries(Object.entries(names).filter(([f]) => p[f]).map(([f, name]) => [name, p[f]]));
|
|
171
|
+
const was = (() => { try { return new URL(prev[names.endpoint]).origin; } catch { return null; } })();
|
|
172
|
+
if (p.endpoint && was && p.endpoint !== was && add[names.apiKey] === undefined) { // 다른 리소스인데 키가 없음
|
|
173
|
+
console.log(JSON.stringify({ file: target, endpointChanged: { from: was, to: p.endpoint } }));
|
|
174
|
+
process.exit(3);
|
|
175
|
+
}
|
|
176
|
+
const old = prev[names.apiKey];
|
|
177
|
+
persistCreds({ ...prev, ...add }, { target });
|
|
178
|
+
const replaced = old && add[names.apiKey] && old !== add[names.apiKey] ? redact(old) : null;
|
|
179
|
+
console.log(JSON.stringify({ file: target, endpoint: p.endpoint, deployment: p.deployment, apiVersion: p.apiVersion, apiKey: redact(p.apiKey), replaced }));
|
|
180
|
+
})();
|
|
181
|
+
" "$HOME/.config/banker/3d-intro/sample.txt" sora
|
|
182
|
+
rm -f "$HOME/.config/banker/3d-intro/sample.txt"
|
|
150
183
|
```
|
|
184
|
+
파싱 결과(저장한 파일, 엔드포인트, 배포명, apiVersion, 길이만 보이는 key)를 사람에게 보여 주고 확인받는다.
|
|
185
|
+
`replaced` 에 길이가 보이면 파일에 있던 다른 키를 이 샘플의 키로 바꾼 것이다. 교체(로테이션)가 아니었다면 사람이 키 파일의 키 줄을 다시 적는다.
|
|
186
|
+
출력에 `endpointChanged` 가 있으면(node 는 exit 3, 블록은 다음 `rm -f` 때문에 0) 샘플의 endpoint 가 키 파일의 것과 다른데 샘플에 키가 없다는 뜻이다. 기존 키가 새 host 로 가지 않도록 아무것도 저장하지 않았다. 다른 리소스로 바꾸는 것이면 사람이 키 파일의 endpoint 와 키 줄을 함께 고친다.
|
|
187
|
+
샘플의 SDK 코드에서 키는 따옴표 안의 값만 읽는다. `api_key=subscription_key` 처럼 변수 이름이면 키로 보지 않아 기존 키를 덮지 않는다.
|
|
188
|
+
필드가 `null` 이면(정규식 미매치, 또는 URL 이 아닌 endpoint) 그 필드만 모드 (a) 로 보완한다. 키가 샘플에 변수(`$AZURE_API_KEY` 등)로만 있으면 키 파일에 사람이 직접 적는다.
|
|
151
189
|
|
|
152
190
|
### WAN 영상 풀 (선택, 권장)
|
|
153
191
|
Alibaba Cloud Model Studio 의 WAN 영상 모델(기본 `wan3.0-video-prime`)을 여러 API 키로 돌린다. 키(workspace)마다 할당량이 따로라 키가 많을수록 오래 쓴다.
|
|
154
|
-
키마다 아래 두 줄을 번호를
|
|
192
|
+
키마다 아래 두 줄을 번호를 붙여, 위 비공개 키 파일에 사람이 직접 적는다(콘솔 샘플 파일이 있으면 모드 (b) 를 `wan:<n>` 으로 돌린다). 번호 개수 제한은 없다.
|
|
155
193
|
```
|
|
156
194
|
# endpoint 는 origin 만 (경로 제외)
|
|
157
195
|
WAN_1_ENDPOINT=https://ws-<workspace-id>.<region>.maas.aliyuncs.com
|
|
@@ -161,15 +199,15 @@ WAN_2_API_KEY=...
|
|
|
161
199
|
# 선택. 키 하나만 모델이 다르면 WAN_<n>_MODEL
|
|
162
200
|
WAN_MODEL=wan3.0-video-prime
|
|
163
201
|
```
|
|
164
|
-
- 콘솔 샘플(curl)
|
|
202
|
+
- 콘솔 샘플(curl)은 모드 (b) 로 파일에서 읽는다. 키는 샘플에 실제 값(`Bearer sk-ws-...`)이 있을 때만 뽑히고, `$DASHSCOPE_API_KEY` 같은 변수면 사람이 키 파일에 직접 적는다. JSON 본문의 `"model"` 은 뽑히지 않으니 `WAN_MODEL` 로 따로 넣는다.
|
|
165
203
|
- 엔드포인트, 키, 모델의 리전이 같아야 한다. 다르면 `401 InvalidApiKey` 가 난다.
|
|
166
204
|
- 값 뒤의 주석은 `#` 앞에 공백이 있어야 주석으로 읽힌다(`값 # 메모`). 공백 없이 붙은 `#` 는 값의 일부다. 값에 ` #` 이 들어가야 하면 따옴표로 감싼다.
|
|
167
|
-
- endpoint 가
|
|
205
|
+
- endpoint 가 https URL 이 아니거나(`http://` 는 키가 평문으로 가므로 막는다. 이 기기의 localhost 만 예외) 모델이 한 id 가 아닌 항목은 build 가 건너뛴다. 모든 WAN 항목이 그렇다면 build 는 Sora 로 넘어가지 않고 멈춘다.
|
|
168
206
|
- 선택 설정(모두 기본값이 있음): `VIDEO_PROVIDER_ORDER`(기본 `wan,sora`), `WAN_RESOLUTION`(기본은 출력 크기에서 계산), `WAN_PROMPT_EXTEND`(기본 `false`), `WAN_MAX_CONCURRENT`(기본 5), `WAN_EXHAUSTED_TTL_HOURS`(기본 24).
|
|
169
207
|
- 소진·쿨다운 기록은 `~/.config/banker/3d-intro/video-pool-state.json` 에 지문(엔드포인트, 키, 모델)으로만 남는다. 키나 모델을 바꾸면 지문이 바뀌어 기록이 새로 시작된다.
|
|
170
208
|
|
|
171
209
|
### FLUX.2-pro (opt-in)
|
|
172
|
-
필요할 때만: endpoint 하나 추가(`AZURE_IMAGE_SERVICES_ENDPOINT`) — 같은 리소스에 배포된 경우가 많아 키는 기본적으로 `AZURE_IMAGE_API_KEY` 를
|
|
210
|
+
필요할 때만: endpoint 하나 추가(`AZURE_IMAGE_SERVICES_ENDPOINT`) — 같은 리소스에 배포된 경우가 많아 키는 기본적으로 `AZURE_IMAGE_API_KEY` 를 재사용한다. 다른 리소스면 그 키를 `AZURE_IMAGE_SERVICES_API_KEY=` 줄로 사람이 키 파일에 직접 적는다(채팅으로 받지 않는다).
|
|
173
211
|
FLUX 는 이 스킬의 §4 프리플라이트 대상이 아니다(무과금으로 확인할 별도 엔드포인트가 없음).
|
|
174
212
|
저장만 하고 실제 연결 확인은 생략한다(graceful skip) — 첫 실제 사용 시점(3d-intro-build)으로 미룬다.
|
|
175
213
|
|
|
@@ -182,12 +220,13 @@ node -e "
|
|
|
182
220
|
const { resolveCreds, listVideos, azFetch, redact } = await import('$ADAPTER');
|
|
183
221
|
const c = resolveCreds({ projectDir: process.cwd() });
|
|
184
222
|
if (!c._source) { console.log('크레덴셜 없음 -- §3 먼저 실행'); process.exit(2); }
|
|
223
|
+
const how = (r) => (r.error ? r.error.cause?.code || r.error.code || r.error.name : r.authStyle); // status 0 이면 이유
|
|
185
224
|
const sora = await listVideos({ endpoint: c.AZURE_SORA_ENDPOINT, key: c.AZURE_SORA_API_KEY, apiVersion: c.AZURE_SORA_API_VERSION || 'preview' });
|
|
186
|
-
console.log('sora.listVideos ->', sora.status, '(' + sora
|
|
225
|
+
console.log('sora.listVideos ->', sora.status, '(' + how(sora) + ')');
|
|
187
226
|
const imgv = c.AZURE_GPT_IMAGE_API_VERSION || 'preview';
|
|
188
227
|
const imgUrl = c.AZURE_IMAGE_OPENAI_ENDPOINT + '/openai/v1/images/generations?api-version=' + imgv;
|
|
189
228
|
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 }) });
|
|
190
|
-
console.log('images.badreq ->', img.status, '(' + img
|
|
229
|
+
console.log('images.badreq ->', img.status, '(' + how(img) + ')');
|
|
191
230
|
const { probeWanPool } = await import('$POOL');
|
|
192
231
|
const wan = await probeWanPool({ creds: c });
|
|
193
232
|
for (const w of wan) console.log('wan.probe', w.label, w.host, w.model, '->', w.status, w.code || '', '| key', w.key);
|
|
@@ -211,6 +250,7 @@ node -e "
|
|
|
211
250
|
| 둘 중 하나라도 401/403 | - | 인증 실패 — `azFetch` 가 `api-key`→`Bearer` 두 헤더 스타일을 이미 자동 재시도하므로, 그래도 401/403 이면 헤더 스타일이 아니라 키·엔드포인트 자체가 틀린 것(§3 재입력) |
|
|
212
251
|
| `images.badreq` 가 200 | - | 있을 수 없음(프롬프트 없이 생성이 성공할 리 없다) — 어댑터·API 버전 불일치 의심, 정직히 보고 |
|
|
213
252
|
| status 0 | - | 네트워크 오류(엔드포인트 오타·오프라인) |
|
|
253
|
+
| status 0 (`EINSECURE`) | - | endpoint 가 `http://` 다. 키를 평문으로 보내지 않으려고 보내기 전에 막았다 — `https://` 로 고침(이 기기의 localhost 만 http 허용) |
|
|
214
254
|
|
|
215
255
|
## 5. 검증 (보고 의무)
|
|
216
256
|
```bash
|
|
@@ -219,12 +259,13 @@ command -v ffmpeg >/dev/null && ffmpeg -version | head -1 || echo "시스템 ffm
|
|
|
219
259
|
# 위 §4 프리플라이트를 재실행해 PASS 문자열 + 두 status 코드를 그대로 증거로 남긴다
|
|
220
260
|
```
|
|
221
261
|
4-field 보고: 변경(Node/ffmpeg 확보 방식 · 크레덴셜 저장 경로 — 파일 내용 아님) / Evidence(§0 감지 출력 · §4 status 코드 전부) / 검증(`listVideos`=200 **AND** `images.badreq`=400 **AND** WAN 항목 전부 200 이어야 PASS) / Unknown(FLUX 미검증 · WAN 키별 남은 할당량(조회 API 없음 — 소진은 첫 생성에서 드러나고 풀이 자동으로 다음 키·Sora 로 넘어감) · 배포명이 실제 존재하는지는 이 무과금 프리플라이트로 확인 불가하며 첫 실제 생성 시점에만 드러남 · 관리자 권한 제약으로 ffmpeg 시스템 설치 불가 등, 가짜 성공 금지).
|
|
222
|
-
크레덴셜은 어떤 필드도 원문으로 보고하지 않고 항상 `redact()`
|
|
262
|
+
크레덴셜은 어떤 필드도 원문으로 보고하지 않고 항상 `redact()` 출력(길이만)만 남긴다.
|
|
223
263
|
|
|
224
264
|
## 함정
|
|
225
265
|
- **키·엔드포인트가 진짜 다른 이유**: Sora 와 gpt-image-2 는 같은 Azure OpenAI 리소스에 함께 배포될 수도, 완전히 별도 리소스일 수도 있다 — 하나로 퉁치지 말고 두 세트를 각각 확인한다.
|
|
226
266
|
- **`azFetch` 는 이미 이중 인증을 시도한다**: `api-key` 헤더로 먼저 시도하고 401/403 이면 자동으로 `Authorization: Bearer` 로 재시도한다 — 그래도 실패하면 헤더 스타일 문제가 아니라 키·엔드포인트 자체가 틀린 것이다.
|
|
227
|
-
-
|
|
267
|
+
- **키가 드러났으면 교체한다**: 키가 대화, 터미널 기록, 출력에 한 번이라도 드러났으면(예: 샘플을 채팅에 붙임) 그 키를 바꾼다. Azure 는 리소스의 Keys and Endpoint 에서 Regenerate 로 교체한다.
|
|
268
|
+
WAN 키는 Model Studio 콘솔의 API Key 관리에서 그 키를 지우고 새로 만들어 교체한다.
|
|
228
269
|
- **WAN 키 검증은 GET task 로만**: 존재하지 않는 task 조회는 과금이 없고 키 유효성(200 vs 401)을 가른다. 생성 요청(POST)은 과금될 수 있으니 점검용으로 쓰지 않는다.
|
|
229
270
|
- **이미지 배포 429**: 낮은 티어(S0) 이미지 배포는 연속 호출에 `429 RateLimitReached` 를 낸다. 프리플라이트를 짧은 간격으로 반복하지 않는다.
|
|
230
271
|
- **RHEL8/Rocky8 ffmpeg 미제공**: BaseOS/AppStream 에 없음(라이선스) → motion-graphic-setup 과 동일하게 RPM Fusion 또는 `ffmpeg-static` 폴백.
|