@kaydash9999/banker-plugins 0.26.1 → 0.28.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 +39 -0
- package/README.md +25 -10
- package/bin/lib/codex-hooks.mjs +7 -1
- package/codex/command-skills/front-qa/SKILL.md +3 -1
- package/codex/hooks.json +15 -5
- package/codex/manifest.json +4 -0
- package/codex/skills/ralph/SKILL.md +272 -0
- package/codex/skills/ralph/scripts/gate-evidence.mjs +457 -0
- package/codex/skills/ralph/scripts/ralph-state.mjs +622 -0
- package/codex/skills/ralph/scripts/run-folder.mjs +299 -0
- package/codex/transform-matrix.md +5 -4
- package/commands/front-qa.md +3 -1
- package/hooks/ralph-continue-codex.mjs +59 -0
- package/package.json +1 -1
- package/skills/all-in-one/SKILL.md +22 -8
- package/skills/ralph-qa/references/gate-evidence.mjs +3 -3
- package/skills/ralph-qa/references/run-folder.mjs +3 -3
- package/skills/ultra-chain/SKILL.md +11 -4
- package/skills/ultra-init/SKILL.md +9 -7
- package/skills/ultraqa/references/gate-evidence.mjs +3 -3
- package/skills/ultraqa/references/run-folder.mjs +3 -3
- package/skills/video-gen/SKILL.md +141 -0
- package/skills/video-gen/assets/example.json +36 -0
- package/skills/video-gen/scripts/vg-catalog.mjs +23 -0
- package/skills/video-gen/scripts/vg-deck.mjs +72 -0
- package/skills/video-gen/scripts/vg-draw.mjs +231 -0
- package/skills/video-gen/scripts/vg-engine.mjs +74 -0
- package/skills/video-gen/scripts/vg-fx.mjs +106 -0
- package/skills/video-gen/scripts/vg-model.mjs +236 -0
- package/skills/video-gen/scripts/vg-render.mjs +151 -0
- package/skills/video-gen/scripts/vg-scenes.mjs +226 -0
- package/skills/video-gen/scripts/video-gen.mjs +212 -0
- package/skills/video-gen/vendor/README.md +25 -0
- package/skills/video-gen/vendor/aimotion/LICENSE.md +21 -0
- package/skills/video-gen/vendor/canvaskit/LICENSE.md +29 -0
- package/skills/video-gen/vendor/canvaskit/THIRD-PARTY-NOTICES.md +1366 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.js +270 -0
- package/skills/video-gen/vendor/canvaskit/canvaskit.wasm +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Bold.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSans-Regular.ttf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Bold.otf +0 -0
- package/skills/video-gen/vendor/fonts/NotoSansKR-Regular.otf +0 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans-kr.md +94 -0
- package/skills/video-gen/vendor/fonts/OFL-noto-sans.md +93 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/LICENSE.md +21 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/MPL-1.1.md +469 -0
- package/skills/video-gen/vendor/h264-mp4-encoder/h264-mp4-encoder.node.js +2 -0
- package/skills/visual-ralph/SKILL.md +9 -4
- package/skills/wbs-gen/SKILL.md +127 -0
- package/skills/wbs-gen/assets/example.json +58 -0
- package/skills/wbs-gen/scripts/wbs-gen.mjs +238 -0
- package/skills/wbs-gen/scripts/wg-dates.mjs +106 -0
- package/skills/wbs-gen/scripts/wg-holidays.mjs +89 -0
- package/skills/wbs-gen/scripts/wg-html.mjs +108 -0
- package/skills/wbs-gen/scripts/wg-plan.mjs +230 -0
- package/skills/wbs-gen/scripts/wg-xlsx.mjs +328 -0
- package/skills/wbs-gen/scripts/wg-zip.mjs +83 -0
- package/skills/wide-pptx/SKILL.md +121 -0
- package/skills/wide-pptx/assets/example.json +63 -0
- package/skills/wide-pptx/scripts/wide-pptx.mjs +185 -0
- package/skills/wide-pptx/scripts/wp-html.mjs +98 -0
- package/skills/wide-pptx/scripts/wp-model.mjs +236 -0
- package/skills/wide-pptx/scripts/wp-pptx.mjs +219 -0
- package/skills/wide-pptx/scripts/wp-svg.mjs +114 -0
- package/skills/wide-pptx/vendor/LICENSE-jszip.md +651 -0
- package/skills/wide-pptx/vendor/LICENSE-pako.md +21 -0
- package/skills/wide-pptx/vendor/LICENSE-pptxgenjs.md +21 -0
- package/skills/wide-pptx/vendor/README.md +12 -0
- package/skills/wide-pptx/vendor/THIRD-PARTY-NOTICES.md +142 -0
- package/skills/wide-pptx/vendor/pptxgen.bundle.js +96 -0
|
@@ -38,7 +38,7 @@ So the effective order is `ralplan → ralph → QA gate`. This is intentional,
|
|
|
38
38
|
<Loop_Authority_Rules>
|
|
39
39
|
Several bundled skills are loop/persistence engines. To prevent competing loops, **only ONE loop authority is active at a time, and the phases run strictly sequentially** (a phase fully completes before the next begins):
|
|
40
40
|
|
|
41
|
-
1. In **Phase 2, ralph is the sole loop authority** ("the boulder never stops"). Let its loop run to completion. Do **NOT** arm a Claude Code `/goal` Stop-hook loop beside it.
|
|
41
|
+
1. In **Phase 2, ralph is the sole loop authority** ("the boulder never stops" in Claude Code; on Codex banker's `ralph` Stop hook keeps the turn going). Let its loop run to completion. Do **NOT** arm a Claude Code `/goal` Stop-hook loop beside it.
|
|
42
42
|
2. **The QA gate runs only in Phase 3, AFTER ralph has finished**, as the terminal QA sub-gate — never concurrently with ralph. banker's `ralph-qa` owns that judge → fix loop on both runtimes; ultra-init owns no loop.
|
|
43
43
|
3. **There is no ultrawork phase.** OMC 5.0.0 retired `ultrawork`; ralph's own parallel delegation covers it inside Phase 2.
|
|
44
44
|
</Loop_Authority_Rules>
|
|
@@ -53,19 +53,20 @@ Several bundled skills are loop/persistence engines. To prevent competing loops,
|
|
|
53
53
|
omc ralph verify --help 2>&1 | grep -q 'Usage: omc ralph verify' && echo "omc ok" || echo "omc CLI missing or too old"
|
|
54
54
|
```
|
|
55
55
|
If the check fails, **guide installation or update first** via `/banker:setup` → oh-my-claudecode (or the `setup-omc` skill / `npm install -g oh-my-claude-sisyphus@latest`), then resume once it passes. There is no way around a missing or stale CLI: ralph itself stops without `omc ralph verify`, so do not start Phase 1.
|
|
56
|
-
- **런타임 매핑 (Claude Code ↔ Codex).** `ralplan`·`
|
|
57
|
-
- **Codex 의 tmux 밖 실행.** OMX 의 런타임 워크플로(`
|
|
58
|
-
- **Codex 의 계획과 상태 경로.** 이 문서의 `.omc/plans/`, `.omc/state/sessions/<id>/prd.json` 은 Claude Code(OMC) 경로다. Codex 에서는 OMX 경로를 쓴다: 계획은 `.omx/plans/`(ralplan 이 쓰는 `prd-*.md`, `test-spec-*.md`), ralph 의 진행 기록은
|
|
56
|
+
- **런타임 매핑 (Claude Code ↔ Codex).** `ralplan`·`cancel` 은 Claude Code에선 OMC, **Codex에선 oh-my-codex(OMX)가 제공하는 동명 스킬**이다(Codex는 `omx setup` 전제, 점검은 `omx --version`). `ralph` 는 Claude Code에선 OMC, **Codex에선 banker 의 Codex 전용 `ralph`** 다(npm 길 `$banker-ralph`, 플러그인 길 `$banker:ralph`, `SKILL.md` 는 npm 길 `<S>/../banker-ralph/SKILL.md`, 플러그인 길 `<S>/../../codex/skills/ralph/SKILL.md`). OMX 0.21 이 `$ralph` 를 없앴으므로 OMX 의 `$ralph` 는 부르지 않는다. banker `ralph` 는 OMX 와 `omc` CLI 를 쓰지 않으므로 위 `omc` 점검은 Claude Code 에만 해당한다. Phase 3 QA 게이트는 두 런타임 모두 banker `ralph-qa` 다: Claude Code `Skill("banker:ralph-qa")`, Codex npm 길 `$banker-ralph-qa`, 플러그인 길 `$banker:ralph-qa`. Codex 에는 Skill 도구가 없으니 그 `SKILL.md` 를 읽고 그대로 따른다(npm 길 `<S>/../banker-ralph-qa/SKILL.md`, 플러그인 길 `<S>/../ralph-qa/SKILL.md`, `<S>` 는 이 스킬 폴더). `<RQ>` 는 ralph-qa 의 `references` 폴더다(Claude Code 와 Codex 플러그인 길 `<S>/../ralph-qa/references`, Codex npm 길 `<S>/../banker-ralph-qa/references`). 아래 Phase들의 `Skill("oh-my-claudecode:<name>")` 표기는 Claude Code 기준 — **Codex 런타임에선 OMX의 해당 스킬을 적용**한다(`ralph` 만 banker `ralph`).
|
|
57
|
+
- **Codex 의 tmux 밖 실행.** banker `ralph` 는 tmux 가 필요 없다. OMX 의 런타임 워크플로(`team` 등)는 OMX tmux 런타임을 요구한다. OMX 의 `AGENTS.md` 는 tmux 밖(Codex App, 일반 `codex`, `codex exec`)이면 그 사실을 알리고 가장 가까운 대체 경로로 계속하라고 정한다(oh-my-codex 0.20.2 `templates/AGENTS.md`). 그래서 tmux 밖이라는 사실만으로는 멈추지 않는다. 그 단계는 이 세션이 그 스킬의 `SKILL.md` 절차를 네이티브 하위 에이전트로 그대로 따르고, 보고에 `<단계>: tmux 밖 대체 실행(OMX 런타임 없음)` 한 줄을 적는다. 종결 낱말은 다른 멈춤 조건과 마지막 ralph-qa 판정으로만 정한다. 대체 실행도 할 수 없으면(하위 에이전트 기능이 꺼짐 등) `STOPPED(omx-runtime-unavailable)` 로 멈추고 tmux 안의 OMX 에서 다시 실행하라고 안내한다.
|
|
58
|
+
- **Codex 의 계획과 상태 경로.** 이 문서의 `.omc/plans/`, `.omc/state/sessions/<id>/prd.json` 은 Claude Code(OMC) 경로다. Codex 에서는 OMX 경로를 쓴다: 계획은 `.omx/plans/`(ralplan 이 쓰는 `prd-*.md`, `test-spec-*.md`), ralph 의 진행 기록은 저장소 밖 게이트 폴더의 `ralph.md`(banker `ralph` 의 `ralph-state.mjs`). Codex 에서 `.omc/` 아래에 계획을 쓰지 않는다.
|
|
59
59
|
|
|
60
60
|
### Phase 1 — PLAN: ralplan --deliberate (합의 계획)
|
|
61
61
|
- Invoke `Skill("oh-my-claudecode:ralplan")` with `--deliberate <brief>` in **non-interactive** mode (so it produces the consensus-approved plan, marks it `pending approval`, and **stops without launching team/ralph itself** — ultra-init controls the hand-off).
|
|
62
|
+
- **Codex: plan only.** OMX 0.21's `$ralplan` ends by moving on to `$ultragoal`. Tell it to write the plan and stop there: no `$ultragoal`, `$ralph`, `$team` or implementation. Phase 2 is banker's `ralph`.
|
|
62
63
|
- ralplan runs **Planner → Architect → Critic** to consensus (max 5 iterations), and in deliberate mode adds a **pre-mortem (3 scenarios) + expanded test plan (unit/integration/e2e/observability)**.
|
|
63
64
|
- Capture the final plan and its path in `.omc/plans/`: **ADR + ordered user stories + per-story testable acceptance criteria + the test plan**. This is the seed for Phases 2–3 and the durable record of the run.
|
|
64
65
|
- Autonomy: treat the consensus-approved plan as approved-for-execution and continue. (`--gated` → pause here for user approval, then continue.)
|
|
65
66
|
|
|
66
67
|
### Before Phase 2 — record what already fails
|
|
67
68
|
- Make the gate folder with `node '<RQ>/gate-evidence.mjs' init --cwd '<repo>'` (its `dir` is the gate folder `<g>`) and run each targeted QA dimension's command once on the unchanged tree, through ralph-qa's runner, as iteration 0: `node '<RQ>/gate-evidence.mjs' run --dir '<g>' --id <dimension> --cmd '<command>' --timeout <seconds> --iter 0 --cwd '<repo>'`. One command per dimension (no `&&` or `;` chains), a `--timeout` on each; `scenarios` has no baseline (its rows test the change).
|
|
68
|
-
- This **pre-change record** (iteration 0 in `<g>/evidence.md`) is the QA gate's baseline: ralph-qa counts a failure with the same command and failure signature as not this change's (`baseline`). It covers checks outside ralph's feedback commands
|
|
69
|
+
- This **pre-change record** (iteration 0 in `<g>/evidence.md`) is the QA gate's baseline: ralph-qa counts a failure with the same command and failure signature as not this change's (`baseline`). It covers checks outside ralph's feedback commands. On Codex banker's `ralph` keeps a baseline of its own in its own gate folder; this one is still the QA gate's. On Codex the runner writes outside the workspace: ask for approval to run it outside the sandbox.
|
|
69
70
|
- Never use `git stash` or `git reset` for this; if the tree already has changes, take the record from a clean copy of HEAD (`git worktree add --detach <tmp> HEAD`, run with `--cwd <tmp>`, then `git worktree remove <tmp>`). A fresh worktree has no installed dependencies (`node_modules`, a venv): link or install them there first, and confirm the commands really ran — a record made only of "command not found" errors is no baseline. The runner writes the folder a command ran in as `<repo>` in the failure signature, so a failure in the copy and the same failure in the repo match.
|
|
70
71
|
- The record lives in `<g>`; if the session may compact or end before Phase 3, put the `<g>` path in the `ready-compact` note. The gate folder lives until Phase 3 has its end word: no cleanup's day rule takes it (ralph-qa's gate folder rule).
|
|
71
72
|
- **Not a git repo** (common for a new project): if `init` (or `run`) stops with `not-a-git-tree` (exit 3, nothing made or run), first check that the folder the stop names (`checked` in its JSON, an absolute path) is the target repo; if not, call it again with `--cwd '<repo>'`. Only when the target really is no git repo: the gates cannot have evidence (the work tree fingerprint sees no change). Take no baseline, and in Phase 3 hand ralph-qa no `--gates`; it then writes `게이트: 선언 없음(git 저장소 아님)` in its report and seat instructions. Never run `git init` yourself: tell the user that `git init` and a first commit make the gates work next time. The same on both runtimes (Claude Code, and Codex on the npm and the plugin route).
|
|
@@ -75,7 +76,8 @@ Several bundled skills are loop/persistence engines. To prevent competing loops,
|
|
|
75
76
|
- **No questions mid-run:** ralph 5.6.1 asks the user once for the PRD's `repoQualityClass` (`prototype`, `production` or `library`) when the task does not say. Infer it from repo signals (CI config, test depth, published package metadata) and put it in the handoff; let ralph ask only when the signals genuinely conflict.
|
|
76
77
|
- ralph first records the feedback baseline (`omc ralph verify --write-baseline`: the failures that already existed), then iterates **story-by-story**, delegating to parallel agents where stories allow, until all stories `passes: true`, followed by **reviewer verification → mandatory ai-slop-cleaner deslop → post-deslop regression re-verify**. ralph is the loop authority for this phase. Note its session id (the `<id>` in its PRD path `.omc/state/sessions/<id>/prd.json`) for a resume, and the baseline failures it reports at its closeout for the Phase 4 summary.
|
|
77
78
|
- **Feedback commands must print only failures.** OMC 5.6.1's `omc ralph verify` fingerprints every output line and normalizes only `<number><unit>` durations, timestamps, `/tmp` paths and hex ids, so a summary line that changes between runs or when tests are added reads as a NEW failure (node:test's default reporter: `ℹ duration_ms 151.4`, `ℹ tests 8` — red on every run with no change). Have ralph set the PRD `feedbackCommands` before it records the baseline to forms that are constant on success and name each failure (node:test: `FORCE_COLOR=0 NODE_NO_WARNINGS=1 node --test --test-reporter=dot 2>&1 | grep -Ev '^[.X]+$'` — the variables keep color codes from slipping past the filter and process-id warnings out of the output; adapt the form on Windows). If the gate still flags only such lines, fix the commands and re-record ralph's baseline on the current tree (`omc ralph verify --write-baseline`); the pre-change record still tells old failures from new ones. Never edit the baseline file by hand; never stash or reset.
|
|
78
|
-
-
|
|
79
|
+
- **Codex: banker `ralph`.** Follow its `SKILL.md` (paths in Phase 0) instead of OMC `ralph`. Hand it the plan path with its stories and acceptance criteria, each targeted dimension's command as a gate (one command per gate id), `--no-deslop` if passed, and "no questions: write assumptions down". `--critic` has no Codex counterpart: ralph's fresh reviewer takes that place. It runs its own gate folder, apart from `<g>`. Go on to Phase 3 only after its `RALPH COMPLETE` line; on `RALPH STOPPED` stop and report its reason. The `repoQualityClass` and `omc ralph verify` bullets above are Claude Code only. For a resume, note its gate folder (`ralph-state.mjs find --cwd '<repo>'` lists it).
|
|
80
|
+
- Do not interrupt on "The boulder never stops" continuations (Claude Code) or banker ralph's Stop-hook continuations (Codex) — that signal means ralph's iteration continues. Let it run to completion.
|
|
79
81
|
|
|
80
82
|
### Phase 3 — QA GATE: banker ralph-qa (QA 순환)
|
|
81
83
|
- After ralph completes, invoke ralph-qa once with `--gates=<dimensions> --lens=both`: every dimension the project supports (default `typecheck,lint,build,tests`, or the `--qa` list; `scenarios` adds adversarial scenario rows). One run covers every dimension: its gate conjunct holds only when each one does.
|
|
@@ -87,7 +89,7 @@ Several bundled skills are loop/persistence engines. To prevent competing loops,
|
|
|
87
89
|
- This is a belt-and-suspenders gate over ralph's own regression: a final all-green check under ultra-init's authority (ralph has already finished, so there is no competing loop).
|
|
88
90
|
|
|
89
91
|
### Phase 4 — COMPLETE (완료 / 정리)
|
|
90
|
-
- `Skill("oh-my-claudecode:cancel")` → clear ralph / mode state cleanly (don't leave stale state files).
|
|
92
|
+
- `Skill("oh-my-claudecode:cancel")` → clear ralph / mode state cleanly (don't leave stale state files). On Codex banker's `ralph` sets no OMX mode: there is nothing to cancel for it.
|
|
91
93
|
- Emit a **concise, evidence-backed summary**: what was planned (plan path), stories delivered, files touched, reviewer verdict, deslop result, ralph-qa's verdict line with its gate block (per dimension), and the baseline failures that predate the change (the `baseline` gates and ralph's closeout list), listed apart.
|
|
92
94
|
- **Resuming an interrupted run:** the plan file in `.omc/plans/` and ralph's PRD (`.omc/state/sessions/<id>/prd.json`, with its stories' `passes` flags) are the durable record. Hand both to a new `ralph` run; for a new session, prepare it with `ready-compact --hand-off`, which must also carry the `<g>` path (the pre-change record) and the first ralph session id. On resume, Phase 3 judges against the iteration-0 records in `<g>`; never run iteration 0 again on the half-changed tree, and do not judge by the baseline a new ralph records there: either would excuse regressions made before the interruption. The same goes for a resume in the same session.
|
|
93
95
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* gate-evidence: a gate command run by this program, not by the model, and what it saw, as the evidence ralph-qa
|
|
4
|
-
* (--gates), ultraqa, all-in-one, ultra-init and ultra-chain judge by. The source of truth is
|
|
5
|
-
* skills/ralph-qa/references/gate-evidence.mjs; skills/ultraqa/references
|
|
6
|
-
* (scripts/sync-adapter.js).
|
|
4
|
+
* (--gates), ultraqa, the Codex ralph, all-in-one, ultra-init and ultra-chain judge by. The source of truth is
|
|
5
|
+
* skills/ralph-qa/references/gate-evidence.mjs; skills/ultraqa/references and codex/skills/ralph/scripts hold
|
|
6
|
+
* byte-identical copies (scripts/sync-adapter.js).
|
|
7
7
|
*
|
|
8
8
|
* node gate-evidence.mjs init [--cwd <repo>] the gate folder <g> of one ralph-qa run (ralph-qa-gates.<letters and digits>): made
|
|
9
9
|
* once before the baseline, kept across the iterations, apart from the payload folder seat-run.mjs makes anew
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* run-folder: the private run folders of ralph-qa and
|
|
4
|
-
* against. The source of truth is skills/ralph-qa/references/run-folder.mjs;
|
|
5
|
-
* byte-identical
|
|
3
|
+
* run-folder: the private run folders of ralph-qa, ultraqa and the Codex ralph, and the work tree snapshot their gate
|
|
4
|
+
* evidence is judged against. The source of truth is skills/ralph-qa/references/run-folder.mjs;
|
|
5
|
+
* skills/ultraqa/references and codex/skills/ralph/scripts hold byte-identical copies (scripts/sync-adapter.js).
|
|
6
6
|
*
|
|
7
7
|
* bases(ctx) { base, known }: this run's base, and every base a run of any version used
|
|
8
8
|
* sharedAbove(p) the nearest folder above p every account may write in, or null (POSIX)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: video-gen
|
|
3
|
+
description: "만들 수 있는 영상 목록을 보여 주고 고르게 한 뒤, 내용을 받지 않았으면 인터뷰해서 영상을 만듦. 내장 종류는 타이틀, 키네틱 타이포, 슬라이드 영상, 차트, 카운터, 이름 자막, 인용, 사진, 크레딧. 스킬 안의 WASM 엔진과 Noto 글꼴로 설치와 내려받기 없이 MP4 를 만듦. HTML 모션 그래픽, AI 3D 인트로, 모션 웹사이트는 해당 banker 스킬로 넘김. 'video-gen'/'영상 만들어'/'동영상 생성'/'소개 영상'/'모션 영상' 시 사용."
|
|
4
|
+
argument-hint: "[<종류>] [--dir <폴더>] [--scene <scene.json>] [--deck <deck.json>] [--size 1280x720|1920x1080|1080x1920|1080x1080]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# video-gen: 영상 목록, 인터뷰, MP4 생성
|
|
8
|
+
|
|
9
|
+
만들 수 있는 영상을 보여 주고, 고른 종류에 맞는 내용을 받아 MP4 를 만든다. 답변은 한글.
|
|
10
|
+
|
|
11
|
+
## 원칙
|
|
12
|
+
|
|
13
|
+
- **내장 종류는 설치와 내려받기 없이 모든 OS 에서 만든다.** 렌더 엔진(Skia CanvasKit WASM), H.264 MP4 인코더(WASM), 글꼴(Noto Sans, Noto Sans KR)이 모두 `vendor/` 에 있다. Chrome, ffmpeg, 네이티브 모듈, 인터넷을 쓰지 않는다.
|
|
14
|
+
- **글꼴은 한글 Noto Sans KR, 그 밖(영문, 숫자, 기호) Noto Sans 다.** 엔진이 글자마다 고른다. 글꼴이 파일 안에 있어 어느 PC 에서나 같게 나온다.
|
|
15
|
+
- **프레임은 시간만의 함수다.** 같은 장면 파일은 같은 영상이 되고, 정지 화면(`still`)은 `--at` 에 가장 가까운 영상 프레임과 같다(출력의 `FRAME` 줄이 그 번호).
|
|
16
|
+
- 사용자가 주지 않은 수치, 이름, 인용을 지어내지 않는다. 모자라면 묻는다. 물을 수 없으면 `[확인 필요]` 로 두고 보고한다.
|
|
17
|
+
- 있는 파일을 덮어쓰지 않는다. 같은 이름이 있으면 묻고, 허락을 받은 뒤에만 `--force` 를 준다.
|
|
18
|
+
|
|
19
|
+
## 런타임
|
|
20
|
+
|
|
21
|
+
| 항목 | Claude Code | Codex |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| 스킬 디렉터리 `<S>` | 스킬 맨 위 "Base directory for this skill:" 경로 | 이 SKILL.md 의 폴더. npm 설치는 `~/.codex/skills/banker-video-gen/`, 플러그인 설치는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/video-gen/`. 절대경로로 씀 |
|
|
24
|
+
| 부르기 | `/banker:video-gen` | `$banker-video-gen`, 플러그인 `$banker:video-gen` |
|
|
25
|
+
| 확인 질문 | `AskUserQuestion` | `request_user_input`. 없으면 번호 붙인 평문 선택지 |
|
|
26
|
+
| 샌드박스 | 없음 | 도우미는 작업 폴더 안에서만 읽고 써서 승인이 필요 없음. 작업 폴더 밖 사진은 `sandbox_permissions: "require_escalated"` 로 승인을 청하거나 작업 폴더로 복사해 씀 |
|
|
27
|
+
| 긴 렌더 | Bash 도구 `timeout` 을 넉넉히(최대 600000), 더 길면 `run_in_background` | `timeout_ms` 를 같은 값으로 |
|
|
28
|
+
|
|
29
|
+
물을 수 없는 실행(`codex exec`, 다른 스킬의 연쇄)에서는 목록과 인터뷰를 건너뛰고, 받은 자료로 고를 수 있는 종류 하나를 정해 만든다.
|
|
30
|
+
|
|
31
|
+
## 인자
|
|
32
|
+
|
|
33
|
+
| 인자 | 뜻 |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `<종류>` | 목록의 종류 id(예: `kinetic`, `slides`). 없으면 목록을 보여 주고 고르게 함 |
|
|
36
|
+
| `--dir <폴더>` | 출력 폴더. 없으면 지금 작업 폴더. 도우미의 `--out <파일>` 과 다름 |
|
|
37
|
+
| `--scene <scene.json>` | 이미 있는 장면 파일을 씀 |
|
|
38
|
+
| `--deck <deck.json>` | wide-pptx 덱을 슬라이드 영상으로 바꿈 |
|
|
39
|
+
| `--size` | 화면 크기. 기본 `1280x720`. 세로 영상은 `1080x1920` |
|
|
40
|
+
|
|
41
|
+
## 도우미
|
|
42
|
+
|
|
43
|
+
`G="<S>/scripts/video-gen.mjs"`. 머리 주석이 전체 사용법이다.
|
|
44
|
+
|
|
45
|
+
| 명령 | 하는 일 | 종료 코드 |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `node "$G" list` | 영상 목록(종류, 만드는 곳, 설명, 필요한 것) | 0 |
|
|
48
|
+
| `node "$G" example` | 예시 장면 파일(JSON). 틀로 씀 | 0 |
|
|
49
|
+
| `node "$G" from-deck --deck <deck.json>` | wide-pptx 덱을 장면 파일로 바꿔 stdout 에 냄. 원 차트는 막대로 바꾸고 경고 | 0, 파일 오류 2 |
|
|
50
|
+
| `node "$G" check --scene <scene.json>` | 검사하고 `OK 장면 N, 길이, 크기, fps, 프레임` 을 냄. 그림은 엔진으로 실제로 풀어 보고, 글은 내장 글꼴로 재서 화면을 넘을 곳을 `경고:` 로 냄 | 0, 장면 오류 1 |
|
|
51
|
+
| `node "$G" still --scene <scene.json> --at <초> --out <파일.png>` | 그 시각에 가장 가까운 영상 프레임 한 장. `--at` 이 영상 길이 이상이면 오류 | 0, 1, 2 |
|
|
52
|
+
| `node "$G" render --scene <scene.json> --out <파일.mp4>` | MP4 를 만듦. 진행은 stderr 에 1초마다. `--quality <10-51>`(기본 20, 작을수록 고화질) | 0, 1, 2 |
|
|
53
|
+
|
|
54
|
+
- 렌더는 CPU 로 한다. 1920x1080 은 1280x720 보다 두 배 넘게 걸린다. 처음 몇 초의 진행 줄로 남은 시간을 어림해 사용자에게 알린다.
|
|
55
|
+
|
|
56
|
+
## 영상 목록
|
|
57
|
+
|
|
58
|
+
`node "$G" list` 가 같은 표를 낸다.
|
|
59
|
+
|
|
60
|
+
| 종류 | 만드는 곳 | 필요한 것 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `title` 모션 타이틀 | 내장 | 제목, 부제 |
|
|
63
|
+
| `kinetic` 키네틱 타이포 | 내장 | 문장 1~6줄 |
|
|
64
|
+
| `slides` 슬라이드 영상 | 내장(`from-deck` 또는 장면별 글머리) | 덱 파일 또는 제목과 글머리 |
|
|
65
|
+
| `chart` 데이터 차트 | 내장 | 항목, 계열 값, 단위 |
|
|
66
|
+
| `counter` 숫자 카운터 | 내장 | 시작 값, 끝 값, 단위, 설명 |
|
|
67
|
+
| `lowerthird` 이름 자막 | 내장 | 이름, 직함, 배경 사진(선택) |
|
|
68
|
+
| `quote` 인용 | 내장 | 인용문, 출처 |
|
|
69
|
+
| `image` 사진 장면 | 내장 | 로컬 사진(PNG, JPEG, GIF, WebP), 캡션 |
|
|
70
|
+
| `credits` 엔딩 크레딧 | 내장 | 제목, 줄 목록 |
|
|
71
|
+
| `motion-graphic` HTML 모션 그래픽 | `motion-graphic-make` 스킬 | HyperFrames, Chrome, ffmpeg 가 있는 PC |
|
|
72
|
+
| `3d-intro` AI 3D 인트로 | `3d-intro-build` 스킬 | Azure 키(유료), 인터넷 |
|
|
73
|
+
| `motion-site` 모션 웹사이트 | `motion-sites` 스킬 | 주제와 내용 |
|
|
74
|
+
|
|
75
|
+
한 영상에 여러 종류를 장면으로 이어 붙일 수 있다(예: 타이틀, 차트, 크레딧).
|
|
76
|
+
|
|
77
|
+
## 장면 파일(scene.json)
|
|
78
|
+
|
|
79
|
+
| 칸 | 뜻 |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `title` | 영상 제목(보고용) |
|
|
82
|
+
| `size` | `1280x720`(기본), `1920x1080`, `1080x1920`, `1080x1080` |
|
|
83
|
+
| `fps` | 12~60 정수, 기본 30 |
|
|
84
|
+
| `theme` | `bg`(바탕), `fg`(글), `accent`(강조), `muted`(보조 글), `panel`, `ink`(자막 띠). 6자리 16진수 |
|
|
85
|
+
| `scenes` | 장면 목록. 모두 `duration`(0.5~60초, 기본 4)과 `transition`(`cut`, `fade`(기본), `wipe`, `stripes`)을 받음. 전체 600초까지 |
|
|
86
|
+
|
|
87
|
+
| type | 칸 |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `title` | `title`, `subtitle` |
|
|
90
|
+
| `kinetic` | `lines`(1~6줄) |
|
|
91
|
+
| `bullets` | `title`, `bullets`(7개까지 권장) |
|
|
92
|
+
| `chart` | `title`, `chart`(`kind`: bar, line / `labels` / `series`: `[{ "name", "values" }]` / `unit`). 값은 숫자만 |
|
|
93
|
+
| `counter` | `label`, `from`(기본 0), `to`, `unit` |
|
|
94
|
+
| `lowerthird` | `name`, `role`, `image`(배경, 선택) |
|
|
95
|
+
| `quote` | `quote`, `by` |
|
|
96
|
+
| `image` | `image`, `caption`, `motion`(`zoom` 기본, `pan`) |
|
|
97
|
+
| `credits` | `title`, `lines` |
|
|
98
|
+
|
|
99
|
+
사진 경로는 장면 파일 폴더 기준이다. URL 은 받지 않는다. 그림은 한 변 16384, 50MP 까지이고, 잘린 파일은 오류다.
|
|
100
|
+
들어오는 효과의 간격은 장면 길이에 맞춰 줄어, 장면이 끝나기 0.3초 전에는 모두 보인다. 빈 글 항목은 빼고 경고한다.
|
|
101
|
+
|
|
102
|
+
## 절차
|
|
103
|
+
|
|
104
|
+
### 1. 종류 고르기
|
|
105
|
+
- 인자에 종류가 없으면 `node "$G" list` 를 돌려 표를 보여 주고 확인 질문으로 고르게 한다. 질문 하나에 선택지는 4개까지라, 사용자의 말에 맞는 종류 3개와 "다른 종류" 를 낸다. 여러 종류를 이은 영상도 고를 수 있다고 알린다.
|
|
106
|
+
- 고른 것이 다른 스킬 종류(`motion-graphic`, `3d-intro`, `motion-site`)면 그 스킬로 넘기고 이 스킬은 끝낸다. `motion-graphic` 은 렌더 엔진이 없으면 내장 종류로 비슷하게 만들 수 있다고 함께 알린다.
|
|
107
|
+
|
|
108
|
+
### 2. 인터뷰
|
|
109
|
+
- 고른 종류의 "필요한 것" 가운데 받지 않은 것만 한 번에 묻는다(확인 질문 하나에 최대 4문항). 목적, 길이(초), 화면 크기(가로, 세로)도 정해지지 않았으면 함께 묻는다.
|
|
110
|
+
- 색과 장면 전환은 묻지 않고 기본값을 쓴다. 사용자가 색을 말하면 `theme` 에 넣는다.
|
|
111
|
+
- `slides` 에 덱 파일이 있으면 `from-deck` 로 바꾸고, 바뀐 장면 목록을 보여 준다.
|
|
112
|
+
|
|
113
|
+
### 3. 장면 파일 쓰기
|
|
114
|
+
- 출력 폴더에 `<이름>-scene.json` 으로 쓴다. 같은 이름이 있으면 묻는다.
|
|
115
|
+
- 장면 길이는 글을 읽을 시간을 준다(글머리 하나에 1.2초 안팎, 제목 3~4초).
|
|
116
|
+
- `node "$G" check --scene <파일>` 이 0 이 될 때까지 고친다. `경고:` 는 나누거나 줄여서 없앤다.
|
|
117
|
+
|
|
118
|
+
### 4. 미리 보기
|
|
119
|
+
- 장면마다 가운데 시각과 끝나기 0.1초 전 시각을 골라 `still` 로 PNG 를 만들고 본다(그림을 볼 수 있으면). 글이 넘치거나 겹치면 장면 파일을 고친다.
|
|
120
|
+
- 미리 보기 PNG 는 출력 폴더 안 `<이름>-preview/` 에 두고, 보고에 경로를 적는다.
|
|
121
|
+
|
|
122
|
+
### 5. 렌더
|
|
123
|
+
- `node "$G" render --scene <파일> --out <이름>.mp4` 를 돈다. 오래 걸리면 백그라운드로 돌리고 진행 줄을 본다.
|
|
124
|
+
- 종료 코드 2 에 `--force` 안내가 나오면 덮어쓸지 묻고, 허락을 받은 뒤에만 `--force` 를 더한다.
|
|
125
|
+
- 출력의 `OK ...` 줄에서 프레임 수가 장면 길이 x fps 와 같은지 본다.
|
|
126
|
+
|
|
127
|
+
## 보고
|
|
128
|
+
|
|
129
|
+
- 만든 MP4 와 미리 보기 PNG 의 절대경로, 길이, 크기, fps, 파일 크기
|
|
130
|
+
- 장면 목록(종류와 길이)
|
|
131
|
+
- 확인하지 못한 것(재생기로 직접 보지 않음 등), `[확인 필요]` 자리
|
|
132
|
+
- 다음 행동이 있으면 마지막 줄에. 예: "고칠 곳은 `<이름>-scene.json` 을 고치고 다시 부르십시오."
|
|
133
|
+
|
|
134
|
+
## 함정
|
|
135
|
+
|
|
136
|
+
- 소리는 넣지 않는다. 배경 음악은 영상 편집기에서 더한다.
|
|
137
|
+
- 사진은 20MB 까지, PNG, JPEG, GIF(첫 프레임), WebP 만. 휴대폰 사진의 회전 정보(EXIF)는 엔진이 적용한다.
|
|
138
|
+
- 엔딩 크레딧은 장면 끝에서 화면 위로 다 빠져나간다. 마지막 장면이면 영상 끝은 바탕색이다.
|
|
139
|
+
- 내장 글꼴에 없는 글자(일부 이모지, 드문 한자)는 빈 네모로 나온다.
|
|
140
|
+
- `vendor/` 의 파일은 고치지 않는다. 시험이 sha256 을 대조한다.
|
|
141
|
+
- 키네틱 기법 일부는 aimotion(kstost/cokacmotion, MIT)의 도우미 코드를 옮겨 썼다. 고지는 `vendor/aimotion/LICENSE.md`, 출처는 `scripts/vg-fx.mjs` 머리에 있다.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "샘플 대시보드 2차 개발 소개",
|
|
3
|
+
"size": "1280x720",
|
|
4
|
+
"fps": 30,
|
|
5
|
+
"theme": { "bg": "1F3864", "accent": "2E86C1" },
|
|
6
|
+
"scenes": [
|
|
7
|
+
{ "type": "title", "duration": 3.5, "title": "샘플 대시보드 2차 개발", "subtitle": "요건 확정부터 운영 배포까지", "transition": "cut" },
|
|
8
|
+
{ "type": "kinetic", "duration": 3.5, "lines": ["작은 개선이", "큰 차이를 만든다"], "transition": "stripes" },
|
|
9
|
+
{
|
|
10
|
+
"type": "bullets",
|
|
11
|
+
"duration": 5,
|
|
12
|
+
"title": "이번 개발의 범위",
|
|
13
|
+
"bullets": ["요건과 UI 확정", "API 리팩터링과 대시보드 웹", "계열사 지표(선택)"],
|
|
14
|
+
"transition": "fade"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "chart",
|
|
18
|
+
"duration": 5,
|
|
19
|
+
"title": "파트별 투입 영업일",
|
|
20
|
+
"chart": {
|
|
21
|
+
"kind": "bar",
|
|
22
|
+
"unit": "영업일",
|
|
23
|
+
"labels": ["기획", "데이터", "백엔드", "프론트", "AI"],
|
|
24
|
+
"series": [
|
|
25
|
+
{ "name": "필수", "values": [6, 4, 9, 9, 7] },
|
|
26
|
+
{ "name": "선택", "values": [0, 5, 0, 5, 0] }
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"transition": "wipe"
|
|
30
|
+
},
|
|
31
|
+
{ "type": "counter", "duration": 3, "label": "2차 개발 영업일", "from": 0, "to": 27, "unit": "일", "transition": "fade" },
|
|
32
|
+
{ "type": "lowerthird", "duration": 3.5, "name": "홍길동", "role": "기획팀장", "transition": "fade" },
|
|
33
|
+
{ "type": "quote", "duration": 4, "quote": "요건 확정 전에는 요건에 기대지 않는 일부터 한다", "by": "프로젝트 운영 원칙", "transition": "fade" },
|
|
34
|
+
{ "type": "credits", "duration": 5, "title": "함께한 사람들", "lines": ["기획 홍길동", "데이터 김철수", "엔지니어링 이영희", "백엔드 박민수", "프론트엔드 최지우", "AI 에이전트 정하늘"], "transition": "fade" }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// vg-catalog.mjs: what video-gen can make. Built-in kinds render here with the bundled WASM engine; the others
|
|
2
|
+
// hand over to another banker skill. `video-gen.mjs list` prints this table.
|
|
3
|
+
|
|
4
|
+
export const CATALOG = [
|
|
5
|
+
{ id: "title", name: "모션 타이틀", how: "내장", what: "제목과 부제가 강조 막대와 함께 들어오는 짧은 오프닝", needs: "제목, 부제(선택)" },
|
|
6
|
+
{ id: "kinetic", name: "키네틱 타이포", how: "내장", what: "글자가 한 자씩 튀어 오르며 문장이 완성되는 장면", needs: "문장 1~6줄" },
|
|
7
|
+
{ id: "slides", name: "슬라이드 영상", how: "내장", what: "wide-pptx 덱이나 글머리 목록을 장면으로 바꾼 설명 영상", needs: "덱 파일 또는 장면별 제목과 글머리" },
|
|
8
|
+
{ id: "chart", name: "데이터 차트", how: "내장", what: "막대가 자라거나 꺾은선이 그려지는 수치 영상", needs: "항목 이름, 계열 값, 단위" },
|
|
9
|
+
{ id: "counter", name: "숫자 카운터", how: "내장", what: "큰 숫자가 올라가며 강조되는 장면", needs: "시작 값, 끝 값, 단위, 설명" },
|
|
10
|
+
{ id: "lowerthird", name: "이름 자막", how: "내장", what: "인물 이름과 직함 띠가 들어왔다 나가는 장면(배경 그림 선택)", needs: "이름, 직함, 배경 그림(선택)" },
|
|
11
|
+
{ id: "quote", name: "인용", how: "내장", what: "따옴표와 인용문이 떠오르는 장면", needs: "인용문, 출처" },
|
|
12
|
+
{ id: "image", name: "사진 장면", how: "내장", what: "사진을 천천히 확대하거나 옆으로 흘리며 캡션을 붙인 장면", needs: "로컬 사진(PNG, JPEG, GIF, WebP), 캡션" },
|
|
13
|
+
{ id: "credits", name: "엔딩 크레딧", how: "내장", what: "제목과 이름이 아래에서 위로 흐르는 마무리", needs: "제목, 줄 목록" },
|
|
14
|
+
{ id: "motion-graphic", name: "HTML 모션 그래픽", how: "motion-graphic-make", what: "HyperFrames(HTML, CSS, GSAP) 블록으로 만드는 풍부한 모션 그래픽", needs: "HyperFrames 와 Chrome, ffmpeg 가 설치된 PC" },
|
|
15
|
+
{ id: "3d-intro", name: "AI 3D 인트로", how: "3d-intro-build", what: "Azure 영상 생성 모델로 만드는 3D 인트로", needs: "Azure 키(유료), 인터넷" },
|
|
16
|
+
{ id: "motion-site", name: "모션 웹사이트", how: "motion-sites", what: "영상이 아니라 스크롤로 움직이는 웹 페이지", needs: "주제와 내용" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** The catalog as a text table for the terminal. */
|
|
20
|
+
export function catalogText() {
|
|
21
|
+
const rows = CATALOG.map((c) => `${c.id.padEnd(15)} ${c.how.padEnd(20)} ${c.name} - ${c.what} (필요: ${c.needs})`);
|
|
22
|
+
return `${"종류".padEnd(15)} ${"만드는 곳".padEnd(20)} 설명\n${rows.join("\n")}\n`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// vg-deck.mjs: turns a wide-pptx deck (JSON) into a video-gen scene file, one scene per slide, so a presentation
|
|
2
|
+
// can also become a video. Slides that have no scene of their own become the closest one; nothing is invented.
|
|
3
|
+
import { isAbsolute, resolve, win32 } from "node:path";
|
|
4
|
+
|
|
5
|
+
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
6
|
+
const str = (v) => (typeof v === "string" ? v : typeof v === "number" ? String(v) : "");
|
|
7
|
+
const bulletText = (b) => str(typeof b === "object" && b ? b.text : b);
|
|
8
|
+
const HEX = /^#?[0-9A-Fa-f]{6}$/;
|
|
9
|
+
|
|
10
|
+
function columns(s) {
|
|
11
|
+
return [obj(s.left), obj(s.right)].flatMap((c) => {
|
|
12
|
+
const items = (Array.isArray(c.bullets) ? c.bullets : []).map(bulletText).filter(Boolean);
|
|
13
|
+
return c.heading ? items.map((x) => `${str(c.heading)}: ${x}`) : items;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tableRows(s) {
|
|
18
|
+
const head = Array.isArray(s.header) ? s.header.map(str) : [];
|
|
19
|
+
const rows = (Array.isArray(s.rows) ? s.rows : []).map((r) => (Array.isArray(r) ? r.map(str) : []));
|
|
20
|
+
return rows.map((r) => (head.length ? r.map((c, i) => (c && head[i] ? `${head[i]} ${c}` : c)) : r).filter(Boolean).join(" | ")).filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const bulletsScene = (title, items) => ({ type: "bullets", title, bullets: items, duration: Math.min(20, 2 + 1.2 * items.length) });
|
|
24
|
+
|
|
25
|
+
function chartScene(s, skipped, i) {
|
|
26
|
+
const c = obj(s.chart);
|
|
27
|
+
if (c.kind === "pie") skipped.push(`slides[${i}]: 원 차트는 막대 차트로 바꿈`);
|
|
28
|
+
return { type: "chart", title: str(s.title), chart: { kind: c.kind === "line" ? "line" : "bar", labels: c.labels, series: c.series, unit: c.unit }, duration: 6 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function imageScene(s, base) {
|
|
32
|
+
const p = str(s.image);
|
|
33
|
+
const abs = isAbsolute(p) || win32.isAbsolute(p) ? p : resolve(base, p);
|
|
34
|
+
return { type: "image", image: abs, caption: str(s.caption) || str(s.title), duration: 5 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MAP = {
|
|
38
|
+
title: (s) => ({ type: "title", title: str(s.title), subtitle: str(s.subtitle) || [str(s.presenter), str(s.date)].filter(Boolean).join(" | "), duration: 4 }),
|
|
39
|
+
section: (s) => ({ type: "title", title: str(s.title), subtitle: str(s.subtitle), duration: 3 }),
|
|
40
|
+
bullets: (s) => bulletsScene(str(s.title), (Array.isArray(s.bullets) ? s.bullets : []).map(bulletText).filter(Boolean)),
|
|
41
|
+
two: (s) => bulletsScene(str(s.title), columns(s)),
|
|
42
|
+
table: (s) => bulletsScene(str(s.title), tableRows(s)),
|
|
43
|
+
chart: chartScene,
|
|
44
|
+
image: (s, ...rest) => imageScene(s, rest[2]),
|
|
45
|
+
quote: (s) => ({ type: "quote", quote: str(s.quote), by: str(s.by), duration: 5 }),
|
|
46
|
+
closing: (s) => ({ type: "title", title: str(s.title), subtitle: str(s.subtitle), duration: 4 }),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function themeOf(t) {
|
|
50
|
+
const pick = (k, d) => (HEX.test(str(t[k])) ? str(t[k]).replace(/^#/, "").toUpperCase() : d);
|
|
51
|
+
return { bg: pick("primary", "1F3864"), accent: pick("accent", "2E86C1"), ink: pick("dark", "1B2631"), panel: pick("light", "F4F6F7"), fg: "FFFFFF", muted: "D6EAF8" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const blank = (x) => !String(x ?? "").trim();
|
|
55
|
+
const emptyScene = (s) => ({ bullets: () => !s.bullets.length, quote: () => blank(s.quote), title: () => blank(s.title) }[s.type]?.() ?? false);
|
|
56
|
+
|
|
57
|
+
/** Returns { scenes: <scene file object>, skipped: [notes] }. base is the deck's folder (image paths). */
|
|
58
|
+
export function deckToScenes(deck, base = process.cwd()) {
|
|
59
|
+
const d = obj(deck);
|
|
60
|
+
const skipped = [];
|
|
61
|
+
const scenes = (Array.isArray(d.slides) ? d.slides : []).map((raw, i) => {
|
|
62
|
+
const s = obj(raw);
|
|
63
|
+
const f = Object.prototype.hasOwnProperty.call(MAP, s.type) ? MAP[s.type] : null;
|
|
64
|
+
if (!f) {
|
|
65
|
+
skipped.push(`slides[${i}]: 알 수 없는 type(${str(s.type)}) 은 뺌`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const scene = { ...f(s, skipped, i, base), transition: i ? "fade" : "cut" };
|
|
69
|
+
return emptyScene(scene) ? (skipped.push(`slides[${i}]: 영상에 쓸 내용이 없어 뺌`), null) : scene;
|
|
70
|
+
}).filter(Boolean);
|
|
71
|
+
return { scenes: { title: str(d.title), size: "1280x720", fps: 30, theme: themeOf(obj(d.theme)), scenes }, skipped };
|
|
72
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// vg-draw.mjs: drawing helpers on a CanvasKit canvas for video-gen. A Painter holds the objects one render reuses
|
|
2
|
+
// (paints, laid-out paragraphs, decoded images) and frees them at the end, since CanvasKit memory is not collected.
|
|
3
|
+
import { FAMILIES } from "./vg-engine.mjs";
|
|
4
|
+
|
|
5
|
+
const PARA_CACHE = 600;
|
|
6
|
+
const IMAGE_CACHE = 4;
|
|
7
|
+
const rgb = (h) => [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
8
|
+
|
|
9
|
+
export class Painter {
|
|
10
|
+
constructor(CK, fonts, canvas, width, height) {
|
|
11
|
+
this.CK = CK;
|
|
12
|
+
this.fonts = fonts;
|
|
13
|
+
this.c = canvas;
|
|
14
|
+
this.W = width;
|
|
15
|
+
this.H = height;
|
|
16
|
+
// One unit is a pixel of a 1280 x 720 frame, scaled to fit this frame's shorter side.
|
|
17
|
+
this.u = Math.min(width / 1280, height / 720);
|
|
18
|
+
this.paint = new CK.Paint();
|
|
19
|
+
this.paint.setAntiAlias(true);
|
|
20
|
+
// Images get a paint of their own: the shape paint's colour alpha would fade them.
|
|
21
|
+
this.imagePaint = new CK.Paint();
|
|
22
|
+
this.imagePaint.setAntiAlias(true);
|
|
23
|
+
this.paras = new Map();
|
|
24
|
+
this.images = new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
color(h, a = 1) {
|
|
28
|
+
const [r, g, b] = rgb(h);
|
|
29
|
+
return this.CK.Color(r, g, b, Math.max(0, Math.min(1, a)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
clear(h) {
|
|
33
|
+
this.c.clear(this.color(h));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
rect(x, y, w, h, color, alpha = 1, radius = 0) {
|
|
37
|
+
if (w <= 0 || h <= 0 || alpha <= 0) return;
|
|
38
|
+
this.paint.setStyle(this.CK.PaintStyle.Fill);
|
|
39
|
+
this.paint.setColor(this.color(color, alpha));
|
|
40
|
+
const r = this.CK.LTRBRect(x, y, x + w, y + h);
|
|
41
|
+
if (radius > 0) this.c.drawRRect(this.CK.RRectXY(r, radius, radius), this.paint);
|
|
42
|
+
else this.c.drawRect(r, this.paint);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
circle(x, y, r, color, alpha = 1) {
|
|
46
|
+
if (r <= 0 || alpha <= 0) return;
|
|
47
|
+
this.paint.setStyle(this.CK.PaintStyle.Fill);
|
|
48
|
+
this.paint.setColor(this.color(color, alpha));
|
|
49
|
+
this.c.drawCircle(x, y, r, this.paint);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
line(points, color, width, alpha = 1) {
|
|
53
|
+
if (points.length < 2) return;
|
|
54
|
+
// CanvasKit 0.42 paths are immutable: build one, then detach it.
|
|
55
|
+
const b = new this.CK.PathBuilder();
|
|
56
|
+
b.moveTo(points[0][0], points[0][1]);
|
|
57
|
+
for (const p of points.slice(1)) b.lineTo(p[0], p[1]);
|
|
58
|
+
const path = b.detachAndDelete();
|
|
59
|
+
this.paint.setStyle(this.CK.PaintStyle.Stroke);
|
|
60
|
+
this.paint.setStrokeWidth(width);
|
|
61
|
+
this.paint.setStrokeJoin(this.CK.StrokeJoin.Round);
|
|
62
|
+
this.paint.setStrokeCap(this.CK.StrokeCap.Round);
|
|
63
|
+
this.paint.setColor(this.color(color, alpha));
|
|
64
|
+
this.c.drawPath(path, this.paint);
|
|
65
|
+
path.delete();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A laid-out paragraph: size in px, bold, color hex, width px, align left|center|right. Not cached. */
|
|
69
|
+
build(text, { size, bold = false, color = "FFFFFF", width = this.W, align = "left", lineHeight = 1.25 }) {
|
|
70
|
+
const CK = this.CK;
|
|
71
|
+
const style = new CK.ParagraphStyle({
|
|
72
|
+
textAlign: { left: CK.TextAlign.Left, center: CK.TextAlign.Center, right: CK.TextAlign.Right }[align],
|
|
73
|
+
textStyle: {
|
|
74
|
+
color: this.color(color), fontFamilies: FAMILIES, fontSize: size, heightMultiplier: lineHeight,
|
|
75
|
+
fontStyle: { weight: bold ? CK.FontWeight.Bold : CK.FontWeight.Normal },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const b = CK.ParagraphBuilder.MakeFromFontProvider(style, this.fonts);
|
|
79
|
+
b.addText(text);
|
|
80
|
+
const p = b.build();
|
|
81
|
+
b.delete();
|
|
82
|
+
p.layout(width);
|
|
83
|
+
return p;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A cached paragraph. The cache keeps the most recently used PARA_CACHE entries and frees older ones, so a long
|
|
88
|
+
* video does not run the WASM heap out (a frame uses far fewer).
|
|
89
|
+
*/
|
|
90
|
+
para(text, opts) {
|
|
91
|
+
const key = JSON.stringify([text, opts.size, !!opts.bold, opts.color, opts.width, opts.align, opts.lineHeight]);
|
|
92
|
+
let p = this.paras.get(key);
|
|
93
|
+
if (p) {
|
|
94
|
+
this.paras.delete(key);
|
|
95
|
+
} else {
|
|
96
|
+
p = this.build(text, opts);
|
|
97
|
+
while (this.paras.size >= PARA_CACHE) {
|
|
98
|
+
const [old, q] = this.paras.entries().next().value;
|
|
99
|
+
q.delete();
|
|
100
|
+
this.paras.delete(old);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.paras.set(key, p);
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Draws text at (x, y) top-left with alpha and an optional scale about its centre; returns { w, h }.
|
|
109
|
+
* transient text (a number that changes every frame) is laid out, drawn and freed, never cached.
|
|
110
|
+
*/
|
|
111
|
+
text(text, x, y, opts, { alpha = 1, scale = 1, transient = false } = {}) {
|
|
112
|
+
const p = transient ? this.build(text, opts) : this.para(text, opts);
|
|
113
|
+
const { w, h } = this.size(p, opts);
|
|
114
|
+
if (alpha > 0 && text) this.place(p, { x, y, w, h, pad: opts.size }, alpha, scale);
|
|
115
|
+
if (transient) p.delete();
|
|
116
|
+
return { w, h };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Draws a laid-out paragraph faded and scaled about its centre, in a layer bounded by its box. */
|
|
120
|
+
place(p, { x, y, w, h, pad }, alpha, scale) {
|
|
121
|
+
const cx = x + w / 2;
|
|
122
|
+
const cy = y + h / 2;
|
|
123
|
+
const box = [cx - (w / 2) * scale - pad, cy - (h / 2) * scale - pad, cx + (w / 2) * scale + pad, cy + (h / 2) * scale + pad];
|
|
124
|
+
this.layer(alpha, () => {
|
|
125
|
+
if (scale !== 1) {
|
|
126
|
+
this.c.translate(cx, cy);
|
|
127
|
+
this.c.scale(scale, scale);
|
|
128
|
+
this.c.translate(-cx, -cy);
|
|
129
|
+
}
|
|
130
|
+
this.c.drawParagraph(p, x, y);
|
|
131
|
+
}, box);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Width (the text's own for left-aligned text, else the box) and height of a laid-out paragraph. */
|
|
135
|
+
size(p, opts) {
|
|
136
|
+
const left = opts.align === "left" || !opts.align;
|
|
137
|
+
return { w: left ? p.getMaxIntrinsicWidth() : opts.width ?? this.W, h: p.getHeight() };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
measure(text, opts) {
|
|
141
|
+
const p = this.para(text, opts);
|
|
142
|
+
return { w: p.getMaxIntrinsicWidth(), h: p.getHeight() };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Runs draw() with everything it draws faded to alpha. Below 1 it draws into a layer, bounded by box
|
|
147
|
+
* [left, top, right, bottom] when given: an unbounded layer is the whole frame and costs that much per call.
|
|
148
|
+
*/
|
|
149
|
+
layer(alpha, draw, box) {
|
|
150
|
+
if (alpha >= 1) {
|
|
151
|
+
this.c.save();
|
|
152
|
+
draw();
|
|
153
|
+
this.c.restore();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const p = new this.CK.Paint();
|
|
157
|
+
p.setAlphaf(Math.max(0, alpha));
|
|
158
|
+
if (box) this.c.saveLayer(p, this.CK.LTRBRect(...box));
|
|
159
|
+
else this.c.saveLayer(p);
|
|
160
|
+
draw();
|
|
161
|
+
this.c.restore();
|
|
162
|
+
p.delete();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
clip(x, y, w, h, draw) {
|
|
166
|
+
this.c.save();
|
|
167
|
+
this.c.clipRect(this.CK.LTRBRect(x, y, x + w, y + h), this.CK.ClipOp.Intersect, true);
|
|
168
|
+
draw();
|
|
169
|
+
this.c.restore();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The image for a scene, decoded once and shrunk to at most 1.15 x the frame's longer side (sharp enough for the
|
|
174
|
+
* slow zoom, small in memory); only the most recent IMAGE_CACHE images are kept.
|
|
175
|
+
*/
|
|
176
|
+
decoded(img) {
|
|
177
|
+
let im = this.images.get(img.file);
|
|
178
|
+
if (im) {
|
|
179
|
+
this.images.delete(img.file);
|
|
180
|
+
this.images.set(img.file, im);
|
|
181
|
+
return im;
|
|
182
|
+
}
|
|
183
|
+
const full = this.CK.MakeImageFromEncoded(img.data);
|
|
184
|
+
if (!full) throw new Error(`그림을 풀지 못함: ${img.file}`);
|
|
185
|
+
im = this.shrink(full);
|
|
186
|
+
while (this.images.size >= IMAGE_CACHE) {
|
|
187
|
+
const [old, q] = this.images.entries().next().value;
|
|
188
|
+
q.delete();
|
|
189
|
+
this.images.delete(old);
|
|
190
|
+
}
|
|
191
|
+
this.images.set(img.file, im);
|
|
192
|
+
return im;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
shrink(full) {
|
|
196
|
+
// Down to the size it covers the frame at (plus room for the zoom), so a portrait or panorama is not enlarged again.
|
|
197
|
+
const k = Math.min(1, Math.max(this.W / full.width(), this.H / full.height()) * 1.15);
|
|
198
|
+
if (k >= 1) return full;
|
|
199
|
+
const w = Math.max(1, Math.round(full.width() * k));
|
|
200
|
+
const h = Math.max(1, Math.round(full.height() * k));
|
|
201
|
+
const s = this.CK.MakeSurface(w, h);
|
|
202
|
+
s.getCanvas().drawImageRectOptions(full, this.CK.LTRBRect(0, 0, full.width(), full.height()), this.CK.LTRBRect(0, 0, w, h), this.CK.FilterMode.Linear, this.CK.MipmapMode.Linear, null);
|
|
203
|
+
const small = s.makeImageSnapshot();
|
|
204
|
+
s.delete();
|
|
205
|
+
full.delete();
|
|
206
|
+
return small;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Draws an image to cover the box, zoomed by zoom about a point that drifts by pan (-1..1 of the slack). */
|
|
210
|
+
image(img, box, { zoom = 1, pan = 0, alpha = 1 } = {}) {
|
|
211
|
+
const im = this.decoded(img);
|
|
212
|
+
const k = Math.max(box.w / im.width(), box.h / im.height()) * zoom;
|
|
213
|
+
const w = im.width() * k;
|
|
214
|
+
const h = im.height() * k;
|
|
215
|
+
const x = box.x + (box.w - w) / 2 + ((w - box.w) / 2) * pan;
|
|
216
|
+
const y = box.y + (box.h - h) / 2;
|
|
217
|
+
this.clip(box.x, box.y, box.w, box.h, () => this.layer(alpha, () => {
|
|
218
|
+
const src = this.CK.LTRBRect(0, 0, im.width(), im.height());
|
|
219
|
+
this.c.drawImageRectOptions(im, src, this.CK.LTRBRect(x, y, x + w, y + h), this.CK.FilterMode.Linear, this.CK.MipmapMode.None, this.imagePaint);
|
|
220
|
+
}, [box.x, box.y, box.x + box.w, box.y + box.h]));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
free() {
|
|
224
|
+
for (const p of this.paras.values()) p.delete();
|
|
225
|
+
for (const im of this.images.values()) im.delete();
|
|
226
|
+
this.paras.clear();
|
|
227
|
+
this.images.clear();
|
|
228
|
+
this.paint.delete();
|
|
229
|
+
this.imagePaint.delete();
|
|
230
|
+
}
|
|
231
|
+
}
|