kasaframe 0.1.1 → 0.1.3
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/NOTICE +10 -8
- package/README.md +16 -1
- package/app/server/engine.ts +133 -15
- package/app/server/main.ts +7 -3
- package/app/web/main.ts +218 -36
- package/app/web/style.css +37 -4
- package/bin/hooks.mjs +14 -1
- package/bin/kf.mjs +20 -2
- package/docs/AGENTS.md +64 -16
- package/music/README.md +31 -7
- package/package.json +2 -2
- package/packages/adapters/src/project.ts +1 -1
- package/packages/check/src/check.ts +10 -3
- package/packages/check/src/rules/assets.ts +4 -1
- package/packages/check/src/rules/frames.ts +2 -1
- package/packages/check/src/rules/layout.ts +30 -6
- package/packages/cli/package.json +2 -1
- package/packages/cli/src/blur.ts +15 -3
- package/packages/cli/src/commands/bench.ts +123 -0
- package/packages/cli/src/commands/blender.ts +72 -49
- package/packages/cli/src/commands/direct.ts +210 -0
- package/packages/cli/src/commands/doctor.ts +9 -1
- package/packages/cli/src/commands/look.ts +9 -2
- package/packages/cli/src/commands/lyrics.ts +47 -1
- package/packages/cli/src/commands/make.ts +729 -150
- package/packages/cli/src/commands/music.ts +78 -0
- package/packages/cli/src/commands/narrate.ts +238 -0
- package/packages/cli/src/commands/new.ts +14 -5
- package/packages/cli/src/commands/render.ts +94 -60
- package/packages/cli/src/commands/series.ts +110 -0
- package/packages/cli/src/commands/setup.ts +42 -3
- package/packages/cli/src/commands/theme.ts +89 -2
- package/packages/cli/src/deps.ts +6 -6
- package/packages/cli/src/kf.ts +14 -3
- package/packages/cli/src/mix.ts +131 -0
- package/packages/cli/src/pool.ts +34 -0
- package/packages/cli/src/segments.ts +402 -0
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/project.ts +11 -0
- package/packages/director/package.json +23 -0
- package/packages/director/src/assemble.ts +361 -0
- package/packages/director/src/brief.ts +122 -0
- package/packages/director/src/catalog.ts +49 -0
- package/packages/director/src/content.ts +343 -0
- package/packages/director/src/fallback.ts +58 -0
- package/packages/director/src/index.ts +13 -0
- package/packages/director/src/pattern.ts +90 -0
- package/packages/director/src/patterns/fill/cells.ts +115 -0
- package/packages/director/src/patterns/formula/stack.ts +93 -0
- package/packages/director/src/patterns/util.ts +58 -0
- package/packages/director/src/scene.ts +62 -0
- package/packages/director/src/select.ts +163 -0
- package/packages/director/src/transitions.ts +115 -0
- package/packages/gen/PROVIDERS.md +39 -4
- package/packages/gen/src/ask.ts +132 -0
- package/packages/gen/src/audio.ts +91 -0
- package/packages/gen/src/blender.ts +54 -0
- package/packages/gen/src/capabilities.ts +151 -0
- package/packages/gen/src/generate.ts +4 -2
- package/packages/gen/src/image/codex.ts +117 -0
- package/packages/gen/src/index.ts +10 -2
- package/packages/gen/src/keys.ts +26 -0
- package/packages/gen/src/music/elevenlabs.ts +41 -0
- package/packages/gen/src/music/index.ts +120 -0
- package/packages/gen/src/music/library.ts +73 -0
- package/packages/gen/src/music/lyria.ts +52 -0
- package/packages/gen/src/music/types.ts +28 -0
- package/packages/gen/src/registry.ts +6 -3
- package/packages/gen/src/tts/dashscope.ts +64 -0
- package/packages/gen/src/tts/elevenlabs.ts +84 -0
- package/packages/gen/src/tts/gemini.ts +73 -0
- package/packages/gen/src/tts/index.ts +241 -0
- package/packages/gen/src/tts/local.ts +42 -0
- package/packages/gen/src/tts/types.ts +44 -0
- package/packages/gen/src/types.ts +4 -0
- package/packages/gen/src/video/h3.ts +5 -0
- package/packages/gen/src/video/http.ts +1 -1
- package/packages/jizura/src/index.ts +2 -2
- package/packages/jizura/src/install.ts +198 -155
- package/packages/jizura/src/layer.ts +75 -32
- package/packages/jizura/src/pin.ts +4 -1
- package/packages/jizura/src/vendor.ts +140 -0
- package/packages/jizura/src/vite.ts +34 -7
- package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
- package/packages/jizura/vendor/NOTICE +43 -0
- package/packages/jizura/vendor/engine.json +47 -0
- package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
- package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
- package/packages/jizura/vendor/fonts/fonts.json +52 -0
- package/packages/jizura/vendor/jizura_engine.js +35024 -0
- package/packages/motion/README.md +11 -0
- package/packages/motion/package.json +1 -0
- package/packages/motion/src/captions.ts +32 -24
- package/packages/motion/src/moves/brushWipe.ts +14 -1
- package/packages/motion/src/moves/dress.ts +191 -0
- package/packages/motion/src/moves/echoStack.ts +3 -3
- package/packages/motion/src/moves/fit.ts +36 -0
- package/packages/motion/src/moves/ghostStack.ts +14 -5
- package/packages/motion/src/moves/imageSwarm.ts +8 -4
- package/packages/motion/src/moves/index.ts +23 -4
- package/packages/motion/src/moves/karaokeLine.ts +17 -3
- package/packages/motion/src/moves/menu.ts +138 -0
- package/packages/motion/src/moves/neonSign.ts +2 -1
- package/packages/motion/src/moves/slamWords.ts +14 -7
- package/packages/motion/src/moves/sparkTrail.ts +16 -0
- package/packages/motion/src/moves/splitFlap.ts +4 -2
- package/packages/motion/src/moves/stepWords.ts +19 -8
- package/packages/motion/src/moves/tickerRows.ts +13 -9
- package/packages/motion/src/moves/tokenPicker.ts +4 -3
- package/packages/motion/src/moves/wordWall.ts +23 -21
- package/packages/motion/src/moves/worldRoll.ts +2 -1
- package/packages/motion/src/transition3d/index.ts +10 -3
- package/packages/motion/src/transition3d/rig.ts +107 -9
- package/packages/motion/src/transition3d/shatter.ts +5 -2
- package/packages/motion/src/transition3d/space.ts +173 -0
- package/packages/motion/src/transition3d/whip.ts +19 -6
- package/packages/motion/src/transition3d/world.ts +54 -0
- package/packages/motion/src/transitions2d/catalog.ts +1 -1
- package/packages/patterns/package.json +15 -0
- package/packages/patterns/src/bars/rank.ts +119 -0
- package/packages/patterns/src/before-after/shrink.ts +127 -0
- package/packages/patterns/src/before-after/strike.ts +125 -0
- package/packages/patterns/src/code/terminal.ts +133 -0
- package/packages/patterns/src/compare/bars.ts +142 -0
- package/packages/patterns/src/compare/duel.ts +103 -0
- package/packages/patterns/src/end/lockup.ts +85 -0
- package/packages/patterns/src/end/slam.ts +91 -0
- package/packages/patterns/src/flow/graph.ts +250 -0
- package/packages/patterns/src/hook-number/counter.ts +101 -0
- package/packages/patterns/src/hook-number/roll.ts +179 -0
- package/packages/patterns/src/hook-number/slam.ts +91 -0
- package/packages/patterns/src/index.ts +38 -0
- package/packages/patterns/src/kit.ts +449 -0
- package/packages/patterns/src/limit/gauge.ts +117 -0
- package/packages/patterns/src/list/cards.ts +131 -0
- package/packages/patterns/src/list/cycle.ts +22 -0
- package/packages/patterns/src/media/panels.ts +100 -0
- package/packages/patterns/src/objects.ts +234 -0
- package/packages/patterns/src/quote/karaoke.ts +126 -0
- package/packages/patterns/src/quote/slam.ts +22 -0
- package/packages/patterns/src/screen/focus.ts +85 -0
- package/packages/patterns/src/steps/path.ts +137 -0
- package/packages/patterns/src/steps/stack.ts +111 -0
- package/packages/patterns/src/term-reveal/chips.ts +92 -0
- package/packages/patterns/src/term-reveal/expand.ts +127 -0
- package/packages/patterns/src/timeline/axis.ts +144 -0
- package/packages/patterns/src/title/mask.ts +85 -0
- package/packages/patterns/src/title/slam.ts +23 -0
- package/packages/patterns/src/toggle-sim/switch.ts +92 -0
- package/packages/patterns/src/trend/columns.ts +108 -0
- package/packages/patterns/src/trend/draw.ts +140 -0
- package/packages/patterns/src/types.ts +67 -0
- package/packages/patterns/src/words/moves.ts +42 -0
- package/packages/patterns/src/words/slam.ts +4 -0
- package/packages/plan/src/asks.ts +256 -0
- package/packages/plan/src/brief.ts +136 -7
- package/packages/plan/src/captions.ts +5 -2
- package/packages/plan/src/index.ts +6 -2
- package/packages/plan/src/llm.ts +64 -2
- package/packages/plan/src/look.ts +3 -1
- package/packages/plan/src/plan.ts +67 -13
- package/packages/plan/src/series.ts +76 -0
- package/packages/plan/src/types.ts +53 -2
- package/packages/themes/package.json +5 -2
- package/packages/themes/src/index.ts +9 -0
- package/packages/themes/src/slotkit.ts +79 -0
- package/packages/themes/src/slots.ts +46 -0
- package/packages/themes/src/stage3d.ts +20 -0
- package/series/og-shorts.json +14 -0
- package/skills/kasaframe/SKILL.md +4 -3
- package/templates/_shared/project/lib/stage.ts +70 -50
- package/templates/_shared/scenes/default.ts +10 -7
- package/templates/_shared/scenes/drop.ts +11 -8
- package/templates/_shared/scenes/end.ts +9 -6
- package/templates/_shared/scenes/hook.ts +8 -6
- package/templates/_shared/scenes/step.ts +13 -4
- package/templates/ad/template.json +20 -1
- package/templates/demo/template.json +11 -0
- package/templates/explainer/template.json +54 -3
- package/templates/launch/template.json +34 -1
- package/templates/mv/scenes/default.ts +11 -8
- package/templates/mv/template.json +4 -2
- package/templates/promo/template.json +42 -1
- package/templates/shorts/scenes/hook.ts +8 -5
- package/templates/shorts/template.json +72 -3
- package/templates/square/template.json +21 -1
- package/themes/debug-hud/slots.ts +99 -0
- package/themes/og-measure/slots.ts +88 -0
- package/themes/riso-zine/slots.ts +81 -0
package/docs/AGENTS.md
CHANGED
|
@@ -8,14 +8,34 @@
|
|
|
8
8
|
kf make "카사텀 홍보 영상 1분, 카와이 퓨처베이스" [--music 곡.mp3] [--purpose promo] [--theme auto]
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
-
|
|
12
|
-
- **OG 모드**(`--model deepseek/deepseek-v4-pro` 같은 이름): 장면 작성·고치기까지 OG `chat/completions` 로 끝까지. 그림 검사는 `--vision-model <모델>` 을 줄 때만.
|
|
11
|
+
- **기본 `--model claude-code`**(Claude Code 안팎·작업실 어디서 부르든): 뼈대·곡 분석·장면 지시서 `MAKE.md` 까지 만들고 멈춘다. 다른 모델을 부르지 않는다. **`MAKE.md` 맨 위에 「사용자에게 먼저 물을 것」이 있으면 장면을 쓰기 전에 사람에게 한 번에 묻는다**(AskUserQuestion 한 번에 질문 여럿 — 적힌 `header` 를 칩으로, 보기 첫째가 권장) → 답을 `kf make --answer <폴더> --theme <값> --voice <값> --image <값> …` 로 넣으면 지시서가 고쳐진다(테마 답은 style.json·kf.json·장면 뼈대까지 바꾼다). 그다음 「만들 그림」·「주인공 3D 장면」·「영상 모델 컷」을 보고 네가 구체적인 프롬프트로 고른 공급자에 `kf gen img`·`kf blender`·`kf gen vid` 를 부른다(받은 자산·실제 화면·도형으로 되면 만들지 않는다). 장면을 다 채우고 `kf look <폴더>` 가 찍는 장면 모음·확인표를 직접 본 뒤 `kf make --finish <폴더>`(렌더·검사·표지·자막. 그림 보는 모델도 안 부르고 끝에 장면 모음·확인표 경로를 다시 찍는다).
|
|
12
|
+
- **OG 모드**(짚을 때만 — `--model deepseek/deepseek-v4-pro` 같은 이름): 장면 작성·고치기까지 OG `chat/completions` 로 끝까지. 그림 검사는 `--vision-model <모델>` 을 줄 때만. Opus·GPT 계열(`anthropic/claude-opus*`·`openai/gpt*`·`gpt-*`·`o3` 따위)은 `--allow-premium`(또는 `KF_ALLOW_PREMIUM=1`) 없이는 부르기 전에 막는다. 끝나면(성공·실패·중단) `<폴더>/make-stats.json` 에 단계별 모델·ms·토큰·usd 와 합계, 마지막 줄 「OG 호출 N번 · 입력/출력 토큰 · $X」.
|
|
13
13
|
- **그림 보기는 세션이 직접**: `kf look <폴더|mp4>` 가 장면 모음(`out/look-sheet.png`)과 장면별 확인표(`out/look.md` — 빈 화면·글 깨짐·대비·잘림·주인공 자리·할 일·테마)를 만들고 경로만 찍는다. 외부 모델을 부르지 않는다. **눈 없는 모델**(Claude Code 를 OG 로 딥시크 같은 모델에 붙였을 때 — OG `/v1/messages` 는 그림을 버려서 Read 해도 안 보인다)만 `kf look <폴더|mp4> --vision-model google/gemini-3.5-flash [--ask "<볼 것>"]` 로 그림 보는 모델이 장면마다 글로 보고한다(한 번 1센트 안팎, 묻지 않고 써도 된다). `kf make --finish` 도 기본은 그림 보는 모델 없이 끝에 장면 모음·확인표 경로를 찍는다.
|
|
14
|
-
- **곡**: 홍보·공개·뮤비는 곡이 끌고 간다. `--music` 을 안 주면 곡 모음(`KF_MUSIC_DIR`, 없으면 레포 `music/` — 곡마다
|
|
15
|
-
-
|
|
14
|
+
- **곡**: 홍보·공개·뮤비는 곡이 끌고 간다. `--music` 을 안 주면 곡 모음(`KF_MUSIC_DIR`, 없으면 레포 `music/` — 곡마다 원본·라이선스·분위기 태그는 `music/tags.json`·`README.md`. npm 패키지에는 곡이 없다)에서 요청 낱말과 맞는 곡을 고른다. 곡 모음이 비었으면 `--music` 으로 준다.
|
|
15
|
+
- **목소리·곡(쇼츠·설명)**: 차분한 TTS 내레이션 + 깊은 앰비언트 곡(`deep`)이 기본이다. 곡은 분위기 태그가 맞는 곡 모음 곡을 묻지 않고 고르고(같은 틀이면 늘 같은 곡), 없으면 생성(Lyria·Eleven Music, 돈)·곡 넣기·없음을 묻는다. 목소리는 늘 맨 위 질문으로 묻는다 — 보기 차례 로컬 Qwen3(`KF_QWEN3_TTS_URL` 이 있을 때)·ElevenLabs(키가 없으면 넣는 법)·자막만·그 밖 엔진(`kf narrate --voices`). 로컬도 ElevenLabs 도 없거나 생성 키가 없으면 다른 엔진·「자막만」·「곡 없이」로 넘어가지 말고 맨 위 질문대로 사람에게 묻는다(키 넣기가 권장). 묻지 않으려면 `--voice <엔진>:<목소리>|none`·`--music <곡>|gen:lyria-3.5|none`. 그다음은 아래 「내레이션 먼저」.
|
|
16
|
+
- **용도·테마**: 용도는 확신이 낮을 때만 묻는다. 테마는 요청이 「리소 느낌으로」처럼 짚었거나 시리즈가 정했으면 그대로, 아니면 맨 위 질문으로 묻는다 — 보기는 「알아서」(= `auto`: 내용에 맞는 approved 후보 셋 중 첫째, 첫 장면을 셋 다 찍어 `out/theme-<id>.png`)와 이 용도의 approved 테마 전부(생김새 한 줄·2D/3D·견본 `themes/_sheet/<id>.png` — 같은 문장을 테마마다 찍은 격자는 `kf theme sheet`). 답이 오기 전 뼈대는 auto 로 깔려 있다. 못 박으려면 `--purpose <용도>`·`--theme <id>`, 테마 없이 프리셋만이면 `--theme none`. **시리즈**(`kf series list` — 예 「OG 쇼츠」)를 짚으면 용도·테마보다 먼저 그 묶음(용도·테마·목소리·곡·자막·길이·끝 카드·생성 답)으로 묻지 않고 간다 — 같은 시리즈면 매번 같다. 잘 나온 프로젝트를 묶음으로: `kf series new <id> --from <폴더> --match "…"`. 같은 말이 같은 결과를 내는지는 `kf bench "<요청>" --runs 3`. 묻는 것은 용도 확신이 낮을 때뿐(종료 코드 3).
|
|
16
17
|
- **작업실**: `kf app` 이 떠 있으면 `kf make` 가 거기에 등록하고 주소를 찍는다 — 사람은 페이지에서 실시간 미리보기·지난 작업을 본다.
|
|
17
18
|
- **Claude Code 스킬**: `kf skill install` 하면 「영상 만들어」 같은 말에 kasaframe 으로 오는 스킬이 깔린다(`skills/kasaframe/SKILL.md`).
|
|
18
|
-
-
|
|
19
|
+
- **생성 능력**(`kf doctor` 「생성 능력」): 계획은 그림 컷·주인공 3D 장면(Blender 툰 샷, 없으면 three.js)·이음매 3D 전환(three)·캐릭터가 실제로 움직이는 컷(영상 모델 — 요청에 캐릭터와 몸짓이 있거나 뮤비일 때)·곡·내레이션을 기본으로 넣는다. **묻는 것은 돈이 들거나 없는 능력뿐**이고 나머지는 틀·테마가 정한 기본값으로 간다 — 같은 말이면 같은 결과. **없는 능력은 조용히 대체하지 않는다** — 「키·도구 넣는 법(권장) · 지금 되는 다른 엔진 · 빼기(자막만·곡 없이)」를 사람에게 묻고 고른 값을 플래그로 넣는다. 네가 대신 길을 고르지 않는다(`--yes` 로도 안 넘어간다). 키는 `kf setup key <이름>` 이 숨겨 받는다. 묻지 않고 가려면 `--yes`(권장 답 — 돈도 쓴다) · `--no-gen`(돈 안 드는 길) · `--allow image,video,…`(목록 능력만 권장, 나머지는 돈 안 드는 길) · `--image`·`--video`·`--blender`·`--voice`·`--music <값>`(그 질문에 미리 답). OG 모드는 뼈대 전에 터미널에서 묻고, 터미널이 아니면 보기를 찍고 종료 코드 3.
|
|
20
|
+
- **H3**(사내 파드 `h3-pod`)는 0원이지만 MiniMax 공개 가중치가 한국을 제외 지역으로 둔다 — 회사·바깥 결과물엔 쓰지 않는다. 보기에 경고가 붙고 `--yes` 로는 골라지지 않으며, 쓰면 자산 메타·`out/<영상>.credits.json`·`kf check` 의 `asset.restrict` 에 남는다. API 판 `minimax-h3` 는 전 세계 가능.
|
|
21
|
+
|
|
22
|
+
### 감독 모드(기본) — 장면 코드 대신 `content.json`
|
|
23
|
+
|
|
24
|
+
뮤비가 아니면 `kf make` 는 감독 모드다. **너는 `content.json` 하나만 쓴다** — 장면마다 무엇을 보일지(type·칸)와 들릴 말(say)·출처(source). 화면 문법·움직임·전환·카메라·바탕은 감독이 검증된 패턴 중에서 고른다(같은 content.json·씨앗이면 바이트까지 같은 장면). 칸 표는 `MAKE.md` 「content.json 꼴」이 정본(계약 11).
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
kf make "OG 쇼츠 — MLA 가 뭔지 45초" # 뼈대·묻기·MAKE.md(content.json 쓰는 법)까지
|
|
28
|
+
# <폴더>/content.json 을 쓴다 — {"title", "scenes": [{"id", "type": "hook-number", "value": 57, "unit": "×", "label": "…", "say": "…", "source": "…"}, …]}
|
|
29
|
+
kf direct <폴더> # 칸 검사 → 내레이션으로 길이 → 패턴 고르기·장면 파일 → kf check(떨어진 장면만 다음 후보로)
|
|
30
|
+
kf look <폴더> # 장면 모음을 직접 본다 — 마음에 안 들면 content.json 을 고치거나 --seed <글>
|
|
31
|
+
kf make --finish <폴더>
|
|
32
|
+
kf direct content.json # 폴더 없이 바로 — content.json 옆 같은 이름 폴더에 뼈대부터
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- type: `hook-number`·`compare`·`bars`·`steps`·`before-after`·`timeline`·`flow`·`fill`·`formula`·`term-reveal`·`limit`·`toggle-sim`·`list`·`quote`·`end`·`title`·`words`·`trend`·`code`·`screen`·`media`·`custom`. 숫자는 `value`·`unit` 따로, 숫자가 있으면 `source` 꼭, 문장은 `say` 로(화면 칸은 낱말·숫자).
|
|
36
|
+
- 칸이 틀리면 `kf direct` 가 `scenes[2].total: 없다 — …` 처럼 짚고 멈춘다. 고를 패턴·까닭은 `director.json`.
|
|
37
|
+
- 장면 하나만: `kf direct <폴더> --scenes b` · 후보를 사람이 고르기 `--pattern b=<패턴 id>` · 그 장면만 다른 그림 `--scene-seed b=<글>`(둘 다 director.json `pinned` 에 남는다).
|
|
38
|
+
- 패턴에 없는 화면은 `{"type": "custom", "idea": "…"}` — kf direct 가 뼈대만 두고 그 파일은 네가 쓴다(아래 3장). 감독 없이 처음부터 장면 코드를 쓰려면 `kf make … --no-direct`.
|
|
19
39
|
|
|
20
40
|
**규칙 — 단 0.1초도 쉬지 않는다**(검사가 오류로 잡는다. 용도마다 틀이 기준을 정한다 — 아래 표, 이 영상의 값은 MAKE.md 첫머리): ① 0.1초(60fps 6프레임) 넘게 화면이 안 바뀌면 오류 — 끝 카드·큰 숫자·로고도 예외 없음 ② 같은 구도가 표의 초를 넘으면 경고·오류 — 새 요소·칸 채우기·숫자 키우기·컷·전환 ③ 미세한 흔들림 말고 눈에 보이는 큰 움직임 — 표의 자리에서 박마다 하나 이상 ④ 주 요소가 화면을 채운다(세로는 폭 70% 넘게). 쉬지 않는 몫은 **화면 안 움직임**이 진다 — 카메라는 이유가 있을 때만 움직인다(3장 「카메라」). `kf new` 가 넣어 주는 `lib/stage.ts`(`makeStage`·`makeCamera`·`bigWord`·`cutTimes`·`lastT`)가 이걸 처음부터 넘게 짜 둔 무대다 — 장면을 하나하나 고치기 전에 겉에 씌워라.
|
|
21
41
|
|
|
@@ -76,6 +96,7 @@ export default {
|
|
|
76
96
|
};
|
|
77
97
|
```
|
|
78
98
|
|
|
99
|
+
- **연출 수는 뼈대에 이미 걸려 있다**: `kf new` 가 장면마다 바탕·글·경계 덮기(`DRESS`)를 용도·테마 묶음에서 골라 넣는다(같은 요청이면 같은 수). 수는 두고 `WORDS` 와 내용을 바꾼다 — 바꿀 이름은 `MAKE.md` 「연출 수」, 수만 따로 본 예시는 `examples/moves/`.
|
|
79
100
|
- `t` 는 **장면 안 초**(장면 시작 = 0). 반환 함수는 **같은 t 면 같은 화면** — 앞 프레임 상태를 쌓지 않는다.
|
|
80
101
|
- DOM 은 `build` 에서 한 번 짓고, `(t) => …` 에서는 글·스타일만 바꾼다.
|
|
81
102
|
- 박자는 `ctx.beats`(`drop(0)`·`nearestBeat`·`phrase`·`inDrop`), 그림은 `ctx.asset("x.png")`, 글은 `ctx.safe` 안에.
|
|
@@ -94,7 +115,7 @@ export default {
|
|
|
94
115
|
기본은 **정지**, 주인공은 안전 영역 가운데. 화면 전체를 사인으로 흔들거나 박마다 자세를 바꾸면 격자가 기울고 주인공이 가장자리로 밀린다 — `kf check` 가 `camera.wander`(이유 없는 카메라 이동)·`layout.edge`(주인공이 안전 영역 가장자리 4% 띠에) 로 경고한다. 카메라는 장면이 이유를 댈 때만:
|
|
95
116
|
|
|
96
117
|
```ts
|
|
97
|
-
const stage = makeStage(root, ctx); //
|
|
118
|
+
const stage = makeStage(root, ctx); // 정지 바탕(still) + 정지 카메라 — stage.say(낱말) 이면 글 수 + 박마다 차는 강조 띠
|
|
98
119
|
stage.camera
|
|
99
120
|
.pushIn(2.0, { to: answer }) // 드러냄에 다가가기 — 요소 상자를 가운데로, 안전 영역 86% 안에 들게 배율을 누른다
|
|
100
121
|
.pullOut(5.5) // 비교 전체를 보이게 물러나기 — 기본은 처음 구도. 더 넓게는 { scale: 0.9 }
|
|
@@ -119,21 +140,42 @@ return (t: number) => {
|
|
|
119
140
|
- seek 모드의 CSS 애니메이션·트랜지션은 0 초에 시작한 것으로 본다 — 장면 가운데서 시작할 것은 `animation-delay` 로 박거나 움직임 부품으로.
|
|
120
141
|
- 영상: `<video src data-kf-from="<영상 전체 기준 초>" muted>` — seek 마다 맞춘다. 소리는 `kf.json` 의 `music` 만 얹힌다.
|
|
121
142
|
- Lottie·GSAP·three.js: `@kasaframe/adapters` 의 `bindLottie(anim, { from })` · `bindGsap(gsap)` · `bindThree({ renderer, scene, camera, update(t) })`.
|
|
122
|
-
- **Blender 툰 3D**(카메라가 공간을 지나가는 샷·박에 맞춰 움직이는 물체): `kf blender <샷.py> --project <폴더> --scene <id>` 가 헤드리스 Blender 로 굽어 `assets/blender/<id>.mp4` 를 두고 `<video data-kf-from>` 값을 찍어 준다. 샷은 `tools/blender/kfb.py` 헬퍼로 — `update(t)` 가 t 만 보고 자세를 정하고, 카메라 열쇠는 `S.downbeats`·`S.drops` 에(`Track(..., move=…)` 로 도착하면 선다). 굽기 전에 `--still 1.2,4.5` 로 몇 장 본다. 예시·쓰는 법: `tools/blender/README.md`(Blender 4.3 이상, GPL-3.0 폴더).
|
|
143
|
+
- **Blender 툰 3D**(카메라가 공간을 지나가는 샷·박에 맞춰 움직이는 물체): `kf blender <샷.py> --project <폴더> --scene <id>` 가 헤드리스 Blender 로 굽어 `assets/blender/<id>.mp4` 를 두고 `<video data-kf-from>` 값을 찍어 준다. 샷은 `tools/blender/kfb.py` 헬퍼로 — `update(t)` 가 t 만 보고 자세를 정하고, 카메라 열쇠는 `S.downbeats`·`S.drops` 에(`Track(..., move=…)` 로 도착하면 선다). 굽기 전에 `--still 1.2,4.5` 로 몇 장 본다. 앞뒤 2D 장면과 공간을 이으려면 `--match` 로 굽고 샷에서 `kfb.match_start`·`match_end`(첫·끝 프레임을 그 2D 화면과 투영 일치)·`rise_block`(주인공 칸이 같은 자리에서 3D 로 선다) — 맞춘 경계는 컷이어도 이어진다. 맞추지 않은 2D↔3D 경계는 이음매 `rise`(2D→3D)·`land`(3D→2D). 예시·쓰는 법: `tools/blender/README.md`(Blender 4.3 이상, GPL-3.0 폴더).
|
|
123
144
|
- 글꼴·`<img>`·CSS `background-image`·영상은 받을 때까지 기다린다. 더 기다릴 것이 있으면 `definePage({ ready: async () => … })`.
|
|
124
145
|
- **이미 있는 React 컴포넌트**는 `lib/react.ts` 의 `bit(host, Comp)` 로 — 장면이 처음 그려질 때 붙인다(`build` 에서 바로 붙이면 장면이 숨은 동안 등장 애니메이션이 끝나 버린다). 컴포넌트가 쓰는 npm 패키지는 프로젝트 폴더에 `package.json` 을 두고 `npm i <패키지> --legacy-peer-deps`. **react·react-dom 은 프로젝트에 깔지 않는다**(레포 것 한 벌로 묶인다 — 두 벌이면 훅이 깨진다). 새 의존을 넣은 첫 실행은 Vite 가 다시 묶으며 한 번 새로 고칠 수 있다 — 두 번째부터 본다. 자기 시계로 도는 컴포넌트면 `"mode": "step"`(대신 「같은 프레임 두 번 찍기」 검사는 못 한다).
|
|
125
146
|
- **이음매 장면**(`role: "seam"`)은 고치지 않는다 — 틀의 3D 전환(`transition3d`)이 앞 장면 마지막 화면에서 뒤 장면 첫 화면으로 잇는다. 앞뒤 화면 그림은 `kf make --finish` 가 장면을 다 쓴 뒤 찍는다.
|
|
126
147
|
- **되돌이**(쇼츠·피드처럼 끝이 처음으로 이어질 때): 렌더가 마지막으로 찍는 장면 안 시각은 `ctx.dur` 이 아니라 `ctx.dur - 1/fps`(`lastT(ctx)`) — 움직임을 거기서 끝내야 끝 화면과 첫 화면이 픽셀까지 같다.
|
|
127
148
|
|
|
149
|
+
### 내레이션 먼저 — 쇼츠·설명
|
|
150
|
+
|
|
151
|
+
말이 장면 길이를 정한다. 장면을 쓰기 **전에**:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
# 1. captions.json — 장면마다 들릴 말(차분한 설명체, 마디는 쉼표, 문장은 마침표, 1초에 4–5글자, 훅은 한 마디, 끝·이음매는 비워도 된다)
|
|
155
|
+
[{"scene": "hook", "text": "모든 낱말이, 서로를 봅니다."}, {"scene": "setup", "text": "..."}]
|
|
156
|
+
# 2. 읽혀서 장면 길이를 목소리에 맞춘다(곡이 있으면 다음 박에) — 같은 문장·목소리는 캐시라 다시 불러도 0원
|
|
157
|
+
kf narrate <폴더> [--voice elevenlabs:George] [--music <곡>|gen:lyria-3.5|none]
|
|
158
|
+
kf narrate --voices # 쓸 수 있는 엔진·목소리
|
|
159
|
+
kf narrate --say "한 문장" --voice elevenlabs:George # 한 문장만 들어 본다 → say.wav
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- 장면 길이 = 앞 숨(틀 `narration.lead`) + 말 + 뒤 숨(`tail`). 요청에 길이(「30초」)가 있으면 빠르기를 **0.9–1.1 안에서만** 맞추고 남는 시간은 뒤 숨으로 편다 — 그래도 길면 「대본을 줄이면 맞는다」고 알려 준다. 대본을 줄인다(빠르기를 더 올리지 않는다).
|
|
163
|
+
- `kf.json` 장면 시각이 바뀐다 — 장면은 그 뒤에 쓰고 `ctx.dur` 로 짠다. 문장을 고치면 `kf narrate` 를 다시.
|
|
164
|
+
- `captions.json` 줄마다 `words`(영상 기준 초)가 생긴다 — 그 낱말이 들리는 순간에 같은 낱말·도식이 서게 맞춘다(`w.from - ctx.from` 이 장면 안 시각, `import.meta.glob("../captions.json", { eager: true, import: "default" })`). 화면 글 정책은 그대로 — 문장은 소리·자막으로 가고 화면엔 전문 낱말만.
|
|
165
|
+
- 섞기는 `kf render` 가 한다: 말할 때 곡은 목소리보다 20dB 안팎 아래(덕킹), 끝 1.5초 페이드, -14 LUFS(틀 `audio`). 소리를 따로 만지지 않는다.
|
|
166
|
+
- `kf make --finish` 가 `kf narrate` 를 안 거친 프로젝트를 만나면 먼저 부른다(문장이 없으면 곡만 깐다).
|
|
167
|
+
- 곡 모음: `kf music list --mood deep` · 생성 곡을 모음에 넣기 `kf music gen --mood deep --dur 60 --add`(원본·라이선스가 태그에 남는다).
|
|
168
|
+
|
|
128
169
|
### 뮤비 가사 — `kf lyrics align` · JIZURA 가사 층
|
|
129
170
|
|
|
130
171
|
```
|
|
131
|
-
npx kf setup
|
|
172
|
+
npx kf setup lyrics # 한 번만 — 정렬 꾸러미(2GB). 가사 층 엔진·자주 쓰는 한글 글꼴은 실려 있다
|
|
132
173
|
npx kf lyrics align 곡.mp3 --text 공식가사.txt --lang ko --out <폴더>/lyrics.json [--from 초 --duration 초]
|
|
133
174
|
```
|
|
134
175
|
|
|
135
176
|
- 글자는 공식 가사(한 줄 = 가사 한 줄)가 정본이고 정렬은 시각만 붙인다 — 받아쓰기 글자를 화면에 쓰지 않는다. 곡 일부만 쓰면 `kf beats` 와 같은 `--from` 을 준다(시각의 0 이 같아야 한다). 첫 실행은 whisper 모델(1.6GB)을 받는다.
|
|
136
|
-
- `kf new --template mv` 의 장면은 프로젝트 뿌리의 `lyrics.json` 을 JIZURA 가사 층으로 그린다 — 벌스는 차분한 플랜, 드롭(`ctx.beats.inDrop`)부터 큰 플랜.
|
|
177
|
+
- `kf new --template mv` 의 장면은 프로젝트 뿌리의 `lyrics.json` 을 JIZURA 가사 층으로 그린다 — 벌스는 차분한 플랜, 드롭(`ctx.beats.inDrop`)부터 큰 플랜. `lyrics.json` 이 없으면 부른 낱말을 DOM 으로 띄운다.
|
|
178
|
+
- 가사 층 글꼴: 자주 쓰는 한글 얼굴은 실려 있고, 나머지 얼굴·한자·가나는 처음 굽거나 볼 때 받는다. 못 받으면 다른 글꼴로 대신 그리지 않고 `kf render`·`kf check` 가 끝에 묻는다(종료 3) — **사람에게 묻고**(네가 대신 고르지 않는다) 답을 `kf lyrics use <폴더> wait|builtin|words` 로 넣은 뒤 같은 명령을 다시. 오프라인으로 구울 기계면 `kf setup jizura` 로 미리 받아 둔다.
|
|
137
179
|
- 직접 쓸 때: `jizuraPlans({ day: { style: "ocean", schemes: "light" } })` 로 플랜을 이름 짓고 `const paint = jizuraLayer(root, { ctx, plan: "day" })` → `(t) => paint(t)`. 같은 설정의 플랜은 장면끼리 나눠 쓴다. 밝은 바탕엔 `schemes: "light"`(어두운 글), 밤 장면엔 `"dark"`, 주인공 얼굴이 요점이면 `centerFree: true`(가운데를 비운다). 분위기는 `moods: ["calm", "emotional", "editorial"]` 처럼 부품 태그로 거른다.
|
|
138
180
|
- 화면을 덮는 부품(불투명 판·카드 카메라·화면 전체 섬광)은 기본 거부 목록(`DENY`)이 막는다. 더 막을 것은 `deny: { layout: ["…"] }`. **거부 목록·`moods`·`seed` 를 바꾸면 그 플랜의 컷이 전부 다시 굴려진다** — 스틸을 다시 본다. 어느 부품인지는 `jizuraCuts({ ctx, plan })` 로 그 시각의 컷(layout·enter·hold·exit·decor·cam)을 본다.
|
|
139
181
|
- 가사 층 글은 검사에서 장면 글과 따로 묶인다. 띠·배경 큰 글자처럼 일부러 화면 끝을 넘기는 장식은 `text.offscreen` 경고로만 나온다 — 읽혀야 할 가사 줄이 잘리면 그 부품을 막는다.
|
|
@@ -147,7 +189,7 @@ npx kf lyrics align 곡.mp3 --text 공식가사.txt --lang ko --out <폴더>/lyr
|
|
|
147
189
|
- **크기를 className CSS 로 받는 컴포넌트**(캔버스 등)는 그대로 넣으면 300×150 — 장면에 `<style>` 로 그 클래스 크기를 준다.
|
|
148
190
|
- **스크린리더 전용 글**(1px·clip 으로 숨긴 span)은 검사가 대비·화면 밖 판정에서 뺀다. 그래도 걸리면 그 클래스를 `display:none`.
|
|
149
191
|
- **`build` 에서 장면이 숨어 있다**(앞 장면 차례엔 `display:none`) — 그때 잰 크기는 0 이다. 크기는 글자 수로 어림하거나(`fitPx`) 처음 그려질 때 잰다.
|
|
150
|
-
- **0.1초 규칙** — 끝 카드·큰 숫자 화면이 제일 먼저 걸린다. 구도는 칸 평균 밝기와 화면 안 내용(글·칸의 센 가장자리 지도) 둘로 본다 — 숫자가 구르고 칸이 차고 줄이 붙으면 바뀐 것으로
|
|
192
|
+
- **0.1초 규칙** — 끝 카드·큰 숫자 화면이 제일 먼저 걸린다. 구도는 칸 평균 밝기와 화면 안 내용(글·칸의 센 가장자리 지도) 둘로 본다 — 숫자가 구르고 칸이 차고 줄이 붙으면 바뀐 것으로 친다(바탕은 정지 — 뜻 없이 흐르는 장식으로 메우지 않는다). 컷(`cutTimes`)마다 화면 안 내용을 바꾼다(새 줄·칸 채우기·숫자 키우기·바탕 판).
|
|
151
193
|
|
|
152
194
|
## 4. 보기 — `kf preview` · `kf still` · `kf sheet`
|
|
153
195
|
|
|
@@ -166,13 +208,17 @@ kf look <폴더> --vision-model google/gemini-3.5-flash # 그림을 못 보는
|
|
|
166
208
|
## 5. 굽기 — `kf render`
|
|
167
209
|
|
|
168
210
|
```
|
|
169
|
-
kf render <폴더> [--tabs 2] # → out/render.mp4 + out/render.json(markers·움직임 기록)
|
|
211
|
+
kf render <폴더> [--tabs 2] # → out/render.mp4 + out/render.json(markers·움직임 기록) — 장면 조각으로, 바뀐 장면만 다시 찍는다
|
|
212
|
+
kf render <폴더> --scenes hook # 그 장면 조각만(out/segments/hook-<해시>.mp4) — 잇기는 다음 kf render
|
|
170
213
|
kf render <폴더> --from 10 --to 14 --no-lock # 고친 구간만 빨리
|
|
214
|
+
kf render <폴더> --motion-blur 8 [--shutter 180] # 한 프레임을 하위 프레임 8장 평균 — 빠른 것만 번진다(seek 모드만, 그만큼 느리다)
|
|
171
215
|
```
|
|
172
216
|
|
|
217
|
+
쇼츠·홍보 틀은 `kf.json` 의 `render.motionBlur` 로 모션 블러를 기본으로 켠다 — 끌 땐 `--motion-blur 0`.
|
|
218
|
+
|
|
173
219
|
### 자막 켜고 끄기
|
|
174
220
|
|
|
175
|
-
들릴 말은 장면 파일이 아니라 `captions.json` 에 쓴다 — `[{"scene": "<id>", "text": "짧은 마디를 쉼표로"}]`(`from`·`to` 초나 `words` 낱말 시각도 된다). `kf.json` 의 `captions` 가 `off`(쇼츠·설명 기본 — srt·vtt 만) · `on`(화면에 얹고 srt·vtt 도) · `none`. 화면 자막은 엔진이 장면 위에 판을 얹는다(안전 영역 아래쪽 가운데, 테마 글꼴, 어절 단위 두 줄까지, 세로 화면은 폭의 8% 크기) — 장면에 문장을 쓰지 않는다. 켜면 `ctx.safe` 아래쪽에서 자막 띠(판 두 줄 높이)를 뺀다 — 무대(`makeStage`)처럼 `ctx.safe` 로 배치하는 장면은 제 크기 그대로 띠 위로 올라간다. 픽셀을 박아 둔 장면은 안 움직여 `kf check` 가 `caption.overlap` 으로 잡는다 — 글을 `ctx.safe` 안으로 옮기거나, 장면을 못 고치면 `--captions fit`(장면 전체를 띠 위로 줄여 올린다 — 안 겹치지만 내용이 작아지고 움직이는 면적도 준다).
|
|
221
|
+
들릴 말은 장면 파일이 아니라 `captions.json` 에 쓴다 — `[{"scene": "<id>", "text": "짧은 마디를 쉼표로"}]`(`from`·`to` 초나 `words` 낱말 시각도 된다). `kf.json` 의 `captions` 가 `off`(쇼츠·설명 기본 — srt·vtt 만) · `on`(화면에 얹고 srt·vtt 도) · `none`. 화면 자막은 엔진이 장면 위에 판을 얹는다(안전 영역 아래쪽 가운데, 테마 글꼴, 어절 단위 두 줄까지, 세로 화면은 폭의 8% 크기 — `style.json` 의 `"captions": { "size": 50, "position": 1400, "maxLines": 2 }` 로 글자 px·판 가운데 y·줄 수를 덮는다, srt·vtt 마디도 같은 값) — 장면에 문장을 쓰지 않는다. 켜면 `ctx.safe` 아래쪽에서 자막 띠(판 두 줄 높이)를 뺀다 — 무대(`makeStage`)처럼 `ctx.safe` 로 배치하는 장면은 제 크기 그대로 띠 위로 올라간다. 픽셀을 박아 둔 장면은 안 움직여 `kf check` 가 `caption.overlap` 으로 잡는다 — 글을 `ctx.safe` 안으로 옮기거나, 장면을 못 고치면 `--captions fit`(장면 전체를 띠 위로 줄여 올린다 — 안 겹치지만 내용이 작아지고 움직이는 면적도 준다).
|
|
176
222
|
|
|
177
223
|
```
|
|
178
224
|
kf render <폴더> --captions on --suffix # 다시 만들지 않고 자막 판만 → out/render-자막.mp4 (원본은 그대로). fit 이면 장면을 줄여 올린다
|
|
@@ -201,11 +247,11 @@ kf check <폴더>/out/render.mp4 # 영상만 있을 때 — 프레임 차 + O
|
|
|
201
247
|
| `text.safe-area` | 플랫폼 UI 가 가리는 자리에 글 | `ctx.safe` 안으로 |
|
|
202
248
|
| `text.contrast` | 바탕과 대비가 낮다 | 글 색이나 바탕을 3:1 이상 벌린다. 덮개 아래 글이면 덮개를 걷는다 |
|
|
203
249
|
| `text.small` · `text.crowded` | 틀이 정한 글 크기·한 화면 글 수를 넘었다 | 낱말을 줄이고 키운다(터미널·코드 화면이면 둬도 된다) |
|
|
204
|
-
| `frame.freeze` | 0.1초 넘게 거의 안 바뀐다(30fps 로 훑는다) |
|
|
250
|
+
| `frame.freeze` | 0.1초 넘게 거의 안 바뀐다(30fps 로 훑는다) | 내용의 움직임 — 숫자 굴러가기·칸 채우기·연결선·`stage.say` 의 박마다 차는 강조 띠, 끝 카드도. 뜻 없는 장식(떠다니는 방울·판)으로 메우지 않는다 |
|
|
205
251
|
| `layout.static` | 장면 안에서 같은 구도가 표의 초(기본 1.5초 경고·3초 오류, 뮤비·설명·시연 5·8초) | 새 요소·칸 채우기·숫자 키우기·컷 — `cutTimes(ctx)` 박마다 내용을 바꾼다(카메라로 메우지 않는다) |
|
|
206
252
|
| `camera.wander` | 글을 품은 큰 층(카메라 층)이 카메라 이벤트 없이 움직였다 — 장면 안 합 0.8초 넘게 | 사인 드리프트·박마다 자세·늘 다가가는 줌을 걷는다. 이유가 있으면 `stage.camera`(pushIn·pullOut·follow·impact)로 — 그쪽은 이벤트가 남아 안 센다 |
|
|
207
253
|
| `layout.edge` | 주인공 상자(`data-kf-hero`, 없으면 카메라가 움직여 있는 동안의 가장 큰 글)가 안전 영역 가장자리 4% 띠에 0.5초 넘게 | 주인공을 안전 영역 가운데로. 카메라가 밀었으면 그 이동을 걷고, 왼쪽에 붙인 배치면 가운데로 옮긴다 |
|
|
208
|
-
| `layout.fill` | 장면 내용이 안전 영역의 12% 아래(오류) · 세로 화면 폭 70% 아래(경고) | 주 요소를 키운다 — `bigWord`·`fitPx`, 도식 확대, 화면을 채우는
|
|
254
|
+
| `layout.fill` | 장면 내용이 안전 영역의 12% 아래(오류) · 세로 화면 폭 70%·높이 55% 아래(경고 — 높이는 자막 띠를 빼고 잰다) | 주 요소를 키운다 — `bigWord`·`fitPx`, 도식 확대, 화면을 채우는 면. 세로 화면은 위아래로 쓴다(큰 숫자 아래 자·막대, 목록은 세로로 쌓기) |
|
|
209
255
|
| `caption.overlap` | 화면 자막 판이 장면 글을 덮는다(경고) | 그 글을 `ctx.safe` 안으로 — 자막을 켜면 안전 영역 아래쪽 띠 자리가 빠진다. 장면을 못 고치면 `--captions fit` |
|
|
210
256
|
| `frame.spike` | 한 프레임만 앞뒤와 크게 다르다(경고) — 깜빡임·한 프레임 오류 | 그 시각 장면의 조건 분기·켜고 끄기 경계를 본다. 일부러 한 섬광·컷이면 드롭·장면 경계에 붙인다(거기선 안 센다) |
|
|
211
257
|
| `frame.flash` | 화면 25% 넘게 번쩍이는 섬광이 1초에 3번 넘게(빛 민감 발작 — `style.json` `motion.maxFlashPerSec`) | 박마다 섬광이면 두 박·한 마디에 한 번으로, 섬광 연타는 2마디 안. 빠른 곡(150BPM 넘게)은 반 박이 아니라 2박에 한 번 |
|
|
@@ -220,15 +266,17 @@ kf check <폴더>/out/render.mp4 # 영상만 있을 때 — 프레임 차 + O
|
|
|
220
266
|
| `motion.repeat` | 이어지는 두 장면이 같은 등장 움직임 | 한쪽을 다른 `enter` 로 |
|
|
221
267
|
| `asset.*` | 생성 자산의 라이선스·지역 제한·투명 실패 | 밖에 내기 전에 사람에게 알린다 |
|
|
222
268
|
|
|
223
|
-
고친 뒤: 그 구간만 `kf check <폴더> --from 10 --to 14` → 다 고쳤으면 `kf render` → `kf check <영상>`.
|
|
269
|
+
고친 뒤: 그 구간만 `kf check <폴더> --from 10 --to 14` → 다 고쳤으면 `kf render` → `kf check <영상>`. `kf render` 는 장면마다 조각(`out/segments/`)을 두고 그 장면이 불러온 파일·자산·kf.json 그 장면 칸이 그대로면 다시 찍지 않는다 — 이음매는 앞뒤 장면을 고치면 같이 다시. 장면 몇 개만 고쳤으면 `kf make --finish <폴더> --scenes a,b`(바뀐 조각만 굽고 그 구간만 검사), 그 장면을 모델에게 다시 쓰게 하려면 `kf make --rewrite <폴더> --scenes a`(감독 프로젝트는 content.json 의 그 장면을 고치고 같은 명령 — `--pattern a=<패턴>`·`--scene-seed a=<글>` 로 다른 후보).
|
|
224
270
|
|
|
225
271
|
## 8. 없는 그림·영상 — `kf gen`
|
|
226
272
|
|
|
227
273
|
```
|
|
274
|
+
kf doctor # 생성 능력 — 지금 쓸 수 있는 공급자·권장·견적·켜는 법
|
|
228
275
|
kf gen models # 키·단가 표
|
|
229
|
-
kf gen img "<구체적 프롬프트>" --id hook-bg --project <폴더> [--size 1080x1920 --transparent --ref a.png] # 그림 한 장 — 돈이 든다(한 장 $0.25 상한)
|
|
276
|
+
kf gen img "<구체적 프롬프트>" --id hook-bg --project <폴더> [--size 1080x1920 --transparent --ref a.png] [--provider codex-imagegen] # 그림 한 장 — OG 는 돈이 든다(한 장 $0.25 상한), codex-imagegen 은 ChatGPT 로그인으로 0원(1–2분)
|
|
230
277
|
kf gen plan <폴더> [--yes] # plan.json 의 gen:img·gen:vid 를 다 만든다
|
|
231
278
|
kf gen seam <폴더> <장면id> "<프롬프트>" --yes # 앞 장면 끝 → 다음 장면 첫 프레임을 잇는 전환 클립
|
|
279
|
+
kf music gen --mood deep --dur 45 [--provider lyria-3.5] # 분위기 곡 — 분위기·길이 칸이 같으면 캐시에서 같은 곡
|
|
232
280
|
```
|
|
233
281
|
|
|
234
282
|
영상은 `--yes` 가 없으면 견적만 내고 종료 코드 3 — 돈이 드는 호출은 사람 승인 뒤에. 단가·라이선스: `packages/gen/PROVIDERS.md`.
|
package/music/README.md
CHANGED
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
# 곡 모음 — kf make 가 곡을 안 받았을 때 고르는 곳
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
곡마다 **분위기 태그**·원본·라이선스를 `tags.json` 에 둔다(사람용 표는 아래). 다른 폴더를 쓰려면 `KF_MUSIC_DIR=<폴더>` — 그 폴더에도 `tags.json` 을 둔다.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- 틀이 분위기를 정한 용도(쇼츠·설명은 `deep`)는 그 분위기 태그가 맞는 곡을 **묻지 않고** 고른다 — 맞은 태그가 많은 곡, 같으면 파일 이름 순이라 같은 틀이면 늘 같은 곡이다. 맞는 곡이 없을 때만 음악 생성(돈)·곡 넣기·없음을 묻고, 생성 키도 없으면 다른 분위기 곡이나 「곡 없이」로 넘어가지 않고 곡·키 넣는 법을 권장으로 묻는다.
|
|
6
|
+
- 곡이 끌고 가는 용도(홍보·공개·뮤비)는 `--music` 이 없으면 요청 낱말과 파일 이름이 맞는 곡을 고른다(「카와이·퓨처베이스·칠·로파이·애니…」 같은 한국어 장르 낱말은 영어로도 찾는다).
|
|
7
|
+
- 라이선스를 모르는 곡은 분위기로 고르는 데서 빠진다. 출처를 모르는 곡은 넣지 않는다.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
곡 파일은 깃에 올리지 않는다(`.gitignore`) — 아래 표의 원본에서 받아 이 폴더에 둔다. 생성 곡은 `kf music gen --mood deep --dur 60 --add` 로 다시 만든다(같은 기계면 캐시에서 같은 곡, 다른 기계면 같은 프롬프트의 새 곡).
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
kf music list [--mood deep]
|
|
13
|
+
kf music add <곡> --mood deep,ambient --source <원본 주소> --license "<라이선스>" [--title 이름]
|
|
14
|
+
kf music gen --mood deep --dur 45 [--provider lyria-3.5|elevenlabs-music] [--add]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 분위기
|
|
18
|
+
|
|
19
|
+
| 이름 | 곡 태그(하나라도 맞으면 후보, 많이 맞을수록 앞) | 생성 프롬프트 결 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `deep` | deep · ambient · cinematic · minimal · piano · calm · dark | 딥 앰비언트·미니멀 시네마틱, 낮은 패드·느린 피아노·서브 드론, 타악은 멀리 약하게, 70 BPM 안팎, 말소리 중역을 비워 둔다 |
|
|
22
|
+
| `bright` | upbeat · bright · pop · kawaii · future-bass | 밝은 일렉트로 팝, 110 BPM |
|
|
23
|
+
| `calm` | calm · lofi · chill · piano · ambient | 차분한 로파이, 80 BPM |
|
|
24
|
+
| `epic` | epic · cinematic · orchestral · trailer | 시네마틱 현·금관, 90 BPM |
|
|
25
|
+
|
|
26
|
+
프롬프트 원문은 `packages/gen/src/music/library.ts` 의 `MOOD_PROMPT` — 요청 글을 섞지 않아서 분위기·길이 칸(30·45·60·90·120…초)이 같으면 같은 프롬프트다.
|
|
27
|
+
|
|
28
|
+
## 곡
|
|
29
|
+
|
|
30
|
+
| 파일 | 곡 | 분위기 · 장르 · 길이 | 원본 · 라이선스 |
|
|
8
31
|
|---|---|---|---|
|
|
9
|
-
| `kawaii-friends.mp3` | Kawaii Friends — ckotty3 | Future Bass · 2:31.8 · 170 BPM, 드롭 11.34·22.63·45.22초 | https://pixabay.com/music/future-bass-kawaii-friends-110954/ · Pixabay Content License(상업 이용 가능, 출처 표기 의무 없음) |
|
|
10
|
-
| `anime-star-ckotty3.mp3` | Anime Star — ckotty3 | Anime · 1:49 · 107 BPM | Pixabay · 같은 라이선스 |
|
|
11
|
-
| `gummy-drive-mvncm.mp3` | Gummy Drive — mvncm | 2:00 안팎 | Pixabay · 같은 라이선스 |
|
|
32
|
+
| `kawaii-friends.mp3` | Kawaii Friends — ckotty3 | bright·kawaii·future-bass·upbeat · Future Bass · 2:31.8 · 170 BPM, 드롭 11.34·22.63·45.22초 | https://pixabay.com/music/future-bass-kawaii-friends-110954/ · Pixabay Content License(상업 이용 가능, 출처 표기 의무 없음) |
|
|
33
|
+
| `anime-star-ckotty3.mp3` | Anime Star — ckotty3 | bright·upbeat·anime · Anime · 1:49 · 107 BPM | Pixabay · 같은 라이선스 |
|
|
34
|
+
| `gummy-drive-mvncm.mp3` | Gummy Drive — mvncm | (태그 없음) · 2:00 안팎 | Pixabay · 같은 라이선스 |
|
|
35
|
+
| `deep-lyria-3.5-60s.mp3` | deep 60초 (lyria-3.5 생성) | deep·ambient·cinematic·minimal · 60.3초 | Gemini API Lyria 3.5 생성(MOOD_PROMPT.deep, 2026-10-09) · Google 생성형 AI 약관 — 출력물 소유권 주장 없음, 상업 가능 · SynthID 소리 워터마크(안 들림) |
|
|
12
36
|
|
|
13
|
-
새 곡을 넣을 땐
|
|
37
|
+
새 곡을 넣을 땐 `kf music add` 가 `tags.json` 과 이 표에 한 줄씩 더한다.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kasaframe",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "말로 시키는 코드 영상 엔진 — 쇼츠·홍보·설명·뮤비를 장면 파일로 짜고 박에 맞춰 MP4 로 굽고 검사한다",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"video",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"kf": "bin/kf.mjs"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
22
|
-
"node": ">=22.
|
|
22
|
+
"node": ">=22.15"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"esbuild": "^0.28.2",
|
|
@@ -151,7 +151,7 @@ export function mountProject(o: MountOptions): KfPage {
|
|
|
151
151
|
const meta = { fps: kf.fps, width: kf.width, height: kf.height, duration: kf.duration };
|
|
152
152
|
// 자막을 켜면 장면을 짓기 전에 안전 영역 아래쪽 자막 띠를 뺀다 — 주소 ?captions=on|off|fit 가 kf.json 을 그 판만 덮는다(kf render --captions)
|
|
153
153
|
const { captionsPlan } = await import("@kasaframe/motion/captions");
|
|
154
|
-
const caps = await captionsPlan({ kf: kf as Parameters<typeof captionsPlan>[0]["kf"], mode: new URLSearchParams(location.search).get("captions") });
|
|
154
|
+
const caps = await captionsPlan({ kf: kf as Parameters<typeof captionsPlan>[0]["kf"], mode: new URLSearchParams(location.search).get("captions"), style: style as KfStyle | null });
|
|
155
155
|
for (const entry of kf.scenes) {
|
|
156
156
|
const mod = findModule(o.scenes, entry);
|
|
157
157
|
const root = document.createElement("div");
|
|
@@ -184,11 +184,11 @@ async function checkPage(o: CheckOptions): Promise<Report> {
|
|
|
184
184
|
f.push(...contrast(r.contrast, { shotFps: o.shotFps ?? 2 }));
|
|
185
185
|
if (proj.text.minPx || proj.text.maxOnScreen) f.push(...textPolicy(r.texts, proj.text, { fps: meta.fps }));
|
|
186
186
|
if (projDir) f.push(...genAssets(projDir));
|
|
187
|
-
f.push(...freezes(r.thumbs, {
|
|
187
|
+
f.push(...freezes(r.thumbs, { errorSec: proj.rules.maxFreezeSec }));
|
|
188
188
|
f.push(...blanks(r.thumbs, meta.duration));
|
|
189
189
|
f.push(...spikes(r.thumbs, { exclude: [...sceneSpans(proj.kf).map((s) => s.from), ...(proj.beats?.drops ?? []), ...r.markers.filter((m) => m.kind === "scene" || m.kind === "drop").map((m) => m.t)] }));
|
|
190
190
|
f.push(...flashes(r.thumbs, { maxPerSec: proj.rules.maxFlashPerSec, cuts: [...sceneSpans(proj.kf).map((s) => s.from), ...cuts(r.thumbs)] }));
|
|
191
|
-
f.push(...fill(r.thumbs, sceneSpans(proj.kf), { width: meta.width, height: meta.height, safe: proj.safe }));
|
|
191
|
+
f.push(...fill(r.thumbs, sceneSpans(proj.kf), { width: meta.width, height: meta.height, safe: proj.safe, captionTop: captionTop(r.texts) }));
|
|
192
192
|
f.push(...staticLayout(r.thumbs, sceneSpans(proj.kf), { width: meta.width, height: meta.height, warnSec: proj.rules.staticSec?.warn, errorSec: proj.rules.staticSec?.error }));
|
|
193
193
|
const seams = seamScenes(proj.kf);
|
|
194
194
|
f.push(...cameraWander(r.cams, r.events, { fps: meta.fps, skipScenes: seams }));
|
|
@@ -236,7 +236,7 @@ async function checkVideo(o: CheckOptions): Promise<Report> {
|
|
|
236
236
|
|
|
237
237
|
log(`프레임 읽기: ${from.toFixed(2)}–${to.toFixed(2)}초 · ${info.width}×${info.height} · ${info.fps.toFixed(2)}fps`);
|
|
238
238
|
const thumbs = await videoThumbs(file, info, from, to);
|
|
239
|
-
f.push(...freezes(thumbs, {
|
|
239
|
+
f.push(...freezes(thumbs, { errorSec: proj.rules.maxFreezeSec }));
|
|
240
240
|
f.push(...blanks(thumbs, info.duration));
|
|
241
241
|
f.push(...spikes(thumbs, { exclude: [...sceneSpans(proj.kf).map((s) => s.from), ...(proj.beats?.drops ?? [])] }));
|
|
242
242
|
f.push(...flashes(thumbs, { maxPerSec: proj.rules.maxFlashPerSec, cuts: [...sceneSpans(proj.kf).map((s) => s.from), ...cuts(thumbs)] }));
|
|
@@ -271,3 +271,10 @@ async function checkVideo(o: CheckOptions): Promise<Report> {
|
|
|
271
271
|
|
|
272
272
|
return finish(o.target, "video", meta, f, skipped, { frames: thumbs.length, textFrames, thumbFps: info.fps }, t0);
|
|
273
273
|
}
|
|
274
|
+
|
|
275
|
+
// 화면 자막 판의 윗변 — 판 글 상자 위에 판 안쪽 여백(글자 크기의 ⅓)을 더한 자리 중 가장 위. 자막이 없으면 undefined
|
|
276
|
+
function captionTop(frames: { hits: { via: string; box?: { y0: number }; sizePx: number }[] }[]): number | undefined {
|
|
277
|
+
let top = Infinity;
|
|
278
|
+
for (const f of frames) for (const h of f.hits) if (h.via === "caption" && h.box) top = Math.min(top, h.box.y0 - h.sizePx * 0.3);
|
|
279
|
+
return Number.isFinite(top) ? top : undefined;
|
|
280
|
+
}
|
|
@@ -22,7 +22,10 @@ export function genAssets(projectDir: string): Finding[] {
|
|
|
22
22
|
const id = meta.id ?? f.replace(/\.json$/, "");
|
|
23
23
|
const license = String(meta.license ?? "");
|
|
24
24
|
const region = String(meta.region ?? "");
|
|
25
|
-
|
|
25
|
+
// 공급자가 박아 둔 제한(H3 공개 가중치의 한국 제외 등)은 라이선스 글을 읽지 않고 그대로 알린다
|
|
26
|
+
if (meta.restrict) {
|
|
27
|
+
out.push({ rule: "asset.restrict", severity: "warn", from: 0, to: 0, message: `생성 자산 ${id} — ${meta.restrict}`, detail: { file: join("assets", "gen", f), provider: meta.provider, model: meta.model } });
|
|
28
|
+
} else if (region || /제한|비상업|non-?commercial|restrict/i.test(license)) {
|
|
26
29
|
out.push({
|
|
27
30
|
rule: "asset.license",
|
|
28
31
|
severity: "warn",
|
|
@@ -4,6 +4,7 @@ import type { Finding } from "../types.ts";
|
|
|
4
4
|
export interface FreezeOptions {
|
|
5
5
|
// 160×90 회색조 평균 절대차(0..255). 커서 깜빡임 한 번이면 넘는 정도.
|
|
6
6
|
threshold?: number;
|
|
7
|
+
// 틀·style.json 의 motion.maxFreezeSec(틀이 이긴다) — 고정 관찰 화면(설명·뮤비)은 틀에서 늘려 허용한다. 없으면 0.1초
|
|
7
8
|
errorSec?: number;
|
|
8
9
|
warnSec?: number;
|
|
9
10
|
}
|
|
@@ -46,7 +47,7 @@ export function freezes(thumbs: Thumb[], o: FreezeOptions = {}): Finding[] {
|
|
|
46
47
|
severity,
|
|
47
48
|
from: thumbs[a].t,
|
|
48
49
|
to: thumbs[end].t,
|
|
49
|
-
message: `화면이 ${len.toFixed(2)}초 ${area > 0 ? `거의 멈췄다(바뀐 면적 ${(area * 100).toFixed(1)}%)` : "멈췄다"}${severity === "warn" ? ` —
|
|
50
|
+
message: `화면이 ${len.toFixed(2)}초 ${area > 0 ? `거의 멈췄다(바뀐 면적 ${(area * 100).toFixed(1)}%)` : "멈췄다"}${severity === "warn" ? ` — 경고 기준 ${warnSec}초 넘음` : ` — 기준 ${errorSec}초(틀·style.json maxFreezeSec)`} — 그 사이에 눈에 띄는 움직임 하나를 둔다`,
|
|
50
51
|
detail: { seconds: Math.round(len * 100) / 100, changedArea: Math.round(area * 10000) / 10000 },
|
|
51
52
|
});
|
|
52
53
|
}
|
|
@@ -78,9 +78,18 @@ export interface FillOptions {
|
|
|
78
78
|
safe?: SafeArea;
|
|
79
79
|
// 세로 화면은 주 요소가 안전 영역 폭의 이만큼은 차야 한다
|
|
80
80
|
minWidthVertical?: number;
|
|
81
|
+
// 세로 화면은 위아래로도 써야 한다 — 내용이 위쪽에만 몰려 아래가 비면 경고(자막 띠는 빼고 잰다)
|
|
82
|
+
minHeightVertical?: number;
|
|
81
83
|
minArea?: number;
|
|
84
|
+
// 화면 자막 판의 윗변(영상 px) — 그 아래는 장면 내용이 아니라서 채움에서 뺀다
|
|
85
|
+
captionTop?: number;
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
// 판정에 쓸 만큼 그 장면을 봤나 — 구간 검사가 장면 경계에서 시작하면 앞 장면의 마지막 프레임 한두 장(덮기·전환 중)만
|
|
89
|
+
// 그 장면 몫으로 들어와 「채움 0%」로 잡혔다
|
|
90
|
+
const MIN_SAMPLES = 3;
|
|
91
|
+
const enoughOf = (times: number[], dur: number): boolean => times.length >= MIN_SAMPLES && times[times.length - 1] - times[0] >= Math.min(0.5, dur * 0.4);
|
|
92
|
+
|
|
84
93
|
// 시작이 t 이하인 마지막 장면 — kf.json 시각은 넷째 자리에서 반올림돼 경계에 0.0001초 틈이 날 수 있다
|
|
85
94
|
function sceneOf(t: number, scenes: SceneSpan[]): SceneSpan | undefined {
|
|
86
95
|
let hit: SceneSpan | undefined;
|
|
@@ -93,33 +102,39 @@ function median(xs: number[]): number {
|
|
|
93
102
|
return s.length ? s[Math.floor(s.length / 2)] : 0;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
// 화면 채움 — 장면마다 내용 상자의 가운데값. 세로 화면은 폭 70
|
|
105
|
+
// 화면 채움 — 장면마다 내용 상자의 가운데값. 넓이 12% 아래면 오류, 세로 화면은 폭 70%·높이 55% 아래면 경고
|
|
97
106
|
export function fill(thumbs: Thumbish[], scenes: SceneSpan[], o: FillOptions): Finding[] {
|
|
98
107
|
if (!thumbs.length) return [];
|
|
99
108
|
const aspect = o.width / o.height;
|
|
100
109
|
const vertical = o.height > o.width;
|
|
101
110
|
const minW = o.minWidthVertical ?? 0.7;
|
|
111
|
+
const minH = o.minHeightVertical ?? 0.55;
|
|
102
112
|
const minArea = o.minArea ?? 0.12;
|
|
103
|
-
const groups = new Map<string, { span: SceneSpan; w: number[]; a: number[] }>();
|
|
113
|
+
const groups = new Map<string, { span: SceneSpan; w: number[]; h: number[]; a: number[]; t: number[] }>();
|
|
104
114
|
for (const t of thumbs) {
|
|
105
115
|
const s = sceneOf(t.t, scenes) ?? { id: "영상", from: 0, dur: Infinity };
|
|
106
116
|
const { w, h } = dims(t, aspect);
|
|
107
117
|
const k = w / o.width;
|
|
108
118
|
const sa = o.safe ?? { top: 0, bottom: 0, left: 0, right: 0 };
|
|
119
|
+
const y0 = Math.max(0, Math.round(sa.top * k));
|
|
109
120
|
const box = contentBox(t.luma, w, h, {
|
|
110
121
|
x0: Math.max(0, Math.round(sa.left * k)),
|
|
111
|
-
y0
|
|
122
|
+
y0,
|
|
112
123
|
x1: Math.min(w, w - Math.round(sa.right * k)),
|
|
113
|
-
y1: Math.min(h, h - Math.round(sa.bottom * k)),
|
|
124
|
+
y1: Math.max(y0 + 1, Math.min(h, h - Math.round(sa.bottom * k), o.captionTop !== undefined ? Math.round(o.captionTop * k) : h)),
|
|
114
125
|
});
|
|
115
|
-
const g = groups.get(s.id) ?? { span: s, w: [], a: [] };
|
|
126
|
+
const g = groups.get(s.id) ?? { span: s, w: [], h: [], a: [], t: [] };
|
|
116
127
|
g.w.push(box.wFrac);
|
|
128
|
+
g.h.push(box.hFrac);
|
|
117
129
|
g.a.push(box.area);
|
|
130
|
+
g.t.push(t.t);
|
|
118
131
|
groups.set(s.id, g);
|
|
119
132
|
}
|
|
120
133
|
const out: Finding[] = [];
|
|
121
|
-
for (const { span, w, a } of groups.values()) {
|
|
134
|
+
for (const { span, w, h, a, t } of groups.values()) {
|
|
135
|
+
if (!enoughOf(t, span.dur)) continue;
|
|
122
136
|
const mw = median(w);
|
|
137
|
+
const mh = median(h);
|
|
123
138
|
const ma = median(a);
|
|
124
139
|
const to = Number.isFinite(span.dur) ? span.from + span.dur : thumbs.at(-1)!.t;
|
|
125
140
|
if (ma < minArea) {
|
|
@@ -140,6 +155,15 @@ export function fill(thumbs: Thumbish[], scenes: SceneSpan[], o: FillOptions): F
|
|
|
140
155
|
message: `장면 ${span.id} 의 주 요소 폭이 안전 영역의 ${(mw * 100).toFixed(0)}% — 세로 화면은 ${Math.round(minW * 100)}% 넘게 채운다`,
|
|
141
156
|
detail: { scene: span.id, area: Math.round(ma * 1000) / 1000, width: Math.round(mw * 1000) / 1000 },
|
|
142
157
|
});
|
|
158
|
+
} else if (vertical && mh < minH) {
|
|
159
|
+
out.push({
|
|
160
|
+
rule: "layout.fill",
|
|
161
|
+
severity: "warn",
|
|
162
|
+
from: span.from,
|
|
163
|
+
to,
|
|
164
|
+
message: `장면 ${span.id} 의 내용이 안전 영역 높이의 ${(mh * 100).toFixed(0)}% 에 몰렸다(자막 띠 빼고) — 세로 화면은 위아래로 써서 ${Math.round(minH * 100)}% 넘게 채운다(큰 숫자 아래 자·막대, 목록은 세로로 쌓기)`,
|
|
165
|
+
detail: { scene: span.id, area: Math.round(ma * 1000) / 1000, width: Math.round(mw * 1000) / 1000, height: Math.round(mh * 1000) / 1000 },
|
|
166
|
+
});
|
|
143
167
|
}
|
|
144
168
|
}
|
|
145
169
|
return out;
|
package/packages/cli/src/blur.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { PNG } from "pngjs";
|
|
2
4
|
import type { PageInfo, Tab } from "./browser.ts";
|
|
3
5
|
|
|
@@ -8,10 +10,20 @@ export interface BlurOpts {
|
|
|
8
10
|
shutter: number;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
/** kf.json 의 render — 빠른 움직임이 많은 용도(쇼츠·홍보)는 틀이 모션 블러를 기본으로 켜 둔다 */
|
|
14
|
+
export function projectBlur(dir: string): { motionBlur?: number; shutter?: number } | undefined {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(join(dir, "kf.json"), "utf8")).render;
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 플래그가 프로젝트 기본(kf.json render)을 이긴다 — --motion-blur 0 이면 끈다 */
|
|
23
|
+
export function blurOpts(opts: Record<string, string | boolean | undefined>, base?: { motionBlur?: number; shutter?: number }): BlurOpts | null {
|
|
24
|
+
const count = Math.round(Number(opts["motion-blur"] ?? base?.motionBlur ?? 0));
|
|
13
25
|
if (!(count > 1)) return null;
|
|
14
|
-
const shutter = Number(opts.shutter ?? 180);
|
|
26
|
+
const shutter = Number(opts.shutter ?? base?.shutter ?? 180);
|
|
15
27
|
return { count, shutter: Number.isFinite(shutter) ? Math.min(360, Math.max(1, shutter)) : 180 };
|
|
16
28
|
}
|
|
17
29
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { Command } from "../command.ts";
|
|
7
|
+
import { num, parseFlags } from "../command.ts";
|
|
8
|
+
|
|
9
|
+
const kfBin = fileURLToPath(new URL("../kf.ts", import.meta.url));
|
|
10
|
+
|
|
11
|
+
const USAGE = `kf bench "<대충 한 요청>" [--runs 3] [--model claude-code|<OG 모델>] [--out 폴더] [kf make 에 넘길 플래그…]
|
|
12
|
+
|
|
13
|
+
같은 요청으로 kf make 를 여러 번 돌려 나란히 본다 — 「대충 말해도 같은 결과」를 재는 시험 틀.
|
|
14
|
+
같아야 할 것: 용도·시리즈·테마·스타일·목소리·곡·자막·길이·장면 수·장면 시간표·장면 연출·생성 거리·3D 샷·고른 답·물을 것
|
|
15
|
+
달라도 되는 것: 검사 점수·장면 모음(모델이 장면을 쓰는 OG 모드에서만 나온다)
|
|
16
|
+
claude-code 모드(기본)는 kf make 가 지시서에서 멈추니 계획까지 본다. --model <OG 모델> 이면 끝까지 굽고 검사·장면 모음까지(실행마다 렌더 — 오래 걸린다)
|
|
17
|
+
결과: <out>/report.md · report.json · (OG) sheets.png. 같아야 할 것이 달라지면 빨간 「다름」, 종료 코드 1`;
|
|
18
|
+
|
|
19
|
+
const RED = (s: string) => (process.stdout.isTTY ? `\x1b[31m${s}\x1b[0m` : s);
|
|
20
|
+
const DIM = (s: string) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
21
|
+
|
|
22
|
+
const read = (f: string) => (existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null);
|
|
23
|
+
const short = (v: unknown) => createHash("sha256").update(JSON.stringify(v ?? null)).digest("hex").slice(0, 8);
|
|
24
|
+
|
|
25
|
+
interface Run {
|
|
26
|
+
dir: string;
|
|
27
|
+
code: number;
|
|
28
|
+
same: Record<string, string>;
|
|
29
|
+
vary: Record<string, string>;
|
|
30
|
+
sheet?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 실행 하나에서 비교할 값 — 긴 것(시간표·연출·생성 거리)은 지문으로 줄이고 report.json 에 원본을 둔다
|
|
34
|
+
function measure(dir: string, code: number): Run {
|
|
35
|
+
const plan = read(join(dir, "plan.json")) ?? {};
|
|
36
|
+
const kf = read(join(dir, "kf.json")) ?? {};
|
|
37
|
+
const scenes: { id: string; from: number; dur: number; dress?: unknown }[] = kf.scenes ?? plan.scenes ?? [];
|
|
38
|
+
const same: Record<string, string> = {
|
|
39
|
+
용도: plan.purpose ?? "-",
|
|
40
|
+
시리즈: plan.series ?? "-",
|
|
41
|
+
테마: plan.theme ?? "-",
|
|
42
|
+
스타일: plan.style ?? "-",
|
|
43
|
+
목소리: plan.voice ?? plan.answers?.voice ?? "-",
|
|
44
|
+
곡: (kf.music ?? plan.music ?? plan.answers?.music ?? "-").toString().split("/").pop(),
|
|
45
|
+
자막: kf.captions ?? plan.captions ?? "-",
|
|
46
|
+
길이: String(kf.duration ?? plan.duration ?? "-"),
|
|
47
|
+
"장면 수": String(scenes.length),
|
|
48
|
+
시간표: short(scenes.map((s) => [s.id, s.from, s.dur])),
|
|
49
|
+
연출: short(scenes.map((s) => s.dress ?? null)),
|
|
50
|
+
"생성 거리": (plan.gen ?? []).map((g: { kind: string; id: string; provider?: string }) => `${g.kind}:${g.id}${g.provider ? `@${g.provider}` : ""}`).join(" ") || "-",
|
|
51
|
+
"3D 샷": (plan.shots ?? []).map((s: { id: string; tool: string }) => `${s.id}@${s.tool}`).join(" ") || "-",
|
|
52
|
+
"고른 답": Object.entries(plan.answers ?? {}).map(([k, v]) => `${k}=${String(v).split("/").pop()}`).sort().join(" ") || "-",
|
|
53
|
+
"물을 것": (plan.asks ?? []).map((q: { id: string }) => q.id).join(" ") || "-",
|
|
54
|
+
};
|
|
55
|
+
const check = read(join(dir, "out", "check.json"));
|
|
56
|
+
const vary: Record<string, string> = {};
|
|
57
|
+
if (check) {
|
|
58
|
+
const fs: { severity: string }[] = check.findings ?? [];
|
|
59
|
+
vary["검사"] = `오류 ${fs.filter((f) => f.severity === "error").length} · 경고 ${fs.filter((f) => f.severity === "warn").length}`;
|
|
60
|
+
}
|
|
61
|
+
vary["종료 코드"] = String(code);
|
|
62
|
+
const sheet = join(dir, "out", "look-sheet.png");
|
|
63
|
+
return { dir, code, same, vary, ...(existsSync(sheet) ? { sheet } : {}) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function table(runs: Run[]): { lines: string[]; diff: string[] } {
|
|
67
|
+
const keys = Object.keys(runs[0].same);
|
|
68
|
+
const diff = keys.filter((k) => new Set(runs.map((r) => r.same[k])).size > 1);
|
|
69
|
+
const w = Math.max(...keys.map((k) => [...k].length * 2)) + 2;
|
|
70
|
+
const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - [...s].reduce((a, c) => a + (/[ㄱ-]/.test(c) ? 2 : 1), 0)));
|
|
71
|
+
const lines = [pad("", w) + runs.map((_, i) => `실행 ${i + 1}`).join(" | ")];
|
|
72
|
+
for (const k of keys) {
|
|
73
|
+
const row = `${pad(k, w)}${runs.map((r) => r.same[k]).join(" | ")}`;
|
|
74
|
+
lines.push(diff.includes(k) ? RED(`${row} 다름`) : row);
|
|
75
|
+
}
|
|
76
|
+
for (const k of Object.keys(runs[0].vary)) lines.push(DIM(`${pad(k, w)}${runs.map((r) => r.vary[k] ?? "-").join(" | ")}`));
|
|
77
|
+
return { lines, diff };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cmd: Command = {
|
|
81
|
+
summary: "일관성 시험 — 같은 요청으로 kf make 를 여러 번, 같아야 할 것이 달라지면 빨갛게",
|
|
82
|
+
usage: USAGE,
|
|
83
|
+
async run(argv) {
|
|
84
|
+
const { args, opts } = parseFlags(argv);
|
|
85
|
+
const request = args.join(" ").trim();
|
|
86
|
+
if (!request) {
|
|
87
|
+
console.error(USAGE);
|
|
88
|
+
return 2;
|
|
89
|
+
}
|
|
90
|
+
const runs = Math.max(2, Math.round(num(opts.runs, 3)));
|
|
91
|
+
const model = typeof opts.model === "string" ? opts.model : "claude-code";
|
|
92
|
+
const out = resolve(typeof opts.out === "string" ? opts.out : `bench-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}`);
|
|
93
|
+
mkdirSync(out, { recursive: true });
|
|
94
|
+
// kf make 로 그대로 넘길 플래그(--yes·--no-gen·--allow·--image 등) — bench 자신의 것은 뺀다
|
|
95
|
+
const pass: string[] = [];
|
|
96
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
97
|
+
if (["runs", "model", "out"].includes(k)) continue;
|
|
98
|
+
pass.push(`--${k}`, ...(v === true ? [] : [String(v)]));
|
|
99
|
+
}
|
|
100
|
+
const done: Run[] = [];
|
|
101
|
+
for (let i = 1; i <= runs; i++) {
|
|
102
|
+
const dir = join(out, `run-${i}`);
|
|
103
|
+
console.error(`실행 ${i}/${runs} — kf make (${model}) → ${dir}`);
|
|
104
|
+
const r = spawnSync(process.execPath, [kfBin, "make", request, "--out", dir, "--model", model, "--force", ...pass], { stdio: ["ignore", "ignore", "inherit"] });
|
|
105
|
+
done.push(measure(dir, r.status ?? 1));
|
|
106
|
+
}
|
|
107
|
+
const { lines, diff } = table(done);
|
|
108
|
+
console.log(`\n「${request}」 × ${runs} (${model})\n`);
|
|
109
|
+
console.log(lines.join("\n"));
|
|
110
|
+
const sheets = done.filter((r) => r.sheet).map((r) => r.sheet!);
|
|
111
|
+
if (sheets.length > 1) {
|
|
112
|
+
const png = join(out, "sheets.png");
|
|
113
|
+
const r = spawnSync("ffmpeg", ["-v", "error", "-y", ...sheets.flatMap((s) => ["-i", s]), "-filter_complex", `${sheets.map((_, i) => `[${i}]scale=-2:900[s${i}]`).join(";")};${sheets.map((_, i) => `[s${i}]`).join("")}hstack=${sheets.length}`, png]);
|
|
114
|
+
if (r.status === 0) console.log(`\n장면 모음 나란히: ${png}`);
|
|
115
|
+
}
|
|
116
|
+
const md = [`# kf bench — 「${request}」 × ${runs} (${model})`, "", "```", ...lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, "")), "```", "", diff.length ? `같아야 하는데 달라진 것: ${diff.join(" · ")}` : "같아야 할 것이 모두 같다.", ""].join("\n");
|
|
117
|
+
writeFileSync(join(out, "report.md"), md);
|
|
118
|
+
writeFileSync(join(out, "report.json"), `${JSON.stringify({ request, runs, model, diff, results: done.map((r) => ({ ...r, plan: read(join(r.dir, "plan.json")), scenes: read(join(r.dir, "kf.json"))?.scenes })) }, null, 2)}\n`);
|
|
119
|
+
console.log(`\n${diff.length ? RED(`다름 ${diff.length}가지: ${diff.join(" · ")}`) : "같아야 할 것이 모두 같다"}\n보고서: ${join(out, "report.md")}`);
|
|
120
|
+
return diff.length ? 1 : 0;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
export default cmd;
|