kasaframe 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/NOTICE +146 -0
- package/README.md +77 -2
- package/app/package.json +12 -0
- package/app/server/engine.ts +415 -0
- package/app/server/main.ts +205 -0
- package/app/web/env.d.ts +2 -0
- package/app/web/icons.ts +31 -0
- package/app/web/index.html +21 -0
- package/app/web/main.ts +1013 -0
- package/app/web/style.css +430 -0
- package/bin/hooks.mjs +51 -0
- package/bin/kf.mjs +14 -0
- package/docs/AGENTS.md +238 -0
- package/music/README.md +13 -0
- package/package.json +44 -4
- package/packages/adapters/package.json +36 -0
- package/packages/adapters/src/css.ts +11 -0
- package/packages/adapters/src/gsap.ts +26 -0
- package/packages/adapters/src/index.ts +9 -0
- package/packages/adapters/src/lottie.ts +22 -0
- package/packages/adapters/src/media.ts +59 -0
- package/packages/adapters/src/page.ts +229 -0
- package/packages/adapters/src/project.ts +214 -0
- package/packages/adapters/src/react.ts +36 -0
- package/packages/adapters/src/svelte.ts +22 -0
- package/packages/adapters/src/three.ts +18 -0
- package/packages/adapters/src/vue.ts +27 -0
- package/packages/check/package.json +19 -0
- package/packages/check/src/check.ts +273 -0
- package/packages/check/src/cli.ts +70 -0
- package/packages/check/src/image.ts +146 -0
- package/packages/check/src/index.ts +9 -0
- package/packages/check/src/ocr.swift +37 -0
- package/packages/check/src/probe.ts +328 -0
- package/packages/check/src/report.ts +44 -0
- package/packages/check/src/rules/assets.ts +42 -0
- package/packages/check/src/rules/beats.ts +212 -0
- package/packages/check/src/rules/camera.ts +217 -0
- package/packages/check/src/rules/captions.ts +49 -0
- package/packages/check/src/rules/flash.ts +235 -0
- package/packages/check/src/rules/frames.ts +135 -0
- package/packages/check/src/rules/layout.ts +257 -0
- package/packages/check/src/rules/page.ts +87 -0
- package/packages/check/src/rules/runs.ts +20 -0
- package/packages/check/src/rules/text.ts +326 -0
- package/packages/check/src/scan.ts +240 -0
- package/packages/check/src/serve.ts +67 -0
- package/packages/check/src/types.ts +80 -0
- package/packages/check/src/video.ts +137 -0
- package/packages/cli/package.json +29 -0
- package/packages/cli/src/blur.ts +68 -0
- package/packages/cli/src/browser.ts +203 -0
- package/packages/cli/src/command.ts +44 -0
- package/packages/cli/src/commands/app.ts +25 -0
- package/packages/cli/src/commands/beats.ts +36 -0
- package/packages/cli/src/commands/blender.ts +270 -0
- package/packages/cli/src/commands/check.ts +25 -0
- package/packages/cli/src/commands/doctor.ts +22 -0
- package/packages/cli/src/commands/gen.ts +173 -0
- package/packages/cli/src/commands/look.ts +39 -0
- package/packages/cli/src/commands/lyrics.ts +111 -0
- package/packages/cli/src/commands/make.ts +542 -0
- package/packages/cli/src/commands/new.ts +176 -0
- package/packages/cli/src/commands/preview.ts +43 -0
- package/packages/cli/src/commands/render.ts +216 -0
- package/packages/cli/src/commands/setup.ts +63 -0
- package/packages/cli/src/commands/sheet.ts +114 -0
- package/packages/cli/src/commands/skill.ts +43 -0
- package/packages/cli/src/commands/still.ts +40 -0
- package/packages/cli/src/commands/theme.ts +85 -0
- package/packages/cli/src/commands/transitions.ts +40 -0
- package/packages/cli/src/deps.ts +122 -0
- package/packages/cli/src/ffmpeg.ts +81 -0
- package/packages/cli/src/index.ts +8 -0
- package/packages/cli/src/kf.ts +53 -0
- package/packages/cli/src/lock.ts +72 -0
- package/packages/cli/src/out.ts +13 -0
- package/packages/cli/src/serve.ts +124 -0
- package/packages/clock/package.json +14 -0
- package/packages/clock/src/index.ts +25 -0
- package/packages/clock/src/install.ts +351 -0
- package/packages/clock/src/types.ts +45 -0
- package/packages/core/package.json +9 -0
- package/packages/core/src/beats.ts +283 -0
- package/packages/core/src/ease.ts +87 -0
- package/packages/core/src/frame.ts +99 -0
- package/packages/core/src/index.ts +27 -0
- package/packages/core/src/project.ts +146 -0
- package/packages/core/src/rand.ts +56 -0
- package/packages/core/src/spring.ts +52 -0
- package/packages/core/src/style.ts +100 -0
- package/packages/core/src/time.ts +88 -0
- package/packages/gen/PROVIDERS.md +55 -0
- package/packages/gen/package.json +9 -0
- package/packages/gen/src/generate.ts +204 -0
- package/packages/gen/src/image/og.ts +133 -0
- package/packages/gen/src/index.ts +18 -0
- package/packages/gen/src/keys.ts +60 -0
- package/packages/gen/src/media.ts +150 -0
- package/packages/gen/src/plan.ts +106 -0
- package/packages/gen/src/registry.ts +51 -0
- package/packages/gen/src/seam.ts +103 -0
- package/packages/gen/src/types.ts +86 -0
- package/packages/gen/src/video/fal.ts +140 -0
- package/packages/gen/src/video/h3.ts +107 -0
- package/packages/gen/src/video/http.ts +44 -0
- package/packages/gen/src/video/ltx.ts +60 -0
- package/packages/gen/src/video/minimax.ts +55 -0
- package/packages/gen/src/video/veo.ts +71 -0
- package/packages/jizura/package.json +16 -0
- package/packages/jizura/src/deny.ts +73 -0
- package/packages/jizura/src/index.ts +7 -0
- package/packages/jizura/src/install.ts +226 -0
- package/packages/jizura/src/layer.ts +282 -0
- package/packages/jizura/src/lyrics.ts +81 -0
- package/packages/jizura/src/pin.ts +5 -0
- package/packages/jizura/src/vite.ts +36 -0
- package/packages/motion/README.md +142 -0
- package/packages/motion/package.json +29 -0
- package/packages/motion/src/camera.ts +97 -0
- package/packages/motion/src/captions.ts +479 -0
- package/packages/motion/src/enter.ts +128 -0
- package/packages/motion/src/hangul.ts +129 -0
- package/packages/motion/src/impact.ts +100 -0
- package/packages/motion/src/index.ts +17 -0
- package/packages/motion/src/moves/brushWipe.ts +90 -0
- package/packages/motion/src/moves/cloneArray.ts +111 -0
- package/packages/motion/src/moves/curtains.ts +81 -0
- package/packages/motion/src/moves/echoStack.ts +70 -0
- package/packages/motion/src/moves/fit.ts +34 -0
- package/packages/motion/src/moves/flood.ts +61 -0
- package/packages/motion/src/moves/ghostStack.ts +79 -0
- package/packages/motion/src/moves/gridFloor.ts +100 -0
- package/packages/motion/src/moves/imageSwarm.ts +109 -0
- package/packages/motion/src/moves/index.ts +19 -0
- package/packages/motion/src/moves/irisBlades.ts +113 -0
- package/packages/motion/src/moves/karaokeLine.ts +94 -0
- package/packages/motion/src/moves/ledBoard.ts +94 -0
- package/packages/motion/src/moves/liquidTab.ts +72 -0
- package/packages/motion/src/moves/markerWrite.ts +63 -0
- package/packages/motion/src/moves/morphBox.ts +96 -0
- package/packages/motion/src/moves/neonSign.ts +77 -0
- package/packages/motion/src/moves/panelPop.ts +78 -0
- package/packages/motion/src/moves/paperTear.ts +75 -0
- package/packages/motion/src/moves/path.ts +64 -0
- package/packages/motion/src/moves/pathText.ts +107 -0
- package/packages/motion/src/moves/pointSphere.ts +109 -0
- package/packages/motion/src/moves/ransomLetters.ts +73 -0
- package/packages/motion/src/moves/shapeMorph.ts +143 -0
- package/packages/motion/src/moves/slamWords.ts +89 -0
- package/packages/motion/src/moves/sparkTrail.ts +114 -0
- package/packages/motion/src/moves/speedLines.ts +91 -0
- package/packages/motion/src/moves/splitFlap.ts +147 -0
- package/packages/motion/src/moves/stepWords.ts +117 -0
- package/packages/motion/src/moves/strikeOut.ts +98 -0
- package/packages/motion/src/moves/sunburst.ts +58 -0
- package/packages/motion/src/moves/tickerRows.ts +87 -0
- package/packages/motion/src/moves/tokenPicker.ts +114 -0
- package/packages/motion/src/moves/truchet.ts +85 -0
- package/packages/motion/src/moves/wordWall.ts +76 -0
- package/packages/motion/src/moves/worldRoll.ts +31 -0
- package/packages/motion/src/particles.ts +102 -0
- package/packages/motion/src/react/index.tsx +21 -0
- package/packages/motion/src/react/parts.tsx +431 -0
- package/packages/motion/src/react/time.tsx +97 -0
- package/packages/motion/src/text.ts +103 -0
- package/packages/motion/src/transition.ts +82 -0
- package/packages/motion/src/transition3d/cards.ts +65 -0
- package/packages/motion/src/transition3d/chain.ts +84 -0
- package/packages/motion/src/transition3d/dive.ts +181 -0
- package/packages/motion/src/transition3d/fold.ts +110 -0
- package/packages/motion/src/transition3d/gl.ts +219 -0
- package/packages/motion/src/transition3d/index.ts +228 -0
- package/packages/motion/src/transition3d/live.ts +70 -0
- package/packages/motion/src/transition3d/portal.ts +115 -0
- package/packages/motion/src/transition3d/react.tsx +29 -0
- package/packages/motion/src/transition3d/rig.ts +502 -0
- package/packages/motion/src/transition3d/shatter.ts +101 -0
- package/packages/motion/src/transition3d/whip.ts +65 -0
- package/packages/motion/src/transition3d/world.ts +683 -0
- package/packages/motion/src/transition3d/zoomOut.ts +114 -0
- package/packages/motion/src/transitions2d/catalog.ts +127 -0
- package/packages/motion/src/transitions2d/css.d.ts +2 -0
- package/packages/motion/src/transitions2d/cult-ui/LICENSE.md +9 -0
- package/packages/motion/src/transitions2d/cult-ui/RollingNumber.tsx +43 -0
- package/packages/motion/src/transitions2d/gl-transitions/LICENSE +25 -0
- package/packages/motion/src/transitions2d/gl-transitions/shaders.ts +1931 -0
- package/packages/motion/src/transitions2d/index.ts +2 -0
- package/packages/motion/src/transitions2d/kokonutui/LICENSE +21 -0
- package/packages/motion/src/transitions2d/kokonutui/MatrixText.css +16 -0
- package/packages/motion/src/transitions2d/kokonutui/MatrixText.tsx +94 -0
- package/packages/motion/src/transitions2d/magicui/HyperText.css +15 -0
- package/packages/motion/src/transitions2d/magicui/HyperText.tsx +115 -0
- package/packages/motion/src/transitions2d/magicui/LICENSE.md +21 -0
- package/packages/motion/src/transitions2d/magicui/MorphingText.css +25 -0
- package/packages/motion/src/transitions2d/magicui/MorphingText.tsx +130 -0
- package/packages/motion/src/transitions2d/magicui/WordRotate.tsx +42 -0
- package/packages/motion/src/transitions2d/react.ts +13 -0
- package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.css +39 -0
- package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.tsx +32 -0
- package/packages/motion/src/transitions2d/smoothui/LICENSE +21 -0
- package/packages/motion/src/transitions2d/smoothui/SharedAxisY.tsx +50 -0
- package/packages/motion/src/transitions2d/smoothui/TextMorph.tsx +90 -0
- package/packages/motion/src/transitions2d/smoothui/shaders.ts +223 -0
- package/packages/motion/src/transitions2d/unlumen/LICENSE +21 -0
- package/packages/motion/src/transitions2d/unlumen/ScrambleText.tsx +114 -0
- package/packages/motion/src/transitions2d/vengeance-ui/LICENSE +21 -0
- package/packages/motion/src/transitions2d/vengeance-ui/MorphText.css +69 -0
- package/packages/motion/src/transitions2d/vengeance-ui/MorphText.tsx +45 -0
- package/packages/motion/src/type.ts +131 -0
- package/packages/motion/src/ui.ts +172 -0
- package/packages/plan/package.json +15 -0
- package/packages/plan/src/brief.ts +254 -0
- package/packages/plan/src/captions.ts +75 -0
- package/packages/plan/src/detect.ts +93 -0
- package/packages/plan/src/index.ts +23 -0
- package/packages/plan/src/layout.ts +176 -0
- package/packages/plan/src/llm.ts +110 -0
- package/packages/plan/src/look.ts +220 -0
- package/packages/plan/src/plan.ts +125 -0
- package/packages/plan/src/request.ts +55 -0
- package/packages/plan/src/studio.ts +53 -0
- package/packages/plan/src/templates.ts +42 -0
- package/packages/plan/src/themes.ts +48 -0
- package/packages/plan/src/types.ts +106 -0
- package/packages/studio/package.json +12 -0
- package/packages/studio/src/index.ts +1 -0
- package/packages/studio/src/plugin.ts +236 -0
- package/packages/studio/src/ui/main.ts +503 -0
- package/packages/studio/src/ui/studio.css +165 -0
- package/packages/studio/src/ui/timeline.ts +317 -0
- package/packages/themes/kit/THEME-MAKE.md +68 -0
- package/packages/themes/kit/THEME.md +30 -0
- package/packages/themes/kit/briefs/og-intro-15s/brief.json +16 -0
- package/packages/themes/kit/briefs/og-intro-15s/facts.ts +29 -0
- package/packages/themes/kit/briefs/og-intro-15s/logo.svg +12 -0
- package/packages/themes/kit/effects.ts +5 -0
- package/packages/themes/kit/sample/index.html +11 -0
- package/packages/themes/kit/sample/main.ts +9 -0
- package/packages/themes/kit/sample/scenes/scene.ts +15 -0
- package/packages/themes/package.json +16 -0
- package/packages/themes/src/factory.ts +296 -0
- package/packages/themes/src/fx.ts +14 -0
- package/packages/themes/src/index.ts +105 -0
- package/packages/themes/src/stage3d.ts +177 -0
- package/packages/themes/src/types.ts +58 -0
- package/presets/kawaii-pastel.json +94 -0
- package/presets/neutral-dark.json +82 -0
- package/presets/og.json +98 -0
- package/skills/kasaframe/SKILL.md +32 -0
- package/templates/_shared/project/index.html +11 -0
- package/templates/_shared/project/lib/react.ts +22 -0
- package/templates/_shared/project/lib/stage.ts +272 -0
- package/templates/_shared/project/main.ts +9 -0
- package/templates/_shared/scenes/default.ts +18 -0
- package/templates/_shared/scenes/drop.ts +30 -0
- package/templates/_shared/scenes/end.ts +23 -0
- package/templates/_shared/scenes/hook.ts +16 -0
- package/templates/_shared/scenes/seam.ts +41 -0
- package/templates/_shared/scenes/step.ts +40 -0
- package/templates/ad/preview.png +0 -0
- package/templates/ad/template.json +132 -0
- package/templates/demo/preview.png +0 -0
- package/templates/demo/template.json +130 -0
- package/templates/explainer/preview.png +0 -0
- package/templates/explainer/template.json +142 -0
- package/templates/launch/preview.png +0 -0
- package/templates/launch/template.json +130 -0
- package/templates/mv/preview.png +0 -0
- package/templates/mv/scenes/default.ts +95 -0
- package/templates/mv/template.json +176 -0
- package/templates/promo/preview.png +0 -0
- package/templates/promo/template.json +144 -0
- package/templates/shorts/preview.png +0 -0
- package/templates/shorts/scenes/hook.ts +34 -0
- package/templates/shorts/template.json +127 -0
- package/templates/square/preview.png +0 -0
- package/templates/square/template.json +113 -0
- package/themes/debug-hud/THEME.md +39 -0
- package/themes/debug-hud/effects.ts +389 -0
- package/themes/debug-hud/sample/assets/og/logo.svg +12 -0
- package/themes/debug-hud/sample/beats.json +1 -0
- package/themes/debug-hud/sample/index.html +11 -0
- package/themes/debug-hud/sample/kf.json +59 -0
- package/themes/debug-hud/sample/lib/hudkit.ts +49 -0
- package/themes/debug-hud/sample/lib/og.ts +29 -0
- package/themes/debug-hud/sample/main.ts +9 -0
- package/themes/debug-hud/sample/scenes/end.ts +62 -0
- package/themes/debug-hud/sample/scenes/gather.ts +67 -0
- package/themes/debug-hud/sample/scenes/models.ts +70 -0
- package/themes/debug-hud/sample/scenes/price.ts +79 -0
- package/themes/debug-hud/sample/style.json +91 -0
- package/themes/debug-hud/still.png +0 -0
- package/themes/debug-hud/style.json +91 -0
- package/themes/debug-hud/theme.json +68 -0
- package/themes/og-measure/THEME.md +47 -0
- package/themes/og-measure/effects.ts +432 -0
- package/themes/og-measure/sample/assets/brand/og-logo-full.svg +12 -0
- package/themes/og-measure/sample/assets/brand/og-logo.svg +12 -0
- package/themes/og-measure/sample/assets/data/measure.json +353 -0
- package/themes/og-measure/sample/beats.json +1 -0
- package/themes/og-measure/sample/index.html +11 -0
- package/themes/og-measure/sample/kf.json +66 -0
- package/themes/og-measure/sample/lib/data.ts +62 -0
- package/themes/og-measure/sample/main.ts +9 -0
- package/themes/og-measure/sample/measure.ts +78 -0
- package/themes/og-measure/sample/scenes/end.ts +47 -0
- package/themes/og-measure/sample/scenes/gather.ts +51 -0
- package/themes/og-measure/sample/scenes/models.ts +34 -0
- package/themes/og-measure/sample/scenes/one.ts +39 -0
- package/themes/og-measure/sample/scenes/price.ts +56 -0
- package/themes/og-measure/sample/style.json +97 -0
- package/themes/og-measure/still.png +0 -0
- package/themes/og-measure/style.json +97 -0
- package/themes/og-measure/theme.json +79 -0
- package/themes/riso-zine/THEME.md +43 -0
- package/themes/riso-zine/effects.ts +321 -0
- package/themes/riso-zine/sample/assets/og/logo.svg +12 -0
- package/themes/riso-zine/sample/beats.json +1 -0
- package/themes/riso-zine/sample/index.html +11 -0
- package/themes/riso-zine/sample/kf.json +59 -0
- package/themes/riso-zine/sample/lib/og.ts +29 -0
- package/themes/riso-zine/sample/lib/shot.ts +86 -0
- package/themes/riso-zine/sample/lib/zine.ts +42 -0
- package/themes/riso-zine/sample/main.ts +9 -0
- package/themes/riso-zine/sample/scenes/end.ts +61 -0
- package/themes/riso-zine/sample/scenes/gather.ts +62 -0
- package/themes/riso-zine/sample/scenes/models.ts +52 -0
- package/themes/riso-zine/sample/scenes/price.ts +82 -0
- package/themes/riso-zine/sample/style.json +99 -0
- package/themes/riso-zine/still.png +0 -0
- package/themes/riso-zine/style.json +99 -0
- package/themes/riso-zine/theme.json +75 -0
- package/tools/beats/beats.py +372 -0
- package/tools/beats/requirements.txt +3 -0
- package/tools/lyrics/align.py +164 -0
- package/tools/lyrics/requirements.txt +9 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { rand, safeTime } from "@kasaframe/core";
|
|
2
|
+
import { typingFrames } from "./hangul.ts";
|
|
3
|
+
|
|
4
|
+
export interface TypeOpts {
|
|
5
|
+
/** 첫 키를 누르는 시각(초) */
|
|
6
|
+
at?: number;
|
|
7
|
+
/** 초당 키 수. 한글은 자모 하나가 키 하나 */
|
|
8
|
+
cps?: number;
|
|
9
|
+
/** 키 간격을 시드 난수로 ±비율만큼 흔든다 — 사람 손 같게. 0 이면 고르게 */
|
|
10
|
+
jitter?: number;
|
|
11
|
+
seed?: number | string;
|
|
12
|
+
/** 띄어쓰기·문장부호 뒤에 쉬는 배수 */
|
|
13
|
+
pause?: { space?: number; comma?: number; stop?: number; newline?: number };
|
|
14
|
+
ime?: boolean;
|
|
15
|
+
/** 커서 깜빡임 주기(초). 0 이면 늘 보인다 */
|
|
16
|
+
blink?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TypeState {
|
|
20
|
+
/** 지금 화면에 보일 글 — 언제나 문자열 */
|
|
21
|
+
text: string;
|
|
22
|
+
keys: number;
|
|
23
|
+
total: number;
|
|
24
|
+
done: boolean;
|
|
25
|
+
/** 0..1 */
|
|
26
|
+
p: number;
|
|
27
|
+
/** 커서를 그릴지 */
|
|
28
|
+
caret: boolean;
|
|
29
|
+
/** 마지막 키를 누른 뒤 지난 초 — 키 누름 튐 연출에 */
|
|
30
|
+
sinceKey: number;
|
|
31
|
+
/** 다 치는 데 걸리는 초 */
|
|
32
|
+
duration: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Plan {
|
|
36
|
+
frames: string[];
|
|
37
|
+
times: number[];
|
|
38
|
+
duration: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cache = new Map<string, Plan>();
|
|
42
|
+
|
|
43
|
+
function planOf(text: string, o: Required<Omit<TypeOpts, "at" | "blink">>): Plan {
|
|
44
|
+
const key = JSON.stringify([text, o.cps, o.jitter, o.seed, o.pause, o.ime]);
|
|
45
|
+
const hit = cache.get(key);
|
|
46
|
+
if (hit) return hit;
|
|
47
|
+
const frames = typingFrames(text, { ime: o.ime });
|
|
48
|
+
const base = 1 / Math.max(0.1, o.cps);
|
|
49
|
+
const times: number[] = [0];
|
|
50
|
+
let t = 0;
|
|
51
|
+
for (let k = 1; k < frames.length; k++) {
|
|
52
|
+
if (k > 1) {
|
|
53
|
+
const typed = frames[k - 1];
|
|
54
|
+
const last = typed.slice(-1);
|
|
55
|
+
let mul = 1;
|
|
56
|
+
if (last === "\n") mul = o.pause.newline ?? 6;
|
|
57
|
+
else if (/[.!?…。]/.test(last)) mul = o.pause.stop ?? 5;
|
|
58
|
+
else if (/[,、·:;]/.test(last)) mul = o.pause.comma ?? 3;
|
|
59
|
+
else if (last === " ") mul = o.pause.space ?? 1.6;
|
|
60
|
+
const j = o.jitter > 0 ? 1 + (rand(o.seed, k) * 2 - 1) * o.jitter : 1;
|
|
61
|
+
t += base * mul * j;
|
|
62
|
+
}
|
|
63
|
+
times.push(t);
|
|
64
|
+
}
|
|
65
|
+
const plan = { frames, times, duration: t };
|
|
66
|
+
if (cache.size > 256) cache.clear();
|
|
67
|
+
cache.set(key, plan);
|
|
68
|
+
return plan;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 첫 키 시각 at 부터 한 키씩 친다. times[k] 는 at 기준으로 k 번째 키를 누르는 때 */
|
|
72
|
+
export function typeText(text: string, t: number, opts: TypeOpts = {}): TypeState {
|
|
73
|
+
t = safeTime(t);
|
|
74
|
+
// 글이 아닌 값이 오면 빈 글 — String(NaN)·String(undefined) 가 그대로 화면에 찍히던 사고를 막는다
|
|
75
|
+
const src = typeof text === "string" ? text : typeof text === "number" && Number.isFinite(text) ? String(text) : "";
|
|
76
|
+
const o = {
|
|
77
|
+
cps: opts.cps ?? 16,
|
|
78
|
+
jitter: opts.jitter ?? 0.35,
|
|
79
|
+
seed: opts.seed ?? src,
|
|
80
|
+
pause: opts.pause ?? {},
|
|
81
|
+
ime: opts.ime ?? true,
|
|
82
|
+
};
|
|
83
|
+
const plan = planOf(src, o);
|
|
84
|
+
const local = t - (opts.at ?? 0);
|
|
85
|
+
const total = plan.frames.length - 1;
|
|
86
|
+
let k = 0;
|
|
87
|
+
if (local >= 0) {
|
|
88
|
+
let lo = 1;
|
|
89
|
+
let hi = total;
|
|
90
|
+
k = 0;
|
|
91
|
+
while (lo <= hi) {
|
|
92
|
+
const mid = (lo + hi) >> 1;
|
|
93
|
+
if (plan.times[mid] <= local + 1e-9) {
|
|
94
|
+
k = mid;
|
|
95
|
+
lo = mid + 1;
|
|
96
|
+
} else hi = mid - 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const done = k >= total;
|
|
100
|
+
const sinceKey = k > 0 ? local - plan.times[k] : Math.max(0, local);
|
|
101
|
+
const blink = opts.blink ?? 1.06;
|
|
102
|
+
// 치는 동안은 켜 두고 멈춰 있을 때만 깜빡인다 — 칠 때 깜빡이면 글자가 끊겨 보인다
|
|
103
|
+
const phase = (x: number) => Number.isFinite(x) && ((x % blink) + blink) % blink < blink / 2;
|
|
104
|
+
const caret = blink <= 0 || (k > 0 && !done) || phase(done ? local - plan.duration : local);
|
|
105
|
+
return {
|
|
106
|
+
text: plan.frames[k] ?? "",
|
|
107
|
+
keys: k,
|
|
108
|
+
total,
|
|
109
|
+
done,
|
|
110
|
+
p: total === 0 ? 1 : k / total,
|
|
111
|
+
caret,
|
|
112
|
+
sinceKey,
|
|
113
|
+
duration: plan.duration,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 여러 줄을 차례로 — 앞 줄이 끝나고 gap 초 뒤에 다음 줄 */
|
|
118
|
+
export function typeLines(lines: string[], t: number, opts: TypeOpts & { gap?: number } = {}): (TypeState & { line: number })[] {
|
|
119
|
+
t = safeTime(t);
|
|
120
|
+
const out: (TypeState & { line: number })[] = [];
|
|
121
|
+
let at = opts.at ?? 0;
|
|
122
|
+
for (let i = 0; i < lines.length; i++) {
|
|
123
|
+
const s = typeText(lines[i], t, { ...opts, at, seed: `${opts.seed ?? ""}${i}` });
|
|
124
|
+
out.push({ ...s, line: i });
|
|
125
|
+
at += s.duration + (opts.gap ?? 0.35);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 다 치는 데 걸리는 초 — 장면 길이를 맞출 때 */
|
|
131
|
+
export const typeDuration = (text: string, opts: TypeOpts = {}): number => typeText(text, Infinity, opts).duration;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { progress, toEase, type Ease, type EaseName, safeTime } from "@kasaframe/core";
|
|
2
|
+
import { typeText, type TypeOpts } from "./type.ts";
|
|
3
|
+
|
|
4
|
+
export interface CursorKey {
|
|
5
|
+
/** 이 자리에 닿는 시각(초) */
|
|
6
|
+
t: number;
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
/** 닿은 뒤 누른다 */
|
|
10
|
+
click?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CursorState {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
/** 누르고 있는 중 — 커서를 살짝 줄인다 */
|
|
17
|
+
pressed: boolean;
|
|
18
|
+
/** 누른 자리에서 퍼지는 물결. 없으면 null */
|
|
19
|
+
ripple: { x: number; y: number; scale: number; opacity: number } | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 키 사이를 감속 곡선으로 옮긴다. 사람 손처럼 살짝 휘게 가운데를 옆으로 민다 */
|
|
23
|
+
export function cursor(t: number, keys: CursorKey[], opts: { ease?: EaseName | Ease; arc?: number; pressDur?: number } = {}): CursorState {
|
|
24
|
+
t = safeTime(t);
|
|
25
|
+
if (keys.length === 0) return { x: 0, y: 0, pressed: false, ripple: null };
|
|
26
|
+
const ks = keys.slice().sort((a, b) => a.t - b.t);
|
|
27
|
+
const ease = toEase(opts.ease, toEase("inOutCubic"));
|
|
28
|
+
let x = ks[0].x;
|
|
29
|
+
let y = ks[0].y;
|
|
30
|
+
for (let i = 1; i < ks.length; i++) {
|
|
31
|
+
const a = ks[i - 1];
|
|
32
|
+
const b = ks[i];
|
|
33
|
+
if (t <= a.t) break;
|
|
34
|
+
// 앞 키에서 누른 시간만큼 기다렸다가 떠난다
|
|
35
|
+
const leave = a.t + (a.click ? (opts.pressDur ?? 0.14) + 0.08 : 0);
|
|
36
|
+
const p = ease(progress(t, leave, Math.max(0.05, b.t - leave)));
|
|
37
|
+
const bend = Math.sin(Math.PI * p) * (opts.arc ?? 0.12);
|
|
38
|
+
const dx = b.x - a.x;
|
|
39
|
+
const dy = b.y - a.y;
|
|
40
|
+
x = a.x + dx * p - dy * bend;
|
|
41
|
+
y = a.y + dy * p + dx * bend;
|
|
42
|
+
}
|
|
43
|
+
const press = opts.pressDur ?? 0.14;
|
|
44
|
+
let pressed = false;
|
|
45
|
+
let ripple: CursorState["ripple"] = null;
|
|
46
|
+
for (const k of ks) {
|
|
47
|
+
if (!k.click) continue;
|
|
48
|
+
const d = t - k.t;
|
|
49
|
+
if (d >= 0 && d < press) pressed = true;
|
|
50
|
+
if (d >= 0 && d < 0.6) {
|
|
51
|
+
const q = toEase("outCubic")(d / 0.6);
|
|
52
|
+
ripple = { x: k.x, y: k.y, scale: 0.2 + q, opacity: (1 - q) * 0.6 };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { x, y, pressed, ripple };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 막대·수치가 자란다. values 마다 stagger 만큼 늦게 */
|
|
59
|
+
export function grow(t: number, values: number[], opts: { at?: number; dur?: number; stagger?: number; ease?: EaseName | Ease; from?: number } = {}): number[] {
|
|
60
|
+
t = safeTime(t);
|
|
61
|
+
const ease = toEase(opts.ease, toEase("outCubic"));
|
|
62
|
+
return values.map((v, i) => {
|
|
63
|
+
const target = Number.isFinite(v) ? v : 0;
|
|
64
|
+
const from = opts.from ?? 0;
|
|
65
|
+
return from + (target - from) * ease(progress(t, (opts.at ?? 0) + i * (opts.stagger ?? 0.08), opts.dur ?? 0.9));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 선 그리기 진행도 — SVG stroke-dashoffset = 길이 × (1 - p) */
|
|
70
|
+
export const draw = (t: number, opts: { at?: number; dur?: number; ease?: EaseName | Ease } = {}): number =>
|
|
71
|
+
toEase(opts.ease, toEase("inOutCubic"))(progress(t, opts.at ?? 0, opts.dur ?? 1));
|
|
72
|
+
|
|
73
|
+
export interface CodeState {
|
|
74
|
+
lines: string[];
|
|
75
|
+
/** 지금 쓰는 줄(강조용). 다 나왔으면 -1 */
|
|
76
|
+
active: number;
|
|
77
|
+
done: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 코드 블록 — 줄 단위로 나타나거나(line) 타자로 친다(type) */
|
|
81
|
+
export function codeReveal(code: string, t: number, opts: { at?: number; mode?: "line" | "type"; lps?: number } & TypeOpts = {}): CodeState {
|
|
82
|
+
t = safeTime(t);
|
|
83
|
+
const src = typeof code === "string" ? code : "";
|
|
84
|
+
const all = src.split("\n");
|
|
85
|
+
if ((opts.mode ?? "line") === "type") {
|
|
86
|
+
const st = typeText(src, t, { cps: 40, jitter: 0.2, pause: { newline: 3 }, ...opts });
|
|
87
|
+
const lines = st.text.split("\n");
|
|
88
|
+
return { lines, active: st.done ? -1 : lines.length - 1, done: st.done };
|
|
89
|
+
}
|
|
90
|
+
const local = t - (opts.at ?? 0);
|
|
91
|
+
const n = local < 0 ? 0 : Math.min(all.length, Math.floor(local * (opts.lps ?? 8)) + 1);
|
|
92
|
+
return { lines: all.slice(0, n), active: n < all.length ? n - 1 : -1, done: n >= all.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface TermStep {
|
|
96
|
+
cmd: string;
|
|
97
|
+
/** 명령 뒤에 나오는 출력 줄 */
|
|
98
|
+
out?: string | string[];
|
|
99
|
+
prompt?: string;
|
|
100
|
+
/** 앞 단계가 끝나고 이 명령을 치기 전에 쉬는 초 */
|
|
101
|
+
wait?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface TermLine {
|
|
105
|
+
kind: "cmd" | "out";
|
|
106
|
+
prompt: string;
|
|
107
|
+
text: string;
|
|
108
|
+
/** 커서가 이 줄 끝에 있다 */
|
|
109
|
+
caret: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface TermState {
|
|
113
|
+
lines: TermLine[];
|
|
114
|
+
done: boolean;
|
|
115
|
+
/** 마지막 키를 누른 뒤 지난 초 */
|
|
116
|
+
sinceKey: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 터미널 창 + 입력. 명령은 타자로, 출력은 outDelay 뒤 줄마다 lineDelay 간격으로 나온다.
|
|
121
|
+
* 아직 안 온 줄은 아예 만들지 않는다 — 빈 줄을 그려 두면 자리가 비어 보인다.
|
|
122
|
+
*/
|
|
123
|
+
export function terminal(
|
|
124
|
+
t: number,
|
|
125
|
+
steps: TermStep[],
|
|
126
|
+
opts: { at?: number; prompt?: string; outDelay?: number; lineDelay?: number; maxLines?: number } & TypeOpts = {},
|
|
127
|
+
): TermState {
|
|
128
|
+
t = safeTime(t);
|
|
129
|
+
const lines: TermLine[] = [];
|
|
130
|
+
let at = opts.at ?? 0;
|
|
131
|
+
let sinceKey = Infinity;
|
|
132
|
+
let done = true;
|
|
133
|
+
for (let i = 0; i < steps.length; i++) {
|
|
134
|
+
const s = steps[i];
|
|
135
|
+
at += s.wait ?? (i === 0 ? 0 : 0.5);
|
|
136
|
+
if (t < at) {
|
|
137
|
+
done = false;
|
|
138
|
+
lines.push({ kind: "cmd", prompt: s.prompt ?? opts.prompt ?? "$", text: "", caret: true });
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const st = typeText(s.cmd, t, { ...opts, at, seed: `${opts.seed ?? "term"}${i}` });
|
|
142
|
+
lines.push({ kind: "cmd", prompt: s.prompt ?? opts.prompt ?? "$", text: st.text, caret: !st.done });
|
|
143
|
+
sinceKey = Math.min(sinceKey, st.sinceKey);
|
|
144
|
+
if (!st.done) {
|
|
145
|
+
done = false;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
at += st.duration + (opts.outDelay ?? 0.25);
|
|
149
|
+
const outs = s.out === undefined ? [] : Array.isArray(s.out) ? s.out : s.out.split("\n");
|
|
150
|
+
for (let j = 0; j < outs.length; j++) {
|
|
151
|
+
const show = at + j * (opts.lineDelay ?? 0.06);
|
|
152
|
+
if (t < show) {
|
|
153
|
+
done = false;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
lines.push({ kind: "out", prompt: "", text: typeof outs[j] === "string" ? outs[j] : "", caret: false });
|
|
157
|
+
}
|
|
158
|
+
if (!done) break;
|
|
159
|
+
at += outs.length * (opts.lineDelay ?? 0.06);
|
|
160
|
+
}
|
|
161
|
+
const last = lines[lines.length - 1];
|
|
162
|
+
if (done && last) lines.push({ kind: "cmd", prompt: opts.prompt ?? "$", text: "", caret: true });
|
|
163
|
+
const max = opts.maxLines ?? 0;
|
|
164
|
+
return { lines: max > 0 ? lines.slice(-max) : lines, done, sinceKey: Number.isFinite(sinceKey) ? sinceKey : 0 };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 문자열이 화면에 찍히기 전 마지막 거름 — 이상한 값이면 빈 글 */
|
|
168
|
+
export function safeText(v: unknown): string {
|
|
169
|
+
if (typeof v === "string") return v;
|
|
170
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kasaframe/plan",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@kasaframe/core": "*",
|
|
11
|
+
"@kasaframe/gen": "*",
|
|
12
|
+
"@kasaframe/motion": "*",
|
|
13
|
+
"@kasaframe/themes": "*"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { captionMode } from "@kasaframe/motion/captions";
|
|
3
|
+
import { loadTheme } from "@kasaframe/themes";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const repo = fileURLToPath(new URL("../../../", import.meta.url));
|
|
8
|
+
|
|
9
|
+
export interface ProjectScene {
|
|
10
|
+
id: string;
|
|
11
|
+
from: number;
|
|
12
|
+
dur: number;
|
|
13
|
+
file: string;
|
|
14
|
+
role: string;
|
|
15
|
+
idea: string;
|
|
16
|
+
assets?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProjectFile {
|
|
20
|
+
template: string;
|
|
21
|
+
theme?: string;
|
|
22
|
+
title: string;
|
|
23
|
+
fps: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
duration: number;
|
|
27
|
+
music: string | null;
|
|
28
|
+
beats: string | null;
|
|
29
|
+
captions: string;
|
|
30
|
+
safeArea: { top: number; bottom: number; left: number; right: number };
|
|
31
|
+
text: { policy: string; maxOnScreen: number; minPx: number };
|
|
32
|
+
scenes: ProjectScene[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readProject(dir: string): ProjectFile {
|
|
36
|
+
return JSON.parse(readFileSync(join(dir, "kf.json"), "utf8"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 모델이 부품 이름을 지어내지 않게 실제로 내보낸 함수의 첫 줄(인자·반환 타입)만 모은다
|
|
40
|
+
function signatures(pkg: string): string[] {
|
|
41
|
+
const src = join(repo, "packages", pkg, "src");
|
|
42
|
+
if (!existsSync(src)) return [];
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
for (const f of readdirSync(src).filter((x) => x.endsWith(".ts") && !x.endsWith(".test.ts") && x !== "index.ts" && x !== "captions.ts")) {
|
|
45
|
+
const text = readFileSync(join(src, f), "utf8");
|
|
46
|
+
for (const m of text.matchAll(/^export (?:function|const) (\w+)[^\n]*/gm)) {
|
|
47
|
+
const line = m[0].replace(/\s*\{\s*$/, "").replace(/^export (function|const) /, "");
|
|
48
|
+
if (line.length < 220) out.push(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TEXT_POLICY: Record<string, string> = {
|
|
55
|
+
none: "화면에 문장을 쓰지 않는다. 기능 낱말 하나·제품 이름·끝 카드만.",
|
|
56
|
+
"terms-only": "화면 글은 전문 낱말과 숫자만(한 번에 몇 개까지). 말은 자막 트랙으로 따로 간다.",
|
|
57
|
+
captions: "소리 없이도 읽히게 — 들릴 말은 captions.json 에 쓰면 엔진이 화면 아래 자막 판에 얹는다. 장면 글은 핵심 낱말만.",
|
|
58
|
+
lyrics: "가사는 키네틱 타이포로 — 부른 낱말이 부른 시각에 뜨고, 뜻을 그림·움직임으로 받친다. 슬로건 금지는 가사에 걸지 않는다.",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function sceneBeats(dir: string, kf: ProjectFile, s: ProjectScene): string {
|
|
62
|
+
if (!kf.beats || !existsSync(join(dir, kf.beats))) return "곡 없음";
|
|
63
|
+
const b = JSON.parse(readFileSync(join(dir, kf.beats), "utf8"));
|
|
64
|
+
const inside = (xs: number[] = []) => xs.filter((t) => t >= s.from && t < s.from + s.dur).map((t) => +(t - s.from).toFixed(2));
|
|
65
|
+
const drops = inside(b.drops);
|
|
66
|
+
const bars = inside(b.bars ?? b.downbeats);
|
|
67
|
+
return `${b.bpm?.toFixed?.(1) ?? "?"} BPM · 장면 안 드롭 ${drops.length ? drops.join(", ") + "초" : "없음"} · 마디 ${bars.slice(0, 12).join(", ")}${bars.length > 12 ? " …" : ""}초`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface PurposeRules {
|
|
71
|
+
staticSec: { warn: number; error: number };
|
|
72
|
+
beatMove: "every-beat" | "drop-sections";
|
|
73
|
+
maxFreezeSec: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 틀(templates/<용도>/template.json motion)이 기본값을 덮는다 — kf check 도 같은 값을 읽는다
|
|
77
|
+
function purposeRules(id: string): PurposeRules {
|
|
78
|
+
const file = join(repo, "templates", id, "template.json");
|
|
79
|
+
const m = existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")).motion ?? {}) : {};
|
|
80
|
+
return { staticSec: m.staticSec ?? { warn: 1.5, error: 3 }, beatMove: m.beatMove ?? "every-beat", maxFreezeSec: m.maxFreezeSec ?? 0.1 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 용도별 표 — 같은 값을 쓰는 틀끼리 한 줄로
|
|
84
|
+
export function purposeTable(): string[] {
|
|
85
|
+
const dir = join(repo, "templates");
|
|
86
|
+
const ids = existsSync(dir) ? readdirSync(dir).filter((d) => existsSync(join(dir, d, "template.json"))) : [];
|
|
87
|
+
const groups = new Map<string, string[]>();
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
const r = purposeRules(id);
|
|
90
|
+
const key = `같은 구도 ${r.staticSec.warn}초 경고·${r.staticSec.error}초 오류 · 큰 움직임 ${r.beatMove === "every-beat" ? "박마다" : "후렴·드롭 구간만(벌스는 화면 안 움직임으로 충분)"} · 정지 ${r.maxFreezeSec}초 금지`;
|
|
91
|
+
groups.set(key, [...(groups.get(key) ?? []), id]);
|
|
92
|
+
}
|
|
93
|
+
return [...groups].map(([k, v]) => `- ${v.join("·")}: ${k}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dropSections(dir: string, kf: ProjectFile): string {
|
|
97
|
+
if (!kf.beats || !existsSync(join(dir, kf.beats))) return "곡이 없어 드롭 구간도 없다 — 화면 안 움직임으로 간다";
|
|
98
|
+
const b = JSON.parse(readFileSync(join(dir, kf.beats), "utf8"));
|
|
99
|
+
const secs: { t: number; kind: string }[] = b.sections ?? [];
|
|
100
|
+
const spans: { t: number; to: number }[] = [];
|
|
101
|
+
secs.forEach((x, i) => {
|
|
102
|
+
if (x.kind !== "drop") return;
|
|
103
|
+
const to = secs[i + 1]?.t ?? kf.duration;
|
|
104
|
+
const last = spans[spans.length - 1];
|
|
105
|
+
if (last && Math.abs(last.to - x.t) < 0.05) last.to = to;
|
|
106
|
+
else spans.push({ t: x.t, to });
|
|
107
|
+
});
|
|
108
|
+
return spans.length ? spans.map((x) => `${x.t.toFixed(2)}–${x.to.toFixed(2)}초`).join(", ") : "드롭 구간이 잡히지 않았다";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 장면을 채우는 모델(Claude Code 든 OG 의 아무 모델이든)이 읽는 한 장. 사용서(docs/AGENTS.md)가 있으면 뒤에 붙인다
|
|
112
|
+
export function projectBrief(dir: string): string {
|
|
113
|
+
const kf = readProject(dir);
|
|
114
|
+
const style = JSON.parse(readFileSync(join(dir, "style.json"), "utf8"));
|
|
115
|
+
const plan = existsSync(join(dir, "plan.json")) ? JSON.parse(readFileSync(join(dir, "plan.json"), "utf8")) : {};
|
|
116
|
+
const rules = purposeRules(kf.template);
|
|
117
|
+
const tplFile = join(repo, "templates", kf.template, "template.json");
|
|
118
|
+
const tplMotion = existsSync(tplFile) ? (JSON.parse(readFileSync(tplFile, "utf8")).motion ?? {}) : {};
|
|
119
|
+
const colors = Object.keys(style.colors ?? {}).map((k: string) => `--kf-${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`);
|
|
120
|
+
const core = signatures("core");
|
|
121
|
+
const motion = signatures("motion");
|
|
122
|
+
const agents = join(repo, "docs", "AGENTS.md");
|
|
123
|
+
const lines = [
|
|
124
|
+
`# ${kf.title || plan.request || "kasaframe 프로젝트"} — 장면 작성 지시서`,
|
|
125
|
+
"",
|
|
126
|
+
`**이 영상의 규칙 — 용도 ${kf.template}(틀이 기본값을 덮는다, kf check 가 같은 값으로 잡는다)**`,
|
|
127
|
+
`1. 화면은 ${rules.maxFreezeSec}초(60fps 6프레임) 넘게 멈추지 않는다 — 끝 카드·큰 숫자·로고도 예외 없이 화면 안 움직임(숫자 굴러가기·칸 채우기·반짝임·글자 흐름·바탕 흐름)으로 계속 살아 있게.`,
|
|
128
|
+
`2. 같은 구도는 ${rules.staticSec.warn}초 넘기지 않는다(${rules.staticSec.error}초 넘으면 오류) — 새 요소·칸 채우기·숫자 키우기·컷·전환으로 바꾼다.`,
|
|
129
|
+
rules.beatMove === "every-beat"
|
|
130
|
+
? "3. 미세하게 흔들리는 것만으로 때우지 않는다 — 눈에 보이는 큰 움직임이 박마다 하나 이상."
|
|
131
|
+
: `3. 눈에 보이는 큰 움직임을 박마다 요구하는 건 후렴·드롭 구간(beats.json sections 의 drop — ${dropSections(dir, kf)})뿐이다. 벌스·브레이크는 화면 안 움직임(가사·숫자·바탕 흐름)으로 충분하다 — 절제했다가 드롭에서 터뜨린다.`,
|
|
132
|
+
"4. **카메라는 이유가 있을 때만** 움직인다 — 기본은 정지, 주인공은 안전 영역 가운데. 드러냄에 다가가기(`stage.camera.pushIn`)·비교에 물러나기(`pullOut`)·주인공 따라가기(`follow`)·드롭에 한 번 툭(`impact`), 끝나면 선다. 화면 전체를 사인으로 흔들거나 박마다 자세를 바꾸지 않는다(kf check `camera.wander`·`layout.edge`). 회전·원근 기울기는 0.",
|
|
133
|
+
"5. **섬광은 1초에 3번까지**(kf check frame.flash — 빛 민감 발작). 화면 25% 넘게 번쩍이는 건 두 박·한 마디에 한 번, 빨강 전체 깜빡임·대비 큰 줄무늬 연타·어둠↔흰 화면 컷 이어 붙이기는 쓰지 않는다.",
|
|
134
|
+
"",
|
|
135
|
+
"용도별 기본값(templates/<용도>/template.json motion):",
|
|
136
|
+
...purposeTable(),
|
|
137
|
+
"",
|
|
138
|
+
"그림 보기: `npx kf look <폴더>` 가 찍는 장면 모음(out/look-sheet.png)을 Read 로 직접 보고 장면별 확인표(out/look.md)대로 고친다. **Read 로 그림이 안 보이면(눈 없는 모델)만** `npx kf look <폴더> --vision-model google/gemini-3.5-flash [--ask \"<볼 것>\"]` — 그림 보는 모델이 장면마다 글로 알려 준다.",
|
|
139
|
+
"",
|
|
140
|
+
`요청: ${plan.request ?? "(없음)"}`,
|
|
141
|
+
`용도 ${kf.template} · ${kf.width}×${kf.height} · ${kf.fps}fps · ${kf.duration}초 · 스타일 ${style.name}(${style.scheme}) · 곡 ${kf.music ?? "없음"} · 자막 ${kf.captions}`,
|
|
142
|
+
`안전 영역(글은 이 안에): 위 ${kf.safeArea.top}px · 아래 ${kf.safeArea.bottom}px · 왼쪽 ${kf.safeArea.left}px · 오른쪽 ${kf.safeArea.right}px`,
|
|
143
|
+
`화면 글: ${TEXT_POLICY[kf.text?.policy] ?? kf.text?.policy} 글자 크기 최소 ${kf.text?.minPx}px.`,
|
|
144
|
+
`움직임 규칙(style.json motion 위에 틀 motion): ${JSON.stringify({ ...style.motion, ...tplMotion })}`,
|
|
145
|
+
...(style.notes?.length ? ["디자인 메모: " + style.notes.join(" / ")] : []),
|
|
146
|
+
"",
|
|
147
|
+
"## 장면 파일 꼴 — 이 꼴만 쓴다",
|
|
148
|
+
"",
|
|
149
|
+
"```ts",
|
|
150
|
+
'import { applyStyle, clip, ease, type SceneCtx } from "@kasaframe/core";',
|
|
151
|
+
"export default {",
|
|
152
|
+
" build(root: HTMLElement, ctx: SceneCtx) {",
|
|
153
|
+
" const box = document.createElement(\"div\"); // root.style.display 는 엔진이 켜고 끈다 — 배치는 box 에",
|
|
154
|
+
" box.style.cssText = \"position:absolute;inset:0;overflow:hidden;background:var(--kf-bg)\";",
|
|
155
|
+
" root.append(box);",
|
|
156
|
+
" // DOM 은 여기서 한 번만 짓는다",
|
|
157
|
+
" return (t: number) => { /* t = 장면 안 초(0..ctx.dur). 같은 t 면 같은 화면 */ };",
|
|
158
|
+
" },",
|
|
159
|
+
"};",
|
|
160
|
+
"```",
|
|
161
|
+
"",
|
|
162
|
+
"- 시각 함수 밖에서 상태를 쌓지 않는다(앞 프레임 값을 기억하지 않는다). 탭 여러 개가 아무 시각이나 찍는다.",
|
|
163
|
+
"- `Math.random`·`Date.now`·`performance.now`·`setTimeout` 금지 — 난수는 `rand(seed, i)`, 시간은 `t` 만.",
|
|
164
|
+
"- 글자에 `undefined`·`NaN`·`null` 이 찍히지 않게 값은 미리 문자열로 만든다. 이모지 금지(아이콘은 인라인 SVG).",
|
|
165
|
+
"- 장면이 1초 넘게 멈추지 않게 한다(아주 느린 다가가기라도). 빈 화면으로 시작·끝내지 않는다.",
|
|
166
|
+
"- **화면을 채운다**(kf check layout.fill): 주 요소가 안전 영역을 크게 차지한다 — 세로 화면은 폭의 70% 넘게, 글은 큰 글자 단계(hero·title)로. 가운데 작은 그림 하나에 나머지가 흰 여백이면 실패다.",
|
|
167
|
+
"- 구도 판정(kf check layout.static)은 칸 평균 밝기와 화면 안 내용(글·칸의 센 가장자리)으로 본다 — 숫자가 구르고 칸이 차면 바뀐 것이고, 부드럽게 흐르는 바탕만으로는 안 바뀐다. 카메라 샷으로 메우지 않는다.",
|
|
168
|
+
"- `../lib/stage.ts` 의 `makeStage(root, ctx)`(늘 흐르는 빛 방울 바탕·컷마다 모양을 바꾸는 바탕 판·정지 카메라 `stage.camera`)·`makeCamera(layer, ctx, { frame })`(자기 카메라 층에)·`bigWord`·`fitPx`·`cutTimes`·`lastT` 를 써도 된다 — 위 규칙을 처음부터 넘게 짜 둔 도구다. 카메라가 요소를 겨누면 `stage.tick(t)` 은 내용을 칠한 뒤 맨 끝에.",
|
|
169
|
+
"- 박에 붙이는 움직임은 `ctx.beats`(장면 시작 0 기준: `ctx.beats.drops`, `ctx.beats.nearestBeat(t)`, `ctx.beats.pulse(t, {...})`). 시각을 BPM 으로 곱해 만들지 않는다.",
|
|
170
|
+
"- 자산은 `ctx.asset(\"gen/<id>.png\")` 처럼 assets/ 기준 경로로. 없는 파일을 가리키지 않는다.",
|
|
171
|
+
"- 색·글꼴·크기는 CSS 변수로: " + [...colors, "--kf-font-sans", "--kf-font-display", "--kf-font-mono", "--kf-size-hero", "--kf-size-title", "--kf-size-body", "--kf-size-caption", "--kf-radius-md", "--kf-shadow-soft"].join(" "),
|
|
172
|
+
"",
|
|
173
|
+
"## 쓸 수 있는 함수",
|
|
174
|
+
"",
|
|
175
|
+
"`@kasaframe/core`:",
|
|
176
|
+
...core.map((s) => `- ${s}`),
|
|
177
|
+
"",
|
|
178
|
+
...(motion.length ? ["`@kasaframe/motion`(움직임 부품 — 숫자만 넣으면 Style 이나 상태를 돌려준다. applyStyle 로 붙인다):", ...motion.map((s) => `- ${s}`), ""] : []),
|
|
179
|
+
"## 장면",
|
|
180
|
+
"",
|
|
181
|
+
...kf.scenes.flatMap((s) => [
|
|
182
|
+
`### ${s.id} — ${s.file} (${s.from.toFixed(2)}–${(s.from + s.dur).toFixed(2)}초, ${s.dur.toFixed(2)}초, 역할 ${s.role})`,
|
|
183
|
+
`- 할 일: ${s.idea}`,
|
|
184
|
+
...(s.role === "seam" ? ["- 이 장면 파일은 고치지 않는다 — 3D 전환(transition3d)이 앞뒤 화면을 이어 준다"] : []),
|
|
185
|
+
`- 박: ${sceneBeats(dir, kf, s)}`,
|
|
186
|
+
...(s.assets?.length ? [`- 자산: ${s.assets.map((a) => a.replace(/^gen:(img|vid):(.+)$/, (_, k, id) => `assets/gen/${id}.${k === "img" ? "png" : "mp4"}`)).join(", ")}`] : []),
|
|
187
|
+
"",
|
|
188
|
+
]),
|
|
189
|
+
...genSection(dir, kf, plan),
|
|
190
|
+
...themeSection(kf.theme, plan),
|
|
191
|
+
...(existsSync(join(dir, "lyrics.json"))
|
|
192
|
+
? ["## 가사(lyrics.json — 낱말 시각까지)", "", '`import.meta.glob("../lyrics.json", { eager: true, import: "default" })` 로 읽는다(꼴 `{ lines: [{ text, start, end, words?: [{ w, start, end }] }] }`, 시각은 영상 전체 기준 초 — 장면 안 시각은 `start - ctx.from`). 낱말이 없으면 줄을 글자 수 비율로 나눈다.', ""]
|
|
193
|
+
: []),
|
|
194
|
+
...(existsSync(join(dir, "assets", "in"))
|
|
195
|
+
? ["## 받은 자산(사람이 준 것 — 쓸 수 있으면 쓴다)", "", ...readdirSync(join(dir, "assets", "in")).map((f) => `- \`ctx.asset("in/${f}")\``), ""]
|
|
196
|
+
: []),
|
|
197
|
+
...(captionMode(kf.captions) !== "none"
|
|
198
|
+
? [
|
|
199
|
+
"## 자막",
|
|
200
|
+
"",
|
|
201
|
+
'들릴 말은 프로젝트 `captions.json` 에 `[{"scene": "장면 id", "text": "짧은 마디를 쉼표로"}]` 로 쓴다 — `kf make --finish` 가 out/captions.ko.srt·vtt 로 만든다. 장면 파일에는 문장을 쓰지 않는다.',
|
|
202
|
+
captionMode(kf.captions) === "on"
|
|
203
|
+
? `이 영상은 자막을 화면에도 얹는다 — 엔진이 장면 위에 판을 깐다(안전 영역 아래쪽 가운데, 두 줄까지). 그 자리(아래 가장자리에서 ${kf.safeArea.bottom}px 위 언저리)에 장면의 큰 글을 두지 않는다.`
|
|
204
|
+
: "화면에는 얹지 않는다(kf render --captions on 이면 그 판만 얹는다).",
|
|
205
|
+
"",
|
|
206
|
+
]
|
|
207
|
+
: []),
|
|
208
|
+
...(existsSync(agents) ? ["## 사용서(docs/AGENTS.md)", "", readFileSync(agents, "utf8")] : []),
|
|
209
|
+
];
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 테마를 골랐으면 그 비유·금지·효과 쓰는 꼴을 그대로 싣는다(SPEC 9). auto 인데 맞는 테마가 없으면 강한 모델이 새 테마를 draft 로 써 넣을 수 있다
|
|
214
|
+
function themeSection(id: string | undefined, plan: { themeCandidates?: { id: string; why: string }[]; request?: string }): string[] {
|
|
215
|
+
if (id) {
|
|
216
|
+
const t = loadTheme(id);
|
|
217
|
+
const pick = plan.themeCandidates?.find((c) => c.id === id);
|
|
218
|
+
return [
|
|
219
|
+
`## 테마 ${t.name}(${t.id}) — ${t.metaphor}`,
|
|
220
|
+
"",
|
|
221
|
+
...(pick ? [`고른 이유: ${pick.why}`, ""] : []),
|
|
222
|
+
`효과는 복사하지 말고 가져다 쓴다: \`import { fx } from "@kasaframe/themes/fx"; const { ${t.effects.map((e) => e.name).join(", ")} } = fx("${t.id}");\``,
|
|
223
|
+
"",
|
|
224
|
+
...t.effects.map((e) => `- \`${e.name}\`(${e.kind}) — ${e.does} · \`${e.use}\``),
|
|
225
|
+
"",
|
|
226
|
+
...(plan.themeCandidates && plan.themeCandidates.length > 1 ? [`다른 후보: ${plan.themeCandidates.slice(1).map((c) => c.id).join(" · ")} — 첫 장면을 각 테마로 찍은 그림은 out/theme-<id>.png`, ""] : []),
|
|
227
|
+
t.guide,
|
|
228
|
+
"",
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
if (plan.themeCandidates && !plan.themeCandidates.length) {
|
|
232
|
+
return [
|
|
233
|
+
"## 테마 — 맞는 approved 테마가 없다",
|
|
234
|
+
"",
|
|
235
|
+
"프리셋 style 로 간다. 이 내용에 맞는 비유가 뚜렷하면 SPEC 계약 9 대로 `themes/<새 id>/` 에 테마 팩(theme.json status draft · THEME.md · style.json · effects.ts 5–10개)을 써 넣어도 된다 — 사람이 견본을 보고 OK 하기 전까지는 draft 라 이 프로젝트에는 쓰지 않는다.",
|
|
236
|
+
"",
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 그림 생성은 돈이 들고, 막연한 자동 프롬프트는 엉뚱한 그림을 냈다 — 아직 없는 그림은 목록으로만 내고 세션이 프롬프트를 써서 만든다
|
|
243
|
+
function genSection(dir: string, kf: ProjectFile, plan: { gen?: { id: string; kind: string; scene?: string; prompt: string; size?: string }[] }): string[] {
|
|
244
|
+
const todo = (plan.gen ?? []).filter((g) => g.kind === "img" && !existsSync(join(dir, "assets", "gen", `${g.id}.png`)));
|
|
245
|
+
if (!todo.length) return [];
|
|
246
|
+
return [
|
|
247
|
+
"## 만들 그림 — 아직 안 만들었다(묻지 않고 돈을 쓰지 않는다)",
|
|
248
|
+
"",
|
|
249
|
+
"받은 자산·실제 화면 오리기·도형으로 되면 만들지 않는다. 만들 땐 요청 내용으로 **구체적인** 프롬프트를 써서(무엇이·어떤 구도로·어떤 색·글자 없음):",
|
|
250
|
+
"",
|
|
251
|
+
...todo.map((g) => `- \`${g.id}\`(장면 ${g.scene ?? "-"}) — 틀이 제안한 뜻: ${g.prompt}\n \`npx kf gen img "<구체적 프롬프트>" --id ${g.id} --size ${g.size ?? `${kf.width}x${kf.height}`} --project ${dir}\` → \`ctx.asset("gen/${g.id}.png")\``),
|
|
252
|
+
"",
|
|
253
|
+
];
|
|
254
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { captionMetrics, captionMode, toCues, type CaptionLine, type CaptionMode, type Cue } from "@kasaframe/motion/captions";
|
|
4
|
+
import { readProject } from "./brief.ts";
|
|
5
|
+
|
|
6
|
+
// 자막 트랙 — srt(유튜브)·vtt(사이트). OG 쇼츠 scripts/srt3d.py 의 관례를 따른다.
|
|
7
|
+
// 마디 나누기는 화면 자막 판(@kasaframe/motion/captions)과 같은 toCues 라 트랙과 화면이 한 마디도 어긋나지 않는다.
|
|
8
|
+
|
|
9
|
+
export { toCues };
|
|
10
|
+
export type { CaptionLine, Cue };
|
|
11
|
+
|
|
12
|
+
function ts(s: number, sep: string): string {
|
|
13
|
+
const ms = Math.round(s * 1000);
|
|
14
|
+
const p = (n: number, w = 2) => String(n).padStart(w, "0");
|
|
15
|
+
return `${p(Math.floor(ms / 3600000))}:${p(Math.floor(ms / 60000) % 60)}:${p(Math.floor(ms / 1000) % 60)}${sep}${p(ms % 1000, 3)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function srt(cues: Cue[]): string {
|
|
19
|
+
return cues.map((c, i) => `${i + 1}\n${ts(c.from, ",")} --> ${ts(c.to, ",")}\n${c.text}\n`).join("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function vtt(cues: Cue[]): string {
|
|
23
|
+
return `WEBVTT\n\n${cues.map((c) => `${ts(c.from, ".")} --> ${ts(c.to, ".")}\n${c.text}\n`).join("\n")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 뮤비 가사. 시각이 붙은 lyrics.json(natsu_mv align_lyrics.py 꼴: {lines:[{text,start,end}]})이 있으면 그대로,
|
|
27
|
+
// 시각 없는 lyrics.txt 뿐이면 인트로·아웃트로를 뺀 구간에 줄 길이 비율로 대충 편다 — 정확한 시각은 보컬 분리(demucs) + 강제 정렬로(큰 모델이라 GPU 있는 기계에서)
|
|
28
|
+
function lyricLines(dir: string, scenes: { id: string; role: string; from: number; dur: number }[]): CaptionLine[] | null {
|
|
29
|
+
const find = (f: string) => [join(dir, f), join(dir, "assets", "in", f)].find((p) => existsSync(p));
|
|
30
|
+
const timed = find("lyrics.json");
|
|
31
|
+
if (timed) {
|
|
32
|
+
const j = JSON.parse(readFileSync(timed, "utf8")) as { lines?: { text: string; start: number; end: number; words?: { w: string; start: number; end: number }[] }[] };
|
|
33
|
+
return (j.lines ?? []).map((l) => ({ from: l.start, to: l.end, text: l.text, words: l.words?.map((w) => ({ text: w.w, from: w.start, to: w.end })) }));
|
|
34
|
+
}
|
|
35
|
+
const plain = find("lyrics.txt");
|
|
36
|
+
if (!plain) return null;
|
|
37
|
+
const lines = readFileSync(plain, "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
38
|
+
const sung = scenes.filter((s) => !/intro|outro/.test(s.role));
|
|
39
|
+
const total = sung.reduce((a, s) => a + s.dur, 0);
|
|
40
|
+
const chars = lines.reduce((a, l) => a + l.length, 0);
|
|
41
|
+
const out: CaptionLine[] = [];
|
|
42
|
+
let k = 0;
|
|
43
|
+
let acc = 0;
|
|
44
|
+
for (const l of lines) {
|
|
45
|
+
let at = (acc / chars) * total;
|
|
46
|
+
while (k < sung.length - 1 && at > sung[k].dur) {
|
|
47
|
+
at -= sung[k].dur;
|
|
48
|
+
acc -= (sung[k].dur / total) * chars;
|
|
49
|
+
k++;
|
|
50
|
+
}
|
|
51
|
+
const from = sung[k].from + at;
|
|
52
|
+
out.push({ from, to: from + (l.length / chars) * total, text: l });
|
|
53
|
+
acc += l.length;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 프로젝트의 captions.json(또는 가사) → out/captions.ko.srt·vtt. 자막 none 이거나 들릴 말이 없으면 빈 배열. mode 는 kf.json 을 그 판만 덮는다
|
|
59
|
+
export function writeCaptions(dir: string, mode?: CaptionMode): string[] {
|
|
60
|
+
const kf = readProject(dir);
|
|
61
|
+
// captions 칸이 없는 옛 프로젝트는 들릴 말이 있으면 트랙만 낸다
|
|
62
|
+
if ((mode ?? (kf.captions === undefined ? "off" : captionMode(kf.captions))) === "none") return [];
|
|
63
|
+
const scenes = kf.scenes;
|
|
64
|
+
const file = join(dir, "captions.json");
|
|
65
|
+
const lines = existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as CaptionLine[]) : lyricLines(dir, scenes);
|
|
66
|
+
if (!lines) return [];
|
|
67
|
+
const maxEm = kf.width && kf.height ? captionMetrics({ width: kf.width, height: kf.height, safe: kf.safeArea, minPx: kf.text?.minPx }).maxEm : undefined;
|
|
68
|
+
const cues = toCues(lines, scenes, { maxEm, duration: kf.duration });
|
|
69
|
+
if (!cues.length) return [];
|
|
70
|
+
mkdirSync(join(dir, "out"), { recursive: true });
|
|
71
|
+
const out = [join(dir, "out", "captions.ko.srt"), join(dir, "out", "captions.ko.vtt")];
|
|
72
|
+
writeFileSync(out[0], srt(cues));
|
|
73
|
+
writeFileSync(out[1], vtt(cues));
|
|
74
|
+
return out;
|
|
75
|
+
}
|