@kaydash9999/banker-plugins 0.25.0 → 0.26.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 +29 -0
- package/README.md +4 -3
- package/codex/manifest.json +1 -0
- package/codex/transform-matrix.md +1 -0
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +1 -0
- package/skills/3d-intro-build/references/azure-adapter.mjs +54 -27
- package/skills/3d-intro-build/references/video-pool.mjs +11 -6
- package/skills/3d-intro-setup/references/azure-adapter.mjs +54 -27
- package/skills/3d-intro-setup/references/video-pool.mjs +11 -6
- package/skills/make-evidence/SKILL.md +182 -0
- package/skills/motion-sites/SKILL.md +9 -1
- package/skills/motion-sites/scripts/motion-sites.mjs +20 -3
- package/skills/omc-patch/scripts/omc-patch.mjs +74 -16
- package/skills/ralph-qa/SKILL.md +1 -0
- package/skills/ralph-qa/references/seat-run.mjs +21 -4
- package/skills/ralph-qa/references/verifier-probe.mjs +35 -13
- package/skills/remains/SKILL.md +1 -1
- package/skills/remains/scripts/boxes.mjs +31 -12
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "smallOpenSource",
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.26.0] - 2026-11-05
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- 새 스킬 `make-evidence`: 구현한 서비스의 증거 문서 여섯을 실제 실행으로 만들어 `docs/evidence/` 에 커밋한다. 수치는 실행 로그에서만 옮기고, 문서마다 실행 명령과 로그 경로를 적고, 다 쓴 뒤 문서의 수를 로그와 대조한다. push 는 하지 않는다.
|
|
7
|
+
- `optimize.md`: 목적 함수, 제약, 알고리즘 이름, 핵심 함수의 `파일:줄`, 의사코드 10줄
|
|
8
|
+
- `verify.md`: 구현을 보지 않은 새 맥락 하위 에이전트가 README 와 담당자 사실만 읽고 `tests/oracle/` 에 판정기와 시험을 쓰고, 판정기를 먼저 커밋한다. 일부러 넣은 버그 셋(제약 위반, 목적값 오계산, 권한 우회)을 패치로 넣어 돌리고 되돌린 기록을 남긴다
|
|
9
|
+
- `flow.md`: 화면, API, 최적화, DB, 내보내기 순서와 단계별 `파일:줄`, `{host}:{port}` 서버에 실제 요청을 보내는 종단 시험 로그. 서버에 닿지 못하면 미검증으로 적는다
|
|
10
|
+
- `scale.md`: 데이터 1배, 5배, 10배를 스크립트로 만들어 시간과 목적값을 재고, 작은 사례 다섯을 완전 탐색 정해와 비교한 차이(%)
|
|
11
|
+
- `decisions.md`: 결정마다 고른 것, 버린 것, 이유, 확인하는 시험 이름
|
|
12
|
+
- `trace.md`: README 요구마다 번호, 상태(완료, 부분, 미구현과 이유), 시험 이름, 로그 경로
|
|
13
|
+
|
|
14
|
+
## [0.25.1] - 2026-11-03
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- `motion-sites`: Python 을 찾지 못해 첫 실행 구성이 멈출 때, 실패 이유와 `SKILL.md` 가 `MOTION_SITES_PYTHON` 으로 Python 을 지정하는 방법을 알린다. 전에는 PATH 밖의 Python(Windows 의 Miniconda, `py` 없이 Store 연결 파일만 있는 PC)을 쓸 방법이 안내되지 않았다. Windows 박스 실제 설치 시험에서 찾음.
|
|
18
|
+
- `motion-sites`: `MOTION_SITES_PYTHON` 으로 지정한 Python 이 실행되지 않으면 그 사실을 알리고 멈춘다. 전에는 PATH 의 다른 Python 이 있으면 알리지 않고 그쪽에 Playwright 를 설치했다. 지정한 Python 으로 만든 캐시 폴더 가상 환경은 그대로 찾는다.
|
|
19
|
+
- `3d-intro-build`, `3d-intro-setup`: 셸의 `FFMPEG_PATH` 가 상대 경로이거나 없는 파일이면 `resolveFfmpeg()` 가 그 까닭으로 멈춘다. 전에는 알리지 않고 설정 파일, PATH, `ffmpeg-static` 의 다른 ffmpeg 를 썼다. 설정 파일의 값은 예전처럼 쓸 수 없으면 다음 후보로 넘어간다.
|
|
20
|
+
- `remains`: 박스 시험 실행기(`boxes.mjs`)가 저장소의 index 를 쓰지 않는다. `--ref worktree` 의 `git stash create` 는 index 사본으로, `git status` 는 선택 잠금 없이 돈다. 두 실행을 동시에 하거나 사용자의 git 명령이 index 를 잠그고 있으면 "could not write index" 로 멈추던 것을 고친다. 실패 문구가 `git -c -c stash create` 처럼 깨져 보이던 것도 고친다.
|
|
21
|
+
- `remains`: root 로 접속해 `runAs` 계정으로 돌리는 Linux 박스는 개인 폴더를 `/tmp` 대신 `/var/lib` 아래에 만든다. 저장소 사본과 그 계정의 HOME 이 임시 폴더 아래에 있어, 임시 폴더를 거부하는 teamwork 의 설치 흐름 시험을 확인할 수 없던 것을 고친다.
|
|
22
|
+
- 오류 주입 시험으로 찾은 "실패를 성공으로 보고" 를 고친다(omc-patch, ralph-qa 의 seat-run, verifier-probe, 3d-intro 의 azure-adapter, video-pool).
|
|
23
|
+
- `omc-patch`: 풀다 멈춘 판 폴더로 전환하던 것(옆 폴더에 다 푼 뒤에만 판 이름으로 옮기고, hooks.json 이나 scripts/ 가 없는 폴더는 설치된 판으로 보지 않음), hooks.json 이 없거나 훅을 하나도 부르지 않거나 부르는 훅 파일이 없을 때 "실패 0건" 이던 것, 빈 백업이나 잘린 백업으로 되돌리고 "되돌리기 완료" 이던 것, 깨진 hooks.json 을 "이 도구가 지운 적 없음" 으로 넘기던 것, 잘린 패치 파일과 패치 뒤 새로 생긴 감싸지 않은 import 를 `--check` 가 "모두 적용" 으로 보던 것, 오류 문구가 있는 git 종료 1 을 "origin 없음" 으로 보던 것
|
|
24
|
+
- `verifier-probe`: 두 호출이 모두 실패한 codex 를 앉힐 수 있는 좌석으로 보고하던 것(이제 `cli-call-failed` 와 호출이 끝난 사정), 읽지 못한 설정 파일(권한, 폴더)을 없는 파일로 보고 모델을 채택하던 것, 빈 models.dev 사본(`{}`)으로 채택하던 것, SIGTERM 을 무시하는 CLI 에 끝없이 멈추던 것(SIGKILL), 오류 글을 판으로 적던 것
|
|
25
|
+
- `seat-run`: 코드펜스 안의 `VERDICT:` 줄을 좌석의 판정으로 세던 것, 실패한 좌석의 `facts` 에 판정이 남던 것. send 출력에 `answered`, `failed` 를 더한다
|
|
26
|
+
- `3d-intro`: ffmpeg 가 실패하거나 끝나지 않거나 아무것도 쓰지 않아도 앞서 있던 파일이나 반쯤 쓴 파일을 결과로 내던 것(이제 출력부터 지우고 종료 0 과 빈 파일 아님을 요구, 10분 시간 제한), 길이를 읽지 못하면 0초에서 겹치던 crossfade, 200 으로 온 HTML, JSON 오류 페이지를 영상으로 받던 것, 이미지가 아닌 답을 PNG 로 내던 것, 빈 시드 프레임으로 유료 작업을 보내던 것
|
|
27
|
+
|
|
28
|
+
### Notes
|
|
29
|
+
- teamwork 시험은 저장소 사본이 임시 폴더 아래에 있으면(remains 박스 실행기 등) 설치 흐름 고정물을 홈 아래 난수 폴더에 두고 끝나면 지운다. 홈도 임시 폴더 아래면 설치 흐름 시험만 까닭을 대고 건너뛴다. 전에는 macOS, Rocky8 박스에서 74, 75건이 거짓 실패했다.
|
|
30
|
+
- `scripts/class-guards.test.mjs` 는 git 저장소가 아닌 사본(박스에 보내는 추적 파일 묶음)에서도 파일을 걸어 돈다.
|
|
31
|
+
|
|
3
32
|
## [0.25.0] - 2026-11-02
|
|
4
33
|
|
|
5
34
|
### Added
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[빠른 시작](#빠른-시작) · [워크플로 예시](#워크플로-사용-예시) · [구성](#구성) · [설치 상세](#설치-상세-npm--codex) · [설정 변경 지점](#설정-변경-지점-claude-code--codex) · [요구사항](#요구사항) · [업데이트 / 제거](#업데이트--제거) · [업데이트 확인](#업데이트-확인-및-사용량-카운팅) · [오프라인](#오프라인에서-쓰기) · [라이선스 / 서드파티](#라이선스--서드파티)
|
|
10
10
|
|
|
11
11
|
□ banker는 다음을 포함하는 Claude Code 및 Codex CLI 플러그인입니다.
|
|
12
|
-
- QA·보안 감사·문서·아키텍처·위키·미디어 작업과 의존성·개발환경(OS별) 설치를 아우르는 **스킬
|
|
12
|
+
- QA·보안 감사·문서·아키텍처·위키·미디어 작업과 의존성·개발환경(OS별) 설치를 아우르는 **스킬 72개 + 커맨드 2개**(총 74개 구성요소. Codex 는 Claude Code 전용 `ultraqa` 를 뺀 스킬 71개와 Codex 전용 스킬 3개)
|
|
13
13
|
- Claude Code 즉시 명령 `/graceful-pause`, `/progress`
|
|
14
14
|
|
|
15
15
|
□ 설치하면 스킬과 커맨드가 `/banker:<이름>` 네임스페이스로 노출됩니다.
|
|
@@ -158,6 +158,7 @@ Claude Code 2.1.296 은 Claude 5 모델(Opus 5.5 등)에 작업 목록 도구(Ta
|
|
|
158
158
|
| `smart-compact` | 컨텍스트 임계 초과 시 위키·resume 저장 게이트 자동 무장 |
|
|
159
159
|
| `deep-research` | 다중 소스 팬아웃 + 적대적 검증 인용 리서치 |
|
|
160
160
|
| `lfm` | learn from master. 그 분야 최고 전문가 그룹이 일하는 방식을 1차 자료로 조사해 계획 적용 항목으로 정리(ultra-interview, ultra-chain, deep-research, rfp-author 의 리서치에서도 씀) |
|
|
161
|
+
| `make-evidence` | 구현한 서비스의 증거 문서 여섯(optimize, verify, flow, scale, decisions, trace)을 실제 실행으로 만들어 `docs/evidence/` 에 커밋. 수치는 실행 로그에서만 옮기고 문서마다 명령과 로그 경로를 적음. 판정기는 구현을 보지 않은 새 맥락 하위 에이전트가 쓰고, 일부러 넣은 버그 셋을 잡는지 돌린 뒤 되돌림. 미구현은 숨기지 않음, push 는 안 함 |
|
|
161
162
|
| `ultra-interview` | 리서치 선행으로 사람 질문 최소화, 모호성 3% 이하까지 인터뷰 |
|
|
162
163
|
| `interval-report` | 장기 수행 중간 보고를 `docs/intermission.md` 로 갱신(경과·잔여·단일 표) |
|
|
163
164
|
| `summary-wiki` | 위키를 `docs/` 단일 파일 개조식으로 요약(지식 동기화 검토용, 읽기 전용) |
|
|
@@ -232,14 +233,14 @@ banker uninstall # 제거
|
|
|
232
233
|
|
|
233
234
|
- `--scope project` 로 프로젝트 로컬(`./.codex`)에 설치하고, `--dry-run` 으로 미리 볼 수 있습니다.
|
|
234
235
|
- non-root 전용입니다(전역 sudo 설치 시 root 소유 파일을 방지). postinstall이 없으므로 `banker setup` 을 직접 실행합니다.
|
|
235
|
-
- Codex에는 스킬
|
|
236
|
+
- Codex에는 스킬 71개와 Codex 전용 스킬 3개(`graceful-pause`, `progress`, `teamwork`)가 `~/.codex/skills/banker-<name>/` 에, 커맨드 2개가 `~/.codex/prompts/banker-<name>.md` 에 설치됩니다(`codex/manifest.json`). \
|
|
236
237
|
`CODEX_HOME` 을 설정했으면 `~/.codex` 대신 그 폴더에 설치합니다(Codex 가 읽는 곳). \
|
|
237
238
|
`lineage`, `progress`, 사용량 카운팅은 Codex 의 대화 기록(rollout)을 읽습니다. Codex 는 이 형식을 공개 약속으로 내놓지 않아 업데이트로 바뀔 수 있습니다. \
|
|
238
239
|
Codex 를 업데이트한 뒤에는 `banker doctor` 로 형식을 점검합니다. 읽지 못하면 lineage 는 빈 페이지 대신 오류로 멈추고, progress 는 경고 줄을 보이며, 카운팅은 다음 세션 시작 때 경고를 한 번 보입니다. \
|
|
239
240
|
Codex 0.144 이상에는 사용자 프롬프트가 없으므로 커맨드 2개는 스킬로도 설치되어 `$banker-front-qa`, `$banker-setup` 으로 부릅니다. \
|
|
240
241
|
디렉터리명과 일치하도록 프론트매터 `name:` 이 `banker-<name>` 으로 재작성되어 Codex가 `banker-<name>` 으로 인식합니다.
|
|
241
242
|
- Codex 플러그인 설치(`codex plugin add banker@banker-plugins`)는 저장소를 `~/.codex/plugins/cache/banker-plugins/banker/<판>/` 에 두고 `.codex-plugin/plugin.json` 을 읽습니다. \
|
|
242
|
-
스킬
|
|
243
|
+
스킬 71개, Codex 전용 스킬 3개, 커맨드 2개(스킬로)가 `$banker:<name>` 으로 보이고, 업데이트 알림과 사용량 카운팅 훅은 `codex/hooks.json` 에서 옵니다. \
|
|
243
244
|
Codex 는 세션을 시작할 때 마켓플레이스를 스스로 올려 새 판을 설치합니다. 훅 명령 앞에는 훅이 실행하는 파일의 해시가 붙어 있어, 훅 코드가 바뀐 판에서는 `/hooks` 가 다시 신뢰를 묻고 신뢰하기 전에는 훅이 돌지 않습니다. 훅 코드가 그대로인 판은 신뢰도 그대로입니다. 스킬 파일은 신뢰 확인 없이 새 판으로 바뀝니다. \
|
|
244
245
|
obsidizer 훅은 npm 설치와 같이 `obsidizer --enable` 할 때만 사용자 범위에 설치됩니다(셸 호출마다 node 를 띄우므로). \
|
|
245
246
|
훅은 Codex 샌드박스 밖에서 돌므로, PATH 의 node 가 임시 폴더나 작업 폴더 안에 있으면(샌드박스가 바꿀 수 있는 곳) 아무것도 돌리지 않고 그 이유를 세션 시작 알림으로 보입니다. 작업 폴더가 홈 폴더 자체이면 홈 아래의 node(nvm 등)는 그대로 씁니다. 훅 스크립트, 플러그인 폴더, node 가 `config.toml` 의 `[sandbox_workspace_write] writable_roots` 아래(그 항목 맨 위 `.codex` 제외)면 훅은 다른 일을 하지 않고 그 이유를 한 줄로 알립니다. 이 검사는 훅 파일 안에서 하므로, 샌드박스가 이미 그 파일을 고친 뒤에는 막지 못합니다. writable_roots 에 Codex 홈이나 node 가 든 폴더를 넣지 마십시오. \
|
package/codex/manifest.json
CHANGED
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
{ "name": "git-init", "type": "skill", "target": "both", "reason": "makes the first commit of a folder that is not a git repository yet, so worktrees and other parallel work can start from it: writes a .gitignore that keeps secrets and local state out, scans the files to track for secrets by name and content (payload-scan.mjs, file:line:kind only, never the value), then runs git init, git add . and git commit -m init; a hit stops it before any .git exists; pure Node, no deps, no network", "supportingFiles": ["scripts/git-init.mjs","scripts/marker-block.mjs","scripts/payload-scan.mjs"], "caveat": "needs git on PATH (before git 2.28 the first branch is set with git symbolic-ref) and a commit identity (user.name and user.email, or --local-identity, written to .git/config only); an existing .gitignore keeps its bytes and gets one marked block at its end, the first original kept in .gitignore.git-init.bak, which the block itself ignores; a .gitignore symbolic link is refused unless --follow-symlink; key file names cannot be accepted, a content hit only by its exact file:line:kind with --accept; commit hooks are not skipped; it never adds a remote or pushes; on Codex run asks for sandbox_permissions require_escalated, since the default sandbox may block writes to .git" },
|
|
81
81
|
{ "name": "build-testbox", "type": "skill", "target": "both", "reason": "a test environment this machine builds from its own resources: a browser matrix (Playwright Chromium and WebKit, desktop and mobile device emulation) against a loopback URL and container OSes (Rocky Linux 8, 9; macOS only as a Tart VM on Apple Silicon) run in parallel, every requested cell kept as a pass, fail, unverified or unsupported row with a reason, results.json and summary.md; detect, run, setup; pure Node, no deps", "supportingFiles": ["scripts/testbox.mjs","scripts/args.mjs","scripts/detect.mjs","scripts/browsers.mjs","scripts/browser-cell.mjs","scripts/containers.mjs","scripts/setup.mjs","scripts/report.mjs","scripts/proc.mjs"], "caveat": "Playwright (a global install, or the project's with --playwright-from-project) and docker or podman are optional; a missing one leaves its cells unverified with the reason; run downloads nothing (a missing image or browser build is unverified, never pulled); setup only lists what it would fetch and its rough size, and fetches after the user confirms and it runs again with --yes (exit 4 and network needed when the sources are unreachable); macOS runs only as a Tart VM on an Apple Silicon host (other hosts are unsupported under the Apple SLA), and the skill never downloads or starts the VM; on Codex detect and run ask for sandbox_permissions require_escalated for the container engine and the headless browsers, the server under test on a 127.0.0.1 port is the user's own (approved where it is started), and setup --yes, or a container command that uses the network, asks for network access (--network host lifts that cell's isolation, and the user is told)" },
|
|
82
82
|
{ "name": "tune-tmux", "type": "skill", "target": "both", "reason": "adds or removes one marked block in the tmux or psmux config file so mouse drag copies to the OS clipboard and PageUp/PageDown scroll inside tmux, without taking keys or the mouse from full-screen apps; status shows the chosen file, the lines and why any are left out, conflicts in other files tmux reads, and per-app behavior (Claude Code, Codex); first original kept, off removes only the block, --reload applies it to a running server; pure Node, no deps, no network", "supportingFiles": ["scripts/tune-tmux.mjs","scripts/tmux-config.mjs","scripts/app-notes.mjs","scripts/marker-block.mjs","scripts/psmux-caps.json"], "caveat": "needs tmux or psmux (install with setup-tmux; status alone works without them); it writes the user's config file outside the workspace (~/.tmux.conf or the XDG tmux.conf, ~/.psmux.conf for psmux), keeping the first original as <conf>.tune-tmux.bak, so on Codex on, off and --reload ask for sandbox_permissions require_escalated (no --add-dir); a config file that is a symbolic link is refused unless --follow-symlink; psmux gets a block of zero lines today (its defaults give mouse, set-clipboard and wheel copy-mode; drag to the Windows clipboard and PageUp/PageDown inside full-screen apps are unverified; outside apps PageUp needs the prefix, PageDown does not leave copy-mode at the bottom and #{alternate_on} is always 0), so on reports the unverified and broken counts with exit 0 and only takes out an old tune-tmux block left in that file; inside full-screen apps (Claude Code fullscreen, Codex Fullscreen) the app keeps PageUp/PageDown and the mouse, and tmux copy-mode cannot scroll Codex history, a limit tmux config cannot fix; it never changes Claude Code or Codex settings" },
|
|
83
|
+
{ "name": "make-evidence", "type": "skill", "target": "both", "reason": "writes six evidence documents for a built service from real runs and commits them under docs/evidence/ (optimize: objective, constraints, algorithm, file:line, pseudocode; verify: an oracle in tests/oracle/ written by a fresh-context sub-agent that reads only the README and the owner facts, committed first, and three seeded bugs (constraint, objective, authorization) applied as patches, run and reverted; flow: screen, API, optimization, DB, export with file:line and an end-to-end run against {host}:{port}; scale: 1x, 5x, 10x data by script with time and objective, five small cases against exhaustive search with the gap in percent; decisions: chosen, rejected, reason, the test that checks it; trace: every README requirement with done, partial or not done and why, test name and log path); numbers only from run logs, each document names its commands and log paths, a number check against the logs at the end; no push", "supportingFiles": [], "caveat": "procedure only (no script): the project supplies the server, its tests and the tools; a server that cannot be reached is written as unverified, never filled from unit tests; on Codex the commit may ask for sandbox_permissions require_escalated and a run against a non-local server asks for network access" },
|
|
83
84
|
|
|
84
85
|
{ "name": "front-qa", "type": "command", "target": "both", "reason": "ralplan->ralph frontend-QA pipeline; Claude=OMC, Codex=OMX", "supportingFiles": [] },
|
|
85
86
|
{ "name": "setup", "type": "command", "target": "both", "reason": "installer orchestrator; Claude AskUserQuestion multi-select, Codex prompt-based selection", "supportingFiles": [], "codexDescription": "구성요소와 의존성을 여러 개 골라 설치하는 오케스트레이터. 런타임(Node, Python, Java), LSP, tmux, MCP, 샌드박스, OMX, Playwright 등. 권한 확인 끄기는 따로 묻고 기본으로 고르지 않으며, 골라도 실행하지 않고 직접 입력할 명령만 안내." },
|
|
@@ -54,6 +54,7 @@ The generator (`banker setup --codex`) applies the rules below for `target: both
|
|
|
54
54
|
- `init-dir` — the same procedure in both runtimes; the engine is `scripts/init-dir.mjs` (Codex npm install: `~/.codex/skills/banker-init-dir/scripts/init-dir.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/init-dir/scripts/init-dir.mjs`), run with `--runtime claude|codex`. It writes one marked block (`<!-- banker:init-dir:start -->` to `<!-- banker:init-dir:end -->`) into whichever of `AGENTS.md` and `CLAUDE.md` exist (Codex with neither: `AGENTS.md`), the `AGENTS.md` block inside its own `USER:OMX:POLICY` pair; `AGENTS.override.md` is never written, only warned about when it has content of its own. The block editor `scripts/marker-block.mjs` is the source of truth for its future copies (`scripts/sync-adapter.js`). Pure Node with no network. On Codex a `--root` outside the workspace, or starting a generated service that must outlive the session, asks for `sandbox_permissions: "require_escalated"`.
|
|
55
55
|
- `motion-sites` — the same procedure in both runtimes; the engine is `scripts/motion-sites.mjs` (Codex npm install: `~/.codex/skills/banker-motion-sites/scripts/motion-sites.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/motion-sites/scripts/motion-sites.mjs`). `status --runtime codex` finds the original plugin through `config.toml` (`[plugins."motionsites-web-builder@motionsites"]`) and `plugins/cache/motionsites/`; `setup --runtime codex` runs `codex plugin marketplace add Motionsites-AI/claude-skill` and `codex plugin add motionsites-web-builder@motionsites` where Claude Code runs `claude plugin marketplace add` and `claude plugin install`. The hand-over is `$motionsites-web-builder:motionsites-web-builder` once the plugin shows in the session, else the original `SKILL.md` read and followed. `setup` needs the network and asks for `sandbox_permissions: "require_escalated"`.
|
|
56
56
|
- `git-init` — the same procedure in both runtimes; the engine is `scripts/git-init.mjs` (Codex npm install: `~/.codex/skills/banker-git-init/scripts/git-init.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/git-init/scripts/git-init.mjs`). `check` (the default) writes nothing; `run` writes the `.gitignore` block (`# banker:git-init:start` to `# banker:git-init:end`, through `scripts/marker-block.mjs`, a byte-identical copy of init-dir's), scans the files to track with `scripts/payload-scan.mjs` (a byte-identical copy of ralph-qa's; both kept by `scripts/sync-adapter.js`) and only then runs `git init`, `git add .` and `git commit -m "init"`. Needs `git`; no network, no remote, no push. On Codex `run` asks for `sandbox_permissions: "require_escalated"`, as the default sandbox may block writes to `.git`.
|
|
57
|
+
- `make-evidence` — the same procedure in both runtimes, with no script of its own. The oracle is written by a fresh-context sub-agent: Claude Code uses the Agent tool as `general-purpose` with no `model`; Codex uses `spawn_agent` with no `agent_type` or `model` and without the author's conversation (`fork_turns="none"` on multi-agent v2, no `fork_context` on v1). It reads only `README.md` and `docs/evidence/owner-facts.md`. On Codex the commits may ask for `sandbox_permissions: "require_escalated"`, as the default sandbox may block writes to `.git`. The end-to-end run against a server that is not local asks for network access.
|
|
57
58
|
- `build-testbox` — the same procedure in both runtimes; the engine is `scripts/testbox.mjs` (Codex npm install: `~/.codex/skills/banker-build-testbox/scripts/testbox.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/build-testbox/scripts/testbox.mjs`) with `detect`, `run` and `setup`; `scripts/browser-cell.mjs` is copied into the Playwright image and runs there alone for `--browser-in-container`. Playwright and docker or podman are optional: a missing one leaves its cells `unverified` with the reason. `run` downloads nothing; `setup` without `--yes` only lists what it would fetch, and the skill confirms with the user (Claude Code `AskUserQuestion`, Codex `request_user_input`) before running it again with `--yes`. macOS runs only as a Tart VM on an Apple Silicon host; other hosts get `unsupported(apple-sla)`. On Codex `detect` and `run` ask for `sandbox_permissions: "require_escalated"` (container engine, headless browsers, copies under the OS temp folder), `setup --yes` and container commands that use the network ask for network access, and the server under test is the user's own on a 127.0.0.1 port; a cell stopped by a permission error is `unverified(sandbox)`.
|
|
58
59
|
- `tune-tmux` — the same procedure in both runtimes; the engine is `scripts/tune-tmux.mjs` (Codex npm install: `~/.codex/skills/banker-tune-tmux/scripts/tune-tmux.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/tune-tmux/scripts/tune-tmux.mjs`) with `status` (the default), `on` and `off`. It writes one marked block (`# banker:tune-tmux:start` to `# banker:tune-tmux:end`) through `scripts/marker-block.mjs`, a byte-identical copy of init-dir's kept by `scripts/sync-adapter.js`, into the user's tmux config (`~/.tmux.conf` or the XDG `tmux.conf`) or psmux config (`~/.psmux.conf` and the rest of psmux's order); psmux gets zero lines today, as `scripts/psmux-caps.json` verifies nothing beyond its defaults. The conditional `#{||:}` is probed on a private `tmux -L` server, never the user's. A symbolic-link config is refused unless `--follow-symlink`. No network. On Codex `on`, `off` and `--reload` ask for `sandbox_permissions: "require_escalated"`, as the config file and the tmux socket are outside the workspace; Claude Code and Codex settings are never changed, and Codex Fullscreen history cannot be scrolled by tmux copy-mode (only the user's `--no-alt-screen` or `tui.alternate_screen = "never"` changes that).
|
|
59
60
|
- `/graceful-pause` — in Claude Code a function-hooks command (`hooks/register.mjs`, 2.1.289+, not a manifest surface) registered `immediate`, so it runs mid-turn; it appends one hidden user row the running turn reads at its next model request. In Codex the `codex-only` skill `graceful-pause` (`$banker-graceful-pause`): a message sent with Enter while a turn runs is held as "Messages to be submitted after next tool call" and put into the same turn when the running tool call ends, so the session stops after the current step (Codex 0.144.5: verified with `sleep 40` in a real TUI).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaydash9999/banker-plugins",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"banker": "bin/banker.js"
|
|
@@ -63,6 +63,7 @@ if (creds._tracked) throw new Error(`프로젝트 키 파일을 git 이 추적
|
|
|
63
63
|
- `creds._ignored` 가 있으면 프로젝트 키 파일의 `FFMPEG_PATH` 를 쓰지 않은 것이다. 저장소와 함께 온 파일이 실행할 프로그램을 정하지 못하게 막은 것이다. 값은 출력하지 않으니 사람이 파일을 열어 확인하고, 그 경로가 필요하면 `3d-intro-setup` §2 로 홈 설정 파일에 남긴다.
|
|
64
64
|
Claude Code: `/banker:setup` 에서 3d-intro-setup / Codex: `banker-3d-intro-setup`.
|
|
65
65
|
- `resolveFfmpeg()` 가 throw 하면 ffmpeg 가 없다. 같은 `3d-intro-setup` 으로 보낸다.
|
|
66
|
+
- 오류가 `FFMPEG_PATH from the shell is not usable` 이면 셸에서 지정한 `FFMPEG_PATH` 가 상대 경로이거나 없는 파일이다. 다른 ffmpeg 로 넘어가지 않는다. 그 값을 고치거나 지우게 한다.
|
|
66
67
|
- 둘 다 통과해야 다음 단계로 간다.
|
|
67
68
|
- 영상 provider 상태를 무료로 확인해 둔다.
|
|
68
69
|
`poolSummary({ creds })` 는 WAN 키별 상태(`ok`, `cooldown`, `exhausted`, `invalid`, `malformed`)와 마지막 줄의 Sora 상태(`label: 'AZURE_SORA'`, `ok`, `retired`, `unset`, `malformed`)를 키 원문 없이 돌려준다. WAN 줄은 `provider: 'wan'`, Sora 줄은 `provider: 'sora'` 다.
|
|
@@ -642,12 +642,26 @@ export async function generateImage({
|
|
|
642
642
|
const classicWorthTrying = (r) => !(r.ok && dataB64(r)) && r.status !== 429 && r.status !== 0;
|
|
643
643
|
const dataB64 = (r) => r.body?.data?.[0]?.b64_json;
|
|
644
644
|
|
|
645
|
-
// The PNG an image call answered with, or an error that starts with `failed`.
|
|
645
|
+
// The PNG an image call answered with, or an error that starts with `failed`. Bytes that are not an image (a URL or an
|
|
646
|
+
// error text in the image field, base64 that decodes to nothing) are a failure, not a picture.
|
|
646
647
|
function pngFrom(r, b64, failed) {
|
|
647
648
|
if (!(r.ok && b64)) throw new Error(`${failed}: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
648
|
-
|
|
649
|
+
const buf = Buffer.from(String(b64), 'base64');
|
|
650
|
+
if (!isImage(buf)) throw new Error(`${failed}: the answer is not a PNG, JPEG or WebP image (${buf.length} bytes)`);
|
|
651
|
+
return buf;
|
|
649
652
|
}
|
|
650
653
|
|
|
654
|
+
/** Whether bytes start as a PNG, JPEG or WebP image. */
|
|
655
|
+
export const isImage = (b) => Buffer.isBuffer(b) && b.length >= 12 && (b.readUInt32BE(0) === 0x89504e47
|
|
656
|
+
|| (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) || (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP'));
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Whether bytes start as an ISO media file (mp4, mov): the first box is one such a file starts with (ftyp, or styp,
|
|
660
|
+
* free, skip, wide, moov, mdat in fragmented and QuickTime files). An HTML or JSON error page is not.
|
|
661
|
+
*/
|
|
662
|
+
const MEDIA_BOXES = new Set(['ftyp', 'styp', 'free', 'skip', 'wide', 'moov', 'mdat']);
|
|
663
|
+
export const isMp4 = (b) => Buffer.isBuffer(b) && b.length >= 12 && MEDIA_BOXES.has(b.toString('latin1', 4, 8));
|
|
664
|
+
|
|
651
665
|
/** Edit an image (optional mask) via the v1 images/edits multipart path. Returns a PNG Buffer. */
|
|
652
666
|
export async function editImage({
|
|
653
667
|
endpoint, key, deployment, apiVersion = 'preview', prompt, imageBuf, maskBuf, size,
|
|
@@ -781,6 +795,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
|
|
|
781
795
|
key, method: 'GET', binary: true,
|
|
782
796
|
});
|
|
783
797
|
if (!(r.status === 200 && r.body?.length)) throw Object.assign(new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`), { status: r.status });
|
|
798
|
+
if (!isMp4(r.body)) throw Object.assign(new Error(`downloadVideo failed: HTTP 200 but the body is not an mp4 (${r.ct || 'no content type'}, ${r.body.length} bytes)`), { status: r.status });
|
|
784
799
|
return r.body;
|
|
785
800
|
}
|
|
786
801
|
|
|
@@ -919,7 +934,7 @@ function twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }) {
|
|
|
919
934
|
* 3) the optional ffmpeg-static package (dynamic import — may be absent)
|
|
920
935
|
* A project's .env.3d-intro.local never names the program, and "." or another relative PATH entry is
|
|
921
936
|
* skipped: either would let the files of a project, a cloned repository say, choose what runs.
|
|
922
|
-
* Throws an actionable error if none is found.
|
|
937
|
+
* Throws an actionable error if none is found, or if $FFMPEG_PATH is set but not usable.
|
|
923
938
|
*/
|
|
924
939
|
export async function resolveFfmpeg() {
|
|
925
940
|
const found = namedFfmpeg() || ffmpegOnPath() || await staticFfmpeg();
|
|
@@ -928,10 +943,17 @@ export async function resolveFfmpeg() {
|
|
|
928
943
|
}
|
|
929
944
|
|
|
930
945
|
// The ffmpeg this machine names. Absolute only: a relative value would be looked up in the current
|
|
931
|
-
// folder, the project.
|
|
946
|
+
// folder, the project. A $FFMPEG_PATH from the shell is the user's choice for this run: when it is
|
|
947
|
+
// relative or names no file, this stops with why instead of quietly taking another ffmpeg. The files
|
|
948
|
+
// are read only when the shell names nothing.
|
|
932
949
|
function namedFfmpeg() {
|
|
933
950
|
const usable = (p) => !!p && path.isAbsolute(p) && fs.existsSync(p);
|
|
934
|
-
|
|
951
|
+
const shell = process.env.FFMPEG_PATH;
|
|
952
|
+
if (shell) {
|
|
953
|
+
if (usable(shell)) return shell;
|
|
954
|
+
const why = path.isAbsolute(shell) ? 'no such file' : 'not an absolute path';
|
|
955
|
+
throw new Error(`FFMPEG_PATH from the shell is not usable (${why}): ${shell}; fix it or unset it`);
|
|
956
|
+
}
|
|
935
957
|
return [readSettings(), readEnvQuietly(homeCredsPath)].map((s) => s.FFMPEG_PATH).find(usable) || null;
|
|
936
958
|
}
|
|
937
959
|
|
|
@@ -953,17 +975,30 @@ async function staticFfmpeg() {
|
|
|
953
975
|
}
|
|
954
976
|
|
|
955
977
|
// Run ffmpeg with an ARGUMENT ARRAY (never a shell string). Returns {code, stdout, stderr, args}.
|
|
978
|
+
// ffmpeg has a time limit: a stuck one (a FIFO input, a blocked disk) would hold the whole run. SIGKILL, as a stuck
|
|
979
|
+
// ffmpeg may not end on SIGTERM.
|
|
980
|
+
export const FF_TIMEOUT_MS = 10 * 60 * 1000;
|
|
956
981
|
function runFf(bin, args, cwd) {
|
|
957
|
-
const r = cp.spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
958
|
-
return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '', args };
|
|
982
|
+
const r = cp.spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28, timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
|
|
983
|
+
return { code: r.status, signal: r.signal || null, error: r.error || null, stdout: r.stdout || '', stderr: r.stderr || '', args };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// One ffmpeg run that must make `out`: an earlier file there is removed first, and the run counts only when ffmpeg
|
|
987
|
+
// ended by itself with 0 and `out` is a file with something in it. A killed or failed ffmpeg's partial file is not a result.
|
|
988
|
+
function ffOut(name, bin, args, out) {
|
|
989
|
+
fs.rmSync(out, { force: true });
|
|
990
|
+
const r = runFf(bin, args);
|
|
991
|
+
const size = (() => { try { return fs.statSync(out).size; } catch { return 0; } })();
|
|
992
|
+
if (r.code === 0 && size > 0) return out;
|
|
993
|
+
const how = r.error ? (r.error.code === 'ETIMEDOUT' ? `timed out after ${FF_TIMEOUT_MS / 1000} s` : r.error.code || r.error.message)
|
|
994
|
+
: r.signal ? `killed by ${r.signal}` : r.code !== 0 ? `exit ${r.code}: ${tail(r.stderr)}` : 'ffmpeg wrote no output';
|
|
995
|
+
throw new Error(`${name} failed: ${how}`);
|
|
959
996
|
}
|
|
960
997
|
|
|
961
998
|
/** Extract the exact LAST frame of an mp4 to a PNG (reverse + first-frame trick). Returns out. */
|
|
962
999
|
export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
|
|
963
1000
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
964
|
-
|
|
965
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractLastFrame failed: ${tail(r.stderr)}`);
|
|
966
|
-
return out;
|
|
1001
|
+
return ffOut('extractLastFrame', bin, ['-y', '-i', mp4, '-vf', 'reverse', '-frames:v', '1', out], out);
|
|
967
1002
|
}
|
|
968
1003
|
|
|
969
1004
|
/**
|
|
@@ -974,20 +1009,15 @@ export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
|
|
|
974
1009
|
*/
|
|
975
1010
|
export async function stillToClip(png, out, { ffmpeg, seconds = 1, fps = 24, size } = {}) {
|
|
976
1011
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
977
|
-
fs.rmSync(out, { force: true }); // a file from an earlier run must not pass for this one
|
|
978
1012
|
const vf = `${size ? `scale=${String(size).replace('x', ':')},` : ''}format=yuv420p`;
|
|
979
|
-
|
|
980
|
-
'-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out]);
|
|
981
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`stillToClip failed: ${tail(r.stderr)}`);
|
|
982
|
-
return out;
|
|
1013
|
+
return ffOut('stillToClip', bin, ['-y', '-loop', '1', '-i', png, '-t', String(seconds), '-r', String(fps), '-vf', vf,
|
|
1014
|
+
'-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out], out);
|
|
983
1015
|
}
|
|
984
1016
|
|
|
985
1017
|
/** Extract the FIRST frame of an mp4 to a PNG. Returns out. */
|
|
986
1018
|
export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
|
|
987
1019
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
988
|
-
|
|
989
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractFirstFrame failed: ${tail(r.stderr)}`);
|
|
990
|
-
return out;
|
|
1020
|
+
return ffOut('extractFirstFrame', bin, ['-y', '-i', mp4, '-frames:v', '1', out], out);
|
|
991
1021
|
}
|
|
992
1022
|
|
|
993
1023
|
/**
|
|
@@ -1000,13 +1030,12 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
|
|
|
1000
1030
|
let off = offset;
|
|
1001
1031
|
if (off == null) {
|
|
1002
1032
|
const dur = probeDurationSec(bin, aMp4);
|
|
1003
|
-
|
|
1033
|
+
// with A's length unknown, an offset of 0 would cut A to the fade: a wrong clip, not a result
|
|
1034
|
+
if (dur == null) throw new Error(`crossfade failed: could not read the duration of ${path.basename(aMp4)}; pass { offset }`);
|
|
1035
|
+
off = Math.max(0, dur - sec);
|
|
1004
1036
|
}
|
|
1005
1037
|
const filter = `[0:v][1:v]xfade=transition=fade:duration=${sec}:offset=${off}`;
|
|
1006
|
-
|
|
1007
|
-
const r = runFf(bin, args);
|
|
1008
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`crossfade failed: ${tail(r.stderr)}`);
|
|
1009
|
-
return out;
|
|
1038
|
+
return ffOut('crossfade', bin, ['-y', '-i', aMp4, '-i', bMp4, '-filter_complex', filter, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', out], out);
|
|
1010
1039
|
}
|
|
1011
1040
|
|
|
1012
1041
|
/**
|
|
@@ -1015,9 +1044,7 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
|
|
|
1015
1044
|
*/
|
|
1016
1045
|
export async function concatClips(mp4List, out, { ffmpeg, fps = 30, width = 720, height = 1280 } = {}) {
|
|
1017
1046
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
1018
|
-
|
|
1019
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`concatClips failed: ${tail(r.stderr)}`);
|
|
1020
|
-
return out;
|
|
1047
|
+
return ffOut('concatClips', bin, concatArgs(clipList(mp4List), out, { fps, width, height }), out);
|
|
1021
1048
|
}
|
|
1022
1049
|
|
|
1023
1050
|
function clipList(mp4List) {
|
|
@@ -1038,7 +1065,7 @@ function concatArgs(list, out, { fps, width, height }) {
|
|
|
1038
1065
|
|
|
1039
1066
|
// Probe an mp4's duration (seconds) by parsing ffmpeg's stderr banner. Returns null if absent.
|
|
1040
1067
|
function probeDurationSec(bin, mp4) {
|
|
1041
|
-
const r = cp.spawnSync(bin, ['-i', mp4], { encoding: 'utf8' });
|
|
1068
|
+
const r = cp.spawnSync(bin, ['-i', mp4], { encoding: 'utf8', timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
|
|
1042
1069
|
const text = `${r.stderr || ''}${r.stdout || ''}`;
|
|
1043
1070
|
const m = text.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
|
1044
1071
|
if (!m) return null;
|
|
@@ -43,7 +43,7 @@ import crypto from 'node:crypto';
|
|
|
43
43
|
import cp from 'node:child_process';
|
|
44
44
|
import {
|
|
45
45
|
createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey, keyHeldBack, probeSoraDeployment,
|
|
46
|
-
API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft,
|
|
46
|
+
API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft, isMp4, isImage, FF_TIMEOUT_MS,
|
|
47
47
|
} from './azure-adapter.mjs';
|
|
48
48
|
|
|
49
49
|
export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
|
|
@@ -399,9 +399,11 @@ async function stripAlpha(buf) {
|
|
|
399
399
|
const src = path.join(dir, 'in.png');
|
|
400
400
|
const out = path.join(dir, 'out.png');
|
|
401
401
|
fs.writeFileSync(src, buf);
|
|
402
|
-
const r = cp.spawnSync(bin, ['-y', '-i', src, '-vf', 'format=rgb24', '-frames:v', '1', out], { encoding: 'utf8' });
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
const r = cp.spawnSync(bin, ['-y', '-i', src, '-vf', 'format=rgb24', '-frames:v', '1', out], { encoding: 'utf8', timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
|
|
403
|
+
// only a run that ended by itself with 0: a killed ffmpeg's partial PNG would go to WAN as the frame
|
|
404
|
+
const png = r.status === 0 && fs.existsSync(out) ? fs.readFileSync(out) : null;
|
|
405
|
+
if (!png || !isImage(png)) throw new Error(`stripAlpha failed: ${r.error ? r.error.code : r.signal ? `killed by ${r.signal}` : String(r.stderr || '').slice(-400)}`);
|
|
406
|
+
return png;
|
|
405
407
|
} finally {
|
|
406
408
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
407
409
|
}
|
|
@@ -410,6 +412,8 @@ async function stripAlpha(buf) {
|
|
|
410
412
|
/** Encode a PNG/JPEG frame as the data URI WAN accepts, dropping any PNG alpha channel first. */
|
|
411
413
|
export async function frameDataUri(buf) {
|
|
412
414
|
const b = Buffer.isBuffer(buf) ? buf : fs.readFileSync(buf);
|
|
415
|
+
// an empty or non-image seed would be sent with a paid task and answered with a clip that does not start from it
|
|
416
|
+
if (!isImage(b)) throw new Error(`frameDataUri: the frame is not a PNG, JPEG or WebP image (${b.length} bytes)`);
|
|
413
417
|
if (b[0] === 0xff && b[1] === 0xd8) return `data:image/jpeg;base64,${b.toString('base64')}`;
|
|
414
418
|
const png = pngHasAlpha(b) ? await stripAlpha(b) : b;
|
|
415
419
|
return `data:image/png;base64,${png.toString('base64')}`;
|
|
@@ -514,10 +518,11 @@ export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {
|
|
|
514
518
|
let last;
|
|
515
519
|
for (let i = 0; i < tries; i++) {
|
|
516
520
|
last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
|
517
|
-
|
|
521
|
+
// a 200 that is an HTML or JSON page (an expired or refused signed URL) is not the video: try again, then fail
|
|
522
|
+
if (last.status === 200 && isMp4(last.body)) return last.body;
|
|
518
523
|
if (i < tries - 1) await sleep(2000 * (i + 1));
|
|
519
524
|
}
|
|
520
|
-
throw new Error(`WAN video download failed: HTTP ${last?.status}`);
|
|
525
|
+
throw new Error(`WAN video download failed: HTTP ${last?.status}${last?.status === 200 ? ` but the body is not an mp4 (${last.body?.length ?? 0} bytes)` : ''}`);
|
|
521
526
|
}
|
|
522
527
|
|
|
523
528
|
/**
|
|
@@ -642,12 +642,26 @@ export async function generateImage({
|
|
|
642
642
|
const classicWorthTrying = (r) => !(r.ok && dataB64(r)) && r.status !== 429 && r.status !== 0;
|
|
643
643
|
const dataB64 = (r) => r.body?.data?.[0]?.b64_json;
|
|
644
644
|
|
|
645
|
-
// The PNG an image call answered with, or an error that starts with `failed`.
|
|
645
|
+
// The PNG an image call answered with, or an error that starts with `failed`. Bytes that are not an image (a URL or an
|
|
646
|
+
// error text in the image field, base64 that decodes to nothing) are a failure, not a picture.
|
|
646
647
|
function pngFrom(r, b64, failed) {
|
|
647
648
|
if (!(r.ok && b64)) throw new Error(`${failed}: HTTP ${r.status}${why(r)} ${truncate(r.body)}`);
|
|
648
|
-
|
|
649
|
+
const buf = Buffer.from(String(b64), 'base64');
|
|
650
|
+
if (!isImage(buf)) throw new Error(`${failed}: the answer is not a PNG, JPEG or WebP image (${buf.length} bytes)`);
|
|
651
|
+
return buf;
|
|
649
652
|
}
|
|
650
653
|
|
|
654
|
+
/** Whether bytes start as a PNG, JPEG or WebP image. */
|
|
655
|
+
export const isImage = (b) => Buffer.isBuffer(b) && b.length >= 12 && (b.readUInt32BE(0) === 0x89504e47
|
|
656
|
+
|| (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) || (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP'));
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Whether bytes start as an ISO media file (mp4, mov): the first box is one such a file starts with (ftyp, or styp,
|
|
660
|
+
* free, skip, wide, moov, mdat in fragmented and QuickTime files). An HTML or JSON error page is not.
|
|
661
|
+
*/
|
|
662
|
+
const MEDIA_BOXES = new Set(['ftyp', 'styp', 'free', 'skip', 'wide', 'moov', 'mdat']);
|
|
663
|
+
export const isMp4 = (b) => Buffer.isBuffer(b) && b.length >= 12 && MEDIA_BOXES.has(b.toString('latin1', 4, 8));
|
|
664
|
+
|
|
651
665
|
/** Edit an image (optional mask) via the v1 images/edits multipart path. Returns a PNG Buffer. */
|
|
652
666
|
export async function editImage({
|
|
653
667
|
endpoint, key, deployment, apiVersion = 'preview', prompt, imageBuf, maskBuf, size,
|
|
@@ -781,6 +795,7 @@ export async function downloadVideo({ endpoint, key, id, apiVersion = 'preview'
|
|
|
781
795
|
key, method: 'GET', binary: true,
|
|
782
796
|
});
|
|
783
797
|
if (!(r.status === 200 && r.body?.length)) throw Object.assign(new Error(`downloadVideo failed: HTTP ${r.status}${why(r)}`), { status: r.status });
|
|
798
|
+
if (!isMp4(r.body)) throw Object.assign(new Error(`downloadVideo failed: HTTP 200 but the body is not an mp4 (${r.ct || 'no content type'}, ${r.body.length} bytes)`), { status: r.status });
|
|
784
799
|
return r.body;
|
|
785
800
|
}
|
|
786
801
|
|
|
@@ -919,7 +934,7 @@ function twoImageForm({ model, prompt, firstPng, lastPng, size, seconds }) {
|
|
|
919
934
|
* 3) the optional ffmpeg-static package (dynamic import — may be absent)
|
|
920
935
|
* A project's .env.3d-intro.local never names the program, and "." or another relative PATH entry is
|
|
921
936
|
* skipped: either would let the files of a project, a cloned repository say, choose what runs.
|
|
922
|
-
* Throws an actionable error if none is found.
|
|
937
|
+
* Throws an actionable error if none is found, or if $FFMPEG_PATH is set but not usable.
|
|
923
938
|
*/
|
|
924
939
|
export async function resolveFfmpeg() {
|
|
925
940
|
const found = namedFfmpeg() || ffmpegOnPath() || await staticFfmpeg();
|
|
@@ -928,10 +943,17 @@ export async function resolveFfmpeg() {
|
|
|
928
943
|
}
|
|
929
944
|
|
|
930
945
|
// The ffmpeg this machine names. Absolute only: a relative value would be looked up in the current
|
|
931
|
-
// folder, the project.
|
|
946
|
+
// folder, the project. A $FFMPEG_PATH from the shell is the user's choice for this run: when it is
|
|
947
|
+
// relative or names no file, this stops with why instead of quietly taking another ffmpeg. The files
|
|
948
|
+
// are read only when the shell names nothing.
|
|
932
949
|
function namedFfmpeg() {
|
|
933
950
|
const usable = (p) => !!p && path.isAbsolute(p) && fs.existsSync(p);
|
|
934
|
-
|
|
951
|
+
const shell = process.env.FFMPEG_PATH;
|
|
952
|
+
if (shell) {
|
|
953
|
+
if (usable(shell)) return shell;
|
|
954
|
+
const why = path.isAbsolute(shell) ? 'no such file' : 'not an absolute path';
|
|
955
|
+
throw new Error(`FFMPEG_PATH from the shell is not usable (${why}): ${shell}; fix it or unset it`);
|
|
956
|
+
}
|
|
935
957
|
return [readSettings(), readEnvQuietly(homeCredsPath)].map((s) => s.FFMPEG_PATH).find(usable) || null;
|
|
936
958
|
}
|
|
937
959
|
|
|
@@ -953,17 +975,30 @@ async function staticFfmpeg() {
|
|
|
953
975
|
}
|
|
954
976
|
|
|
955
977
|
// Run ffmpeg with an ARGUMENT ARRAY (never a shell string). Returns {code, stdout, stderr, args}.
|
|
978
|
+
// ffmpeg has a time limit: a stuck one (a FIFO input, a blocked disk) would hold the whole run. SIGKILL, as a stuck
|
|
979
|
+
// ffmpeg may not end on SIGTERM.
|
|
980
|
+
export const FF_TIMEOUT_MS = 10 * 60 * 1000;
|
|
956
981
|
function runFf(bin, args, cwd) {
|
|
957
|
-
const r = cp.spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
958
|
-
return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '', args };
|
|
982
|
+
const r = cp.spawnSync(bin, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28, timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
|
|
983
|
+
return { code: r.status, signal: r.signal || null, error: r.error || null, stdout: r.stdout || '', stderr: r.stderr || '', args };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// One ffmpeg run that must make `out`: an earlier file there is removed first, and the run counts only when ffmpeg
|
|
987
|
+
// ended by itself with 0 and `out` is a file with something in it. A killed or failed ffmpeg's partial file is not a result.
|
|
988
|
+
function ffOut(name, bin, args, out) {
|
|
989
|
+
fs.rmSync(out, { force: true });
|
|
990
|
+
const r = runFf(bin, args);
|
|
991
|
+
const size = (() => { try { return fs.statSync(out).size; } catch { return 0; } })();
|
|
992
|
+
if (r.code === 0 && size > 0) return out;
|
|
993
|
+
const how = r.error ? (r.error.code === 'ETIMEDOUT' ? `timed out after ${FF_TIMEOUT_MS / 1000} s` : r.error.code || r.error.message)
|
|
994
|
+
: r.signal ? `killed by ${r.signal}` : r.code !== 0 ? `exit ${r.code}: ${tail(r.stderr)}` : 'ffmpeg wrote no output';
|
|
995
|
+
throw new Error(`${name} failed: ${how}`);
|
|
959
996
|
}
|
|
960
997
|
|
|
961
998
|
/** Extract the exact LAST frame of an mp4 to a PNG (reverse + first-frame trick). Returns out. */
|
|
962
999
|
export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
|
|
963
1000
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
964
|
-
|
|
965
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractLastFrame failed: ${tail(r.stderr)}`);
|
|
966
|
-
return out;
|
|
1001
|
+
return ffOut('extractLastFrame', bin, ['-y', '-i', mp4, '-vf', 'reverse', '-frames:v', '1', out], out);
|
|
967
1002
|
}
|
|
968
1003
|
|
|
969
1004
|
/**
|
|
@@ -974,20 +1009,15 @@ export async function extractLastFrame(mp4, out, { ffmpeg } = {}) {
|
|
|
974
1009
|
*/
|
|
975
1010
|
export async function stillToClip(png, out, { ffmpeg, seconds = 1, fps = 24, size } = {}) {
|
|
976
1011
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
977
|
-
fs.rmSync(out, { force: true }); // a file from an earlier run must not pass for this one
|
|
978
1012
|
const vf = `${size ? `scale=${String(size).replace('x', ':')},` : ''}format=yuv420p`;
|
|
979
|
-
|
|
980
|
-
'-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out]);
|
|
981
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`stillToClip failed: ${tail(r.stderr)}`);
|
|
982
|
-
return out;
|
|
1013
|
+
return ffOut('stillToClip', bin, ['-y', '-loop', '1', '-i', png, '-t', String(seconds), '-r', String(fps), '-vf', vf,
|
|
1014
|
+
'-c:v', 'libx264', '-crf', '20', '-g', '8', '-movflags', '+faststart', '-an', out], out);
|
|
983
1015
|
}
|
|
984
1016
|
|
|
985
1017
|
/** Extract the FIRST frame of an mp4 to a PNG. Returns out. */
|
|
986
1018
|
export async function extractFirstFrame(mp4, out, { ffmpeg } = {}) {
|
|
987
1019
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
988
|
-
|
|
989
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`extractFirstFrame failed: ${tail(r.stderr)}`);
|
|
990
|
-
return out;
|
|
1020
|
+
return ffOut('extractFirstFrame', bin, ['-y', '-i', mp4, '-frames:v', '1', out], out);
|
|
991
1021
|
}
|
|
992
1022
|
|
|
993
1023
|
/**
|
|
@@ -1000,13 +1030,12 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
|
|
|
1000
1030
|
let off = offset;
|
|
1001
1031
|
if (off == null) {
|
|
1002
1032
|
const dur = probeDurationSec(bin, aMp4);
|
|
1003
|
-
|
|
1033
|
+
// with A's length unknown, an offset of 0 would cut A to the fade: a wrong clip, not a result
|
|
1034
|
+
if (dur == null) throw new Error(`crossfade failed: could not read the duration of ${path.basename(aMp4)}; pass { offset }`);
|
|
1035
|
+
off = Math.max(0, dur - sec);
|
|
1004
1036
|
}
|
|
1005
1037
|
const filter = `[0:v][1:v]xfade=transition=fade:duration=${sec}:offset=${off}`;
|
|
1006
|
-
|
|
1007
|
-
const r = runFf(bin, args);
|
|
1008
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`crossfade failed: ${tail(r.stderr)}`);
|
|
1009
|
-
return out;
|
|
1038
|
+
return ffOut('crossfade', bin, ['-y', '-i', aMp4, '-i', bMp4, '-filter_complex', filter, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', out], out);
|
|
1010
1039
|
}
|
|
1011
1040
|
|
|
1012
1041
|
/**
|
|
@@ -1015,9 +1044,7 @@ export async function crossfade(aMp4, bMp4, out, sec = 0.5, { ffmpeg, offset } =
|
|
|
1015
1044
|
*/
|
|
1016
1045
|
export async function concatClips(mp4List, out, { ffmpeg, fps = 30, width = 720, height = 1280 } = {}) {
|
|
1017
1046
|
const bin = ffmpeg || await resolveFfmpeg();
|
|
1018
|
-
|
|
1019
|
-
if (r.code !== 0 && !fs.existsSync(out)) throw new Error(`concatClips failed: ${tail(r.stderr)}`);
|
|
1020
|
-
return out;
|
|
1047
|
+
return ffOut('concatClips', bin, concatArgs(clipList(mp4List), out, { fps, width, height }), out);
|
|
1021
1048
|
}
|
|
1022
1049
|
|
|
1023
1050
|
function clipList(mp4List) {
|
|
@@ -1038,7 +1065,7 @@ function concatArgs(list, out, { fps, width, height }) {
|
|
|
1038
1065
|
|
|
1039
1066
|
// Probe an mp4's duration (seconds) by parsing ffmpeg's stderr banner. Returns null if absent.
|
|
1040
1067
|
function probeDurationSec(bin, mp4) {
|
|
1041
|
-
const r = cp.spawnSync(bin, ['-i', mp4], { encoding: 'utf8' });
|
|
1068
|
+
const r = cp.spawnSync(bin, ['-i', mp4], { encoding: 'utf8', timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
|
|
1042
1069
|
const text = `${r.stderr || ''}${r.stdout || ''}`;
|
|
1043
1070
|
const m = text.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
|
1044
1071
|
if (!m) return null;
|
|
@@ -43,7 +43,7 @@ import crypto from 'node:crypto';
|
|
|
43
43
|
import cp from 'node:child_process';
|
|
44
44
|
import {
|
|
45
45
|
createVideo, pollVideo, downloadVideo, resolveFfmpeg, redact, fetchGuarded, safeForKey, keyHeldBack, probeSoraDeployment,
|
|
46
|
-
API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft,
|
|
46
|
+
API_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, HELD_KEY, errnoOf, timeLeft, isMp4, isImage, FF_TIMEOUT_MS,
|
|
47
47
|
} from './azure-adapter.mjs';
|
|
48
48
|
|
|
49
49
|
export const WAN_DEFAULT_MODEL = 'wan3.0-video-prime';
|
|
@@ -399,9 +399,11 @@ async function stripAlpha(buf) {
|
|
|
399
399
|
const src = path.join(dir, 'in.png');
|
|
400
400
|
const out = path.join(dir, 'out.png');
|
|
401
401
|
fs.writeFileSync(src, buf);
|
|
402
|
-
const r = cp.spawnSync(bin, ['-y', '-i', src, '-vf', 'format=rgb24', '-frames:v', '1', out], { encoding: 'utf8' });
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
const r = cp.spawnSync(bin, ['-y', '-i', src, '-vf', 'format=rgb24', '-frames:v', '1', out], { encoding: 'utf8', timeout: FF_TIMEOUT_MS, killSignal: 'SIGKILL' });
|
|
403
|
+
// only a run that ended by itself with 0: a killed ffmpeg's partial PNG would go to WAN as the frame
|
|
404
|
+
const png = r.status === 0 && fs.existsSync(out) ? fs.readFileSync(out) : null;
|
|
405
|
+
if (!png || !isImage(png)) throw new Error(`stripAlpha failed: ${r.error ? r.error.code : r.signal ? `killed by ${r.signal}` : String(r.stderr || '').slice(-400)}`);
|
|
406
|
+
return png;
|
|
405
407
|
} finally {
|
|
406
408
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
407
409
|
}
|
|
@@ -410,6 +412,8 @@ async function stripAlpha(buf) {
|
|
|
410
412
|
/** Encode a PNG/JPEG frame as the data URI WAN accepts, dropping any PNG alpha channel first. */
|
|
411
413
|
export async function frameDataUri(buf) {
|
|
412
414
|
const b = Buffer.isBuffer(buf) ? buf : fs.readFileSync(buf);
|
|
415
|
+
// an empty or non-image seed would be sent with a paid task and answered with a clip that does not start from it
|
|
416
|
+
if (!isImage(b)) throw new Error(`frameDataUri: the frame is not a PNG, JPEG or WebP image (${b.length} bytes)`);
|
|
413
417
|
if (b[0] === 0xff && b[1] === 0xd8) return `data:image/jpeg;base64,${b.toString('base64')}`;
|
|
414
418
|
const png = pngHasAlpha(b) ? await stripAlpha(b) : b;
|
|
415
419
|
return `data:image/png;base64,${png.toString('base64')}`;
|
|
@@ -514,10 +518,11 @@ export async function downloadWanVideo(url, { sleep = realSleep, tries = 3 } = {
|
|
|
514
518
|
let last;
|
|
515
519
|
for (let i = 0; i < tries; i++) {
|
|
516
520
|
last = await wanFetch(url, { binary: true, timeoutMs: DOWNLOAD_TIMEOUT_MS });
|
|
517
|
-
|
|
521
|
+
// a 200 that is an HTML or JSON page (an expired or refused signed URL) is not the video: try again, then fail
|
|
522
|
+
if (last.status === 200 && isMp4(last.body)) return last.body;
|
|
518
523
|
if (i < tries - 1) await sleep(2000 * (i + 1));
|
|
519
524
|
}
|
|
520
|
-
throw new Error(`WAN video download failed: HTTP ${last?.status}`);
|
|
525
|
+
throw new Error(`WAN video download failed: HTTP ${last?.status}${last?.status === 200 ? ` but the body is not an mp4 (${last.body?.length ?? 0} bytes)` : ''}`);
|
|
521
526
|
}
|
|
522
527
|
|
|
523
528
|
/**
|