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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { definePattern } from "../../pattern.ts";
|
|
2
|
+
import { clamp01, el, ems, glide, outBack, times } from "../util.ts";
|
|
3
|
+
|
|
4
|
+
interface Part {
|
|
5
|
+
text: string;
|
|
6
|
+
kind?: "term" | "op" | "value" | "result";
|
|
7
|
+
}
|
|
8
|
+
interface Formula {
|
|
9
|
+
parts: Part[];
|
|
10
|
+
label?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 식이 박마다 한 항씩 들어와 선다 — 연산자는 작고 흐리게, 결과 항은 강조색. 강조 띠가 늘 「지금 볼 항」을 짚는다:
|
|
14
|
+
// 들어오는 동안은 다음 항이 설 자리로 마중 가고, 다 선 뒤엔 박마다 항을 차례로 짚는다. 띠는 정지점 사이를 내내 옮겨 가
|
|
15
|
+
// 화면이 멈추지 않는다(뜻 없는 흔들림 대신 짚기가 쉬지 않는 몫을 진다). 카메라는 정지 — 식이 가로로 길어 다가가면 앞 항이 잘린다.
|
|
16
|
+
|
|
17
|
+
const kindOf = (p: Part, i: number, all: Part[]): NonNullable<Part["kind"]> =>
|
|
18
|
+
p.kind ?? (/^[=×x*+\-−÷/≈→]$/.test(p.text) ? "op" : all[i - 1]?.text === "=" && i === all.length - 1 ? "result" : /^\d/.test(p.text) ? "value" : "term");
|
|
19
|
+
|
|
20
|
+
export default definePattern<Formula>({
|
|
21
|
+
id: "formula.stack",
|
|
22
|
+
type: "formula",
|
|
23
|
+
does: "식이 박마다 한 항씩 들어와 서고, 강조 띠가 항을 차례로 짚는다",
|
|
24
|
+
energy: ["low", "mid"],
|
|
25
|
+
family: "type",
|
|
26
|
+
camera: ["none"],
|
|
27
|
+
bg: "stage",
|
|
28
|
+
aspects: ["16:9", "9:16", "1:1"],
|
|
29
|
+
dur: { min: 2, max: 14 },
|
|
30
|
+
themes: { "og-measure": 0.5, "debug-hud": 0.5 },
|
|
31
|
+
fits: (c, info) => (c.parts.length * 0.35 > info.dur ? `항 ${c.parts.length}개를 ${info.dur.toFixed(1)}초에 세우기엔 짧다` : true),
|
|
32
|
+
build(root, ctx, c, kit) {
|
|
33
|
+
const W = kit.stage.safeW;
|
|
34
|
+
const H = kit.stage.safeH;
|
|
35
|
+
const parts = c.parts.map((p, i, all) => ({ text: p.text, kind: kindOf(p, i, all) }));
|
|
36
|
+
// 줄 수 — 세로 화면은 한 줄 폭이 좁아 셋까지 나눈다
|
|
37
|
+
const total = parts.reduce((a, p) => a + ems(p.text) * (p.kind === "op" ? 0.7 : 1) + 0.5, 0);
|
|
38
|
+
const lines = Math.min(kit.stage.vertical ? 3 : 2, Math.max(1, Math.ceil(total / (kit.stage.vertical ? 6 : 10))));
|
|
39
|
+
const px = Math.round(Math.max(56, Math.min(kit.stage.vertical ? 190 : 170, (W * 0.88 * lines) / total, (H * 0.6) / (lines * 1.3))));
|
|
40
|
+
|
|
41
|
+
root.style.gap = `${Math.round(px * 0.35)}px`;
|
|
42
|
+
if (c.label) root.append(el("div", `font-family:var(--kf-font-sans);font-weight:700;font-size:${Math.max(48, Math.round(px * 0.42))}px;color:var(--kf-dim);letter-spacing:.04em;white-space:nowrap`, c.label));
|
|
43
|
+
const line = el("div", `position:relative;display:flex;flex-wrap:wrap;justify-content:center;align-items:baseline;column-gap:${Math.round(px * 0.28)}px;row-gap:${Math.round(px * 0.2)}px;max-width:${Math.round(W * 0.94)}px`);
|
|
44
|
+
root.append(line);
|
|
45
|
+
kit.stage.hero(line);
|
|
46
|
+
const band = el("div", `position:absolute;left:0;top:0;box-sizing:border-box;border-radius:${Math.round(px * 0.18)}px;background:color-mix(in srgb, var(--kf-accent) 22%, transparent);border:${Math.max(3, Math.round(px * 0.03))}px solid var(--kf-accent);pointer-events:none`);
|
|
47
|
+
line.append(band);
|
|
48
|
+
const els = parts.map((p) => {
|
|
49
|
+
const color = p.kind === "op" ? "var(--kf-dim)" : p.kind === "result" ? "var(--kf-accent)" : "var(--kf-ink)";
|
|
50
|
+
const size = p.kind === "op" ? Math.round(px * 0.72) : px;
|
|
51
|
+
const e = el("span", `position:relative;display:inline-block;font-family:var(--kf-font-display);font-weight:${p.kind === "op" ? 500 : 800};font-size:${size}px;line-height:1.15;color:${color};white-space:nowrap;font-variant-numeric:tabular-nums`, p.text);
|
|
52
|
+
line.append(e);
|
|
53
|
+
return e;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 첫 항은 장면 첫 프레임에 바로 들어오기 시작한다 — 첫 박을 기다리면 앞 장면(이음매)에서 넘어온 화면이 그만큼 멈춘다
|
|
57
|
+
const at = times(kit.hits, parts.length, 0, Math.min(ctx.dur * 0.55, 0.3 + parts.length * 0.4));
|
|
58
|
+
at[0] = 0;
|
|
59
|
+
const marks = parts.map((p, i) => ({ p, i })).filter((x) => x.p.kind !== "op").map((x) => x.i);
|
|
60
|
+
const after = kit.hits.filter((h) => h > at[at.length - 1] + 0.3);
|
|
61
|
+
// 들어오는 항마다 한 정지점, 다 선 뒤엔 박마다 다음 항 — 마지막은 장면 밖 다음 항으로 가는 중에 끝난다
|
|
62
|
+
const visits = [...marks.map((i) => ({ t: at[i], i })), ...after.map((t, j) => ({ t, i: marks[j % marks.length] }))];
|
|
63
|
+
visits.push({ t: ctx.dur + 0.4, i: marks[after.length % marks.length] });
|
|
64
|
+
const visitAt = visits.map((v) => v.t);
|
|
65
|
+
|
|
66
|
+
let boxes: { x: number; y: number; w: number; h: number }[] | null = null;
|
|
67
|
+
return (t: number) => {
|
|
68
|
+
for (const [i, e] of els.entries()) {
|
|
69
|
+
const p = clamp01((t - at[i]) / 0.35);
|
|
70
|
+
e.style.opacity = p > 0 ? Math.min(1, p * 2).toFixed(3) : "0";
|
|
71
|
+
e.style.transform = `translateY(${((1 - outBack(p)) * px * 0.5).toFixed(1)}px)`;
|
|
72
|
+
// 항은 다음 항이 들어올 때까지 왼쪽부터 써지듯 열린다 — 들어오는 동안 내내 글 면적이 바뀌어 박 사이가 멈추지 않는다
|
|
73
|
+
const w = clamp01((t - at[i]) / Math.min(0.9, Math.max(0.35, (at[i + 1] ?? at[i] + 0.6) - at[i])));
|
|
74
|
+
e.style.clipPath = w >= 1 ? "" : `inset(-20% ${((1 - w) * 100).toFixed(2)}% -20% -5%)`;
|
|
75
|
+
}
|
|
76
|
+
// 항 자리는 처음 보일 때 잰다(build 땐 장면이 숨어 있어 0) — 띠는 그 상자 사이를 옮겨 간다
|
|
77
|
+
if (!boxes && line.offsetWidth > 0) boxes = els.map((e) => ({ x: e.offsetLeft, y: e.offsetTop, w: e.offsetWidth, h: e.offsetHeight }));
|
|
78
|
+
if (!boxes || t < visitAt[0]) {
|
|
79
|
+
band.style.opacity = "0";
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const { j: k, e } = glide(visitAt, t);
|
|
83
|
+
const a = boxes[visits[k].i];
|
|
84
|
+
const b = boxes[visits[k + 1]?.i ?? visits[k].i];
|
|
85
|
+
const pad = px * 0.12;
|
|
86
|
+
const lerp = (x: number, y: number) => x + (y - x) * e;
|
|
87
|
+
band.style.opacity = "1";
|
|
88
|
+
band.style.transform = `translate(${(lerp(a.x, b.x) - pad).toFixed(1)}px, ${(lerp(a.y, b.y) - pad * 0.5).toFixed(1)}px)`;
|
|
89
|
+
band.style.width = `${(lerp(a.w, b.w) + pad * 2).toFixed(1)}px`;
|
|
90
|
+
band.style.height = `${(lerp(a.h, b.h) + pad).toFixed(1)}px`;
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 패턴이 같이 쓰는 작은 도구 — DOM 은 build 안에서만 만든다(이 파일을 Node 가 import 해도 아무것도 안 만든다)
|
|
2
|
+
|
|
3
|
+
export function el(tag: string, css: string, text?: string): HTMLElement {
|
|
4
|
+
const e = document.createElement(tag);
|
|
5
|
+
e.style.cssText = css;
|
|
6
|
+
if (text !== undefined) e.textContent = text;
|
|
7
|
+
return e;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// 렌더 전엔 글 폭을 잴 수 없다 — 한글·한자 1em, 라틴 0.6em, 빈칸 0.3em 으로 어림
|
|
11
|
+
export function ems(text: string): number {
|
|
12
|
+
return [...text].reduce((a, c) => a + (/[ᄀ-ᇿ-가-一-鿿]/.test(c) ? 1 : c === " " ? 0.3 : /[0-9]/.test(c) ? 0.62 : 0.6), 0) || 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const fit = (text: string, width: number, lo: number, hi: number): number => Math.round(Math.max(lo, Math.min(hi, width / ems(text))));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* n 번의 바뀜을 박에 붙인다 — [from, to] 안의 박을 고르게 골라 쓰고, 박이 모자라면 고르게 편다.
|
|
19
|
+
* 박마다 하나씩이 기본이라 같은 구도가 오래 서지 않는다
|
|
20
|
+
*/
|
|
21
|
+
export function times(hits: readonly number[], n: number, from: number, to: number): number[] {
|
|
22
|
+
if (n <= 0) return [];
|
|
23
|
+
const inside = hits.filter((h) => h >= from && h <= to);
|
|
24
|
+
if (inside.length >= n) {
|
|
25
|
+
const step = inside.length / n;
|
|
26
|
+
return Array.from({ length: n }, (_, i) => inside[Math.floor(i * step)]);
|
|
27
|
+
}
|
|
28
|
+
const span = Math.max(0.01, to - from);
|
|
29
|
+
return Array.from({ length: n }, (_, i) => from + (n === 1 ? 0 : (span * i) / (n - 1)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 그 시각까지 지난 시각 수 */
|
|
33
|
+
export const passed = (at: readonly number[], t: number): number => {
|
|
34
|
+
let k = 0;
|
|
35
|
+
while (k < at.length && at[k] <= t) k++;
|
|
36
|
+
return k;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const outBack = (p: number): number => {
|
|
40
|
+
const c = 1.4;
|
|
41
|
+
const x = Math.min(1, Math.max(0, p)) - 1;
|
|
42
|
+
return 1 + (c + 1) * x * x * x + c * x * x;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const clamp01 = (x: number): number => Math.min(1, Math.max(0, x));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 정지점 사이를 쉬지 않고 옮겨 간다 — 앞 정지점 시각부터 다음 정지점 시각까지 내내 움직이고, 끝 속도도 0 이 안 되게
|
|
49
|
+
* 직선과 smoothstep 을 반씩 섞는다(정지점에 닿아 서 버리면 그 사이가 멈춘 프레임이 된다). j 는 앞 정지점 번호, e 는 0..1
|
|
50
|
+
*/
|
|
51
|
+
export function glide(at: readonly number[], t: number): { j: number; e: number } {
|
|
52
|
+
const j = Math.max(0, passed(at, t) - 1);
|
|
53
|
+
const a = at[j] ?? 0;
|
|
54
|
+
const b = at[j + 1];
|
|
55
|
+
if (b === undefined || t < a) return { j, e: 0 };
|
|
56
|
+
const p = clamp01((t - a) / Math.max(1e-3, b - a));
|
|
57
|
+
return { j, e: 0.5 * p + 0.5 * p * p * (3 - 2 * p) };
|
|
58
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SceneCtx } from "@kasaframe/core";
|
|
2
|
+
import { hitTimes, type Dress } from "@kasaframe/motion/moves";
|
|
3
|
+
import { themeSlots } from "@kasaframe/themes/slots";
|
|
4
|
+
import type { Kit, Pattern, SpokenWord, StageLike, TextPolicy } from "./pattern.ts";
|
|
5
|
+
|
|
6
|
+
// 감독이 지은 장면 파일이 부르는 감싸개(페이지 쪽 — Vite 전용 테마 슬롯을 읽어 Node 에서는 가져오지 않는다).
|
|
7
|
+
// 무대(lib/stage.ts)에 감독이 고른 바탕·경계 덮기를 걸고, 테마 바탕 질감을 깔고, 패턴이 내용을 칠한 뒤 무대를 친다.
|
|
8
|
+
// 무대는 프로젝트 lib 에 있어(kf new 가 복사) 장면 파일이 넘겨준다.
|
|
9
|
+
|
|
10
|
+
export interface Directed {
|
|
11
|
+
scene: string;
|
|
12
|
+
type: string;
|
|
13
|
+
content: Record<string, unknown>;
|
|
14
|
+
seed: string;
|
|
15
|
+
theme: string | null;
|
|
16
|
+
dress: Dress;
|
|
17
|
+
policy: TextPolicy;
|
|
18
|
+
first: boolean;
|
|
19
|
+
last: boolean;
|
|
20
|
+
loop: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type MakeStage = (root: HTMLElement, ctx: SceneCtx, o: { panel?: boolean; dress?: Dress }) => StageLike & { tick(t: number): void };
|
|
24
|
+
|
|
25
|
+
interface Line {
|
|
26
|
+
scene: string;
|
|
27
|
+
words?: { text: string; from: number; to: number }[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 패턴이 박에 붙일 때 쓰는 간격 — 장면 문법 예시와 같은 값이라 예시에서 본 그림이 그대로 나온다
|
|
31
|
+
const HIT_GAP = 0.3;
|
|
32
|
+
|
|
33
|
+
// captions.json 의 낱말 시각은 영상 기준 초 — 장면 안 초로 옮긴다
|
|
34
|
+
function wordsOf(glob: Record<string, unknown> | undefined, ctx: SceneCtx): SpokenWord[] {
|
|
35
|
+
const lines = (Object.values(glob ?? {})[0] ?? []) as Line[];
|
|
36
|
+
if (!Array.isArray(lines)) return [];
|
|
37
|
+
return lines.filter((l) => l.scene === ctx.id).flatMap((l) => (l.words ?? []).map((w) => ({ text: w.text, from: w.from - ctx.from, to: w.to - ctx.from })));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function directed(pattern: Pattern<any>, d: Directed, deps: { makeStage: MakeStage; captions?: Record<string, unknown> }) {
|
|
41
|
+
return {
|
|
42
|
+
build(root: HTMLElement, ctx: SceneCtx) {
|
|
43
|
+
// 제 바탕을 까는 패턴엔 바탕 판을 빼 준다 — 판이 패턴 바탕 위로 비치지 않게
|
|
44
|
+
const stage = deps.makeStage(root, ctx, { dress: d.dress, panel: pattern.bg === "own" ? false : undefined });
|
|
45
|
+
const slots = themeSlots(d.theme);
|
|
46
|
+
let surface: ((t: number) => void) | null = null;
|
|
47
|
+
if (slots.surface && pattern.bg === "stage") {
|
|
48
|
+
const layer = document.createElement("div");
|
|
49
|
+
layer.style.cssText = "position:absolute;inset:0";
|
|
50
|
+
stage.box.prepend(layer);
|
|
51
|
+
surface = slots.surface(layer, { ctx, seed: `${d.seed}:surface` });
|
|
52
|
+
}
|
|
53
|
+
const kit: Kit = { seed: d.seed, hits: hitTimes(ctx, HIT_GAP), say: wordsOf(deps.captions, ctx), slots, stage, policy: d.policy, first: d.first, last: d.last, loop: d.loop };
|
|
54
|
+
const paint = pattern.build(stage.cam, ctx, d.content, kit);
|
|
55
|
+
return (t: number) => {
|
|
56
|
+
surface?.(t);
|
|
57
|
+
paint(t);
|
|
58
|
+
stage.tick(t);
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { rand } from "@kasaframe/core";
|
|
2
|
+
import type { AspectName, Energy, PatternMeta, TextPolicy } from "./pattern.ts";
|
|
3
|
+
|
|
4
|
+
// 패턴 고르기 — 점수로 줄 세우고, 씨앗 고정 난수로 순서를 뽑는다(점수가 높을수록 앞에 올 확률이 크다).
|
|
5
|
+
// 같은 씨앗·같은 내용이면 늘 같은 순서, 씨앗만 바꾸면 다른 그림. kf check 가 떨어진 장면은 그 순서의 다음 후보로 간다.
|
|
6
|
+
|
|
7
|
+
export interface Entry {
|
|
8
|
+
meta: PatternMeta;
|
|
9
|
+
/** 장면 파일이 가져갈 이름(@kasaframe/patterns/<type>/<이름>) */
|
|
10
|
+
spec: string;
|
|
11
|
+
/** 감독 안 기본 — 어느 type 이든 받지만 다른 후보가 없을 때만 */
|
|
12
|
+
fallback?: boolean;
|
|
13
|
+
/** 패턴 소스의 판(내용 해시) — 떨어진 기록은 같은 판에만 걸린다(패턴을 고치면 다시 후보가 된다) */
|
|
14
|
+
rev?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Slot {
|
|
18
|
+
id: string;
|
|
19
|
+
type: string;
|
|
20
|
+
content: Record<string, unknown>;
|
|
21
|
+
dur: number;
|
|
22
|
+
energy: Energy;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SelectCtx {
|
|
26
|
+
aspect: AspectName;
|
|
27
|
+
purpose: string;
|
|
28
|
+
theme: string | null;
|
|
29
|
+
policy: TextPolicy;
|
|
30
|
+
seed: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Ranked {
|
|
34
|
+
id: string;
|
|
35
|
+
score: number;
|
|
36
|
+
why: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Rejected {
|
|
40
|
+
id: string;
|
|
41
|
+
why: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Pick {
|
|
45
|
+
scene: string;
|
|
46
|
+
pattern: string;
|
|
47
|
+
/** 고른 순서 전부 — 실패하면 다음 것으로 */
|
|
48
|
+
order: Ranked[];
|
|
49
|
+
rejected: Rejected[];
|
|
50
|
+
/** kf check 에서 떨어진 후보 수 — 0 이면 처음 고른 그대로 */
|
|
51
|
+
attempt: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LEVEL: Record<Energy, number> = { low: 0, mid: 1, high: 2 };
|
|
55
|
+
const TAU = 1;
|
|
56
|
+
const FALLBACK_SCORE = -8;
|
|
57
|
+
|
|
58
|
+
/** 점수 — 장면 사정·앞뒤 장면·지금까지 쓴 횟수. why 는 direct.json 에 남는다 */
|
|
59
|
+
export function score(e: Entry, slot: Slot, ctx: SelectCtx, around: { prev?: PatternMeta; next?: PatternMeta; used: Map<string, number> }): Ranked {
|
|
60
|
+
const m = e.meta;
|
|
61
|
+
const why: string[] = [];
|
|
62
|
+
let s = 0;
|
|
63
|
+
const add = (v: number, w: string) => {
|
|
64
|
+
if (!v) return;
|
|
65
|
+
s += v;
|
|
66
|
+
why.push(`${v > 0 ? "+" : ""}${+v.toFixed(2)} ${w}`);
|
|
67
|
+
};
|
|
68
|
+
if (e.fallback) add(FALLBACK_SCORE, "감독 기본(다른 후보가 없을 때)");
|
|
69
|
+
const fit = m.themes?.[ctx.theme ?? ""] ?? 0;
|
|
70
|
+
add(2 * fit, `테마 ${ctx.theme} 궁합`);
|
|
71
|
+
if (m.purposes?.prefer?.includes(ctx.purpose)) add(1, `용도 ${ctx.purpose}`);
|
|
72
|
+
const gap = Math.min(...m.energy.map((x) => Math.abs(LEVEL[x] - LEVEL[slot.energy])));
|
|
73
|
+
add(gap === 0 ? 2 : gap === 1 ? 0 : -2, `에너지 ${slot.energy}${gap ? ` (패턴 ${m.energy.join("·")})` : ""}`);
|
|
74
|
+
if (slot.dur <= m.dur.max) add(0.5, "길이 안");
|
|
75
|
+
else add(-Math.min(3, (slot.dur - m.dur.max) / m.dur.max * 2), `길이 ${slot.dur.toFixed(1)}초가 상한 ${m.dur.max}초를 넘음`);
|
|
76
|
+
for (const [side, o] of [["앞", around.prev], ["뒤", around.next]] as const) {
|
|
77
|
+
if (!o) continue;
|
|
78
|
+
if (o.family === m.family) add(-3, `${side} 장면과 같은 계열 ${m.family}`);
|
|
79
|
+
const cams = m.camera.filter((c) => c !== "none" && o.camera.includes(c));
|
|
80
|
+
if (cams.length) add(-1, `${side} 장면과 같은 카메라 ${cams.join("·")}`);
|
|
81
|
+
}
|
|
82
|
+
const n = around.used.get(m.id) ?? 0;
|
|
83
|
+
if (n) add(-2 * n, `이 영상에서 ${n}번 씀`);
|
|
84
|
+
return { id: m.id, score: +s.toFixed(3), why };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 고를 수 없는 까닭 — 없으면 null */
|
|
88
|
+
export function reject(e: Entry, slot: Slot, ctx: SelectCtx): string | null {
|
|
89
|
+
const m = e.meta;
|
|
90
|
+
if (!e.fallback && m.type !== slot.type) return "type 다름";
|
|
91
|
+
if (m.aspects.length && !m.aspects.includes(ctx.aspect)) return `화면비 ${ctx.aspect} 안 받음`;
|
|
92
|
+
if (m.purposes?.avoid?.includes(ctx.purpose)) return `용도 ${ctx.purpose} 피함`;
|
|
93
|
+
if ((m.themes?.[ctx.theme ?? ""] ?? 0) <= -1) return `테마 ${ctx.theme} 와 안 맞음`;
|
|
94
|
+
if (slot.dur < m.dur.min) return `장면 ${slot.dur.toFixed(2)}초가 하한 ${m.dur.min}초보다 짧음`;
|
|
95
|
+
if (m.fits) {
|
|
96
|
+
let r: true | string;
|
|
97
|
+
try {
|
|
98
|
+
r = m.fits(slot.content, { aspect: ctx.aspect, dur: slot.dur, policy: ctx.policy });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
r = `fits 가 던짐: ${err instanceof Error ? err.message : err}`;
|
|
101
|
+
}
|
|
102
|
+
if (r !== true) return r;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 후보를 씨앗으로 줄 세운다 — 무게 exp((점수-최고)/τ) 로 하나씩 뽑기(Efraimidis–Spirakis).
|
|
109
|
+
* 난수 번호는 패턴 id 로 정해 다른 type 에 패턴이 늘어도 이 장면의 순서는 흔들리지 않는다
|
|
110
|
+
*/
|
|
111
|
+
export function order(ranked: Ranked[], key: string): Ranked[] {
|
|
112
|
+
if (!ranked.length) return [];
|
|
113
|
+
const best = Math.max(...ranked.map((r) => r.score));
|
|
114
|
+
return ranked
|
|
115
|
+
.map((r) => {
|
|
116
|
+
const w = Math.exp((r.score - best) / TAU);
|
|
117
|
+
const u = Math.max(rand(`${key}|${r.id}`, 0), 1e-12);
|
|
118
|
+
return { r, k: Math.log(u) / w };
|
|
119
|
+
})
|
|
120
|
+
.sort((a, b) => b.k - a.k || (a.r.id < b.r.id ? -1 : 1))
|
|
121
|
+
.map((x) => x.r);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function rankOne(slot: Slot, entries: Entry[], ctx: SelectCtx, around: { prev?: PatternMeta; next?: PatternMeta; used: Map<string, number> }, failed: readonly string[]): { order: Ranked[]; rejected: Rejected[] } {
|
|
125
|
+
const rejected: Rejected[] = [];
|
|
126
|
+
const ok: Ranked[] = [];
|
|
127
|
+
for (const e of entries) {
|
|
128
|
+
const why = failed.includes(e.meta.id) ? "kf check 에서 떨어짐" : reject(e, slot, ctx);
|
|
129
|
+
if (why) {
|
|
130
|
+
if (e.fallback || e.meta.type === slot.type) rejected.push({ id: e.meta.id, why });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
ok.push(score(e, slot, ctx, around));
|
|
134
|
+
}
|
|
135
|
+
return { order: order(ok, `${ctx.seed}|${slot.id}|${slot.type}`), rejected };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 앞에서부터 차례로 — 앞 장면과 겹치지 않게 */
|
|
139
|
+
export function selectAll(slots: Slot[], entries: Entry[], ctx: SelectCtx, failed: Record<string, string[]> = {}): Pick[] {
|
|
140
|
+
const byId = new Map(entries.map((e) => [e.meta.id, e.meta]));
|
|
141
|
+
const used = new Map<string, number>();
|
|
142
|
+
const picks: Pick[] = [];
|
|
143
|
+
for (const [i, slot] of slots.entries()) {
|
|
144
|
+
const prev = picks[i - 1] ? byId.get(picks[i - 1].pattern) : undefined;
|
|
145
|
+
const r = rankOne(slot, entries, ctx, { prev, used }, failed[slot.id] ?? []);
|
|
146
|
+
const top = r.order[0];
|
|
147
|
+
if (!top) throw new Error(`장면 ${slot.id}(${slot.type}) 에 고를 패턴이 없다 — ${r.rejected.map((x) => `${x.id}: ${x.why}`).join(" · ") || "후보 0"}`);
|
|
148
|
+
used.set(top.id, (used.get(top.id) ?? 0) + 1);
|
|
149
|
+
picks.push({ scene: slot.id, pattern: top.id, order: r.order, rejected: r.rejected, attempt: (failed[slot.id] ?? []).length });
|
|
150
|
+
}
|
|
151
|
+
return picks;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 한 장면만 다시 — 앞뒤 장면은 그대로 두고 둘 다와 겹치지 않는 다음 후보. 더 없으면 null */
|
|
155
|
+
export function repick(picks: Pick[], i: number, slots: Slot[], entries: Entry[], ctx: SelectCtx, failed: readonly string[]): Pick | null {
|
|
156
|
+
const byId = new Map(entries.map((e) => [e.meta.id, e.meta]));
|
|
157
|
+
const used = new Map<string, number>();
|
|
158
|
+
picks.forEach((p, k) => k !== i && used.set(p.pattern, (used.get(p.pattern) ?? 0) + 1));
|
|
159
|
+
const r = rankOne(slots[i], entries, ctx, { prev: byId.get(picks[i - 1]?.pattern ?? ""), next: byId.get(picks[i + 1]?.pattern ?? ""), used }, failed);
|
|
160
|
+
const top = r.order[0];
|
|
161
|
+
if (!top) return null;
|
|
162
|
+
return { scene: slots[i].id, pattern: top.id, order: r.order, rejected: r.rejected, attempt: failed.length };
|
|
163
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { rand } from "@kasaframe/core";
|
|
2
|
+
import { type Bundle, bundleFor, pickDress, type Dress } from "@kasaframe/motion/menu";
|
|
3
|
+
import { TRANSITIONS_3D } from "@kasaframe/motion/transition3d";
|
|
4
|
+
import type { Energy } from "./pattern.ts";
|
|
5
|
+
|
|
6
|
+
// 장면 사이 — 이음매(3D 전환 장면)를 어디에 몇 개 넣을지, 그 종류, 나머지 경계의 덮기, 장면마다의 바탕.
|
|
7
|
+
// 이음매 기본은 dive, 같은 전환을 잇달아 쓰지 않는다. 덮기·바탕은 연출 수 메뉴(pickDress)의 규칙을 그대로 따른다
|
|
8
|
+
// (이음매·드롭으로 들어가는 경계·1.2초 아래 장면엔 안 덮는다, 이어지는 장면은 다른 바탕).
|
|
9
|
+
|
|
10
|
+
export interface BeatsLite {
|
|
11
|
+
sections?: { t: number; kind: string; strength?: number }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SECTION_ENERGY: Record<string, Energy> = { drop: "high", main: "mid", intro: "low", break: "low", outro: "low", verse: "low" };
|
|
15
|
+
|
|
16
|
+
/** 장면 시간 창의 곡 에너지 — 그 창을 가장 많이 덮은 구간의 종류. 곡이 없으면 첫 장면만 mid */
|
|
17
|
+
export function energyOf(beats: BeatsLite | null, from: number, dur: number, first: boolean): Energy {
|
|
18
|
+
const secs = [...(beats?.sections ?? [])].sort((a, b) => a.t - b.t);
|
|
19
|
+
if (!secs.length) return first ? "mid" : "low";
|
|
20
|
+
const end = from + dur;
|
|
21
|
+
const cover = new Map<Energy, number>();
|
|
22
|
+
secs.forEach((s, i) => {
|
|
23
|
+
const a = Math.max(from, s.t);
|
|
24
|
+
const b = Math.min(end, secs[i + 1]?.t ?? Infinity);
|
|
25
|
+
if (b <= a) return;
|
|
26
|
+
const e = s.kind === "drop" && (s.strength ?? 1) < 0.4 ? "mid" : (SECTION_ENERGY[s.kind] ?? "mid");
|
|
27
|
+
cover.set(e, (cover.get(e) ?? 0) + (b - a));
|
|
28
|
+
});
|
|
29
|
+
let best: Energy = "low";
|
|
30
|
+
let most = -1;
|
|
31
|
+
for (const e of ["high", "mid", "low"] as const) if ((cover.get(e) ?? 0) > most) [best, most] = [e, cover.get(e) ?? 0];
|
|
32
|
+
return best;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 무대가 바뀌는 type — 이음매로 들어가면 「안으로 들어가 원리를 본다」가 된다
|
|
36
|
+
const STAGE_TYPES = new Set(["flow", "fill", "timeline", "formula", "toggle-sim", "before-after", "trend", "steps", "screen", "code"]);
|
|
37
|
+
|
|
38
|
+
export interface Spot {
|
|
39
|
+
/** 이 장면 앞에 이음매 */
|
|
40
|
+
at: number;
|
|
41
|
+
/** 2D↔3D 경계 — 공간 잇기 전환. up 은 2D→3D(rise), down 은 3D→2D(land) */
|
|
42
|
+
bridge: false | "up" | "down";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 이음매 자리(그 장면 앞). 2D↔3D 가 갈리는 경계엔 늘 넣는다(끊기지 않게) — 단 앞뒤 2D 그림에 투영을 맞춰 구운 3D 샷(matched)이면
|
|
47
|
+
* 컷으로도 이어져 넣지 않는다. 그 밖엔 count 개까지 — 첫 장면 뒤·마지막 장면 앞은 피하고, 무대가 바뀌는 첫 장면 앞, 없으면 가운데.
|
|
48
|
+
* 이음매끼리는 두 장면 넘게 띄운다
|
|
49
|
+
*/
|
|
50
|
+
export function seamSpots(types: readonly string[], spaces: readonly ("2d" | "3d")[], count: number, matched: readonly boolean[] = []): Spot[] {
|
|
51
|
+
const n = types.length;
|
|
52
|
+
const out: Spot[] = [];
|
|
53
|
+
for (let k = 1; k < n; k++) {
|
|
54
|
+
if (spaces[k] === spaces[k - 1]) continue;
|
|
55
|
+
const shot = spaces[k] === "3d" ? k : k - 1;
|
|
56
|
+
if (!matched[shot]) out.push({ at: k, bridge: spaces[k] === "3d" ? "up" : "down" });
|
|
57
|
+
}
|
|
58
|
+
const lo = 2;
|
|
59
|
+
const hi = n - 2;
|
|
60
|
+
const far = (k: number) => !out.some((x) => Math.abs(x.at - k) < 2);
|
|
61
|
+
let extra = Math.max(0, count - out.length);
|
|
62
|
+
for (let k = lo; k <= hi && extra > 0; k++) if (STAGE_TYPES.has(types[k]) && far(k)) (out.push({ at: k, bridge: false }), extra--);
|
|
63
|
+
for (let k = Math.round(n / 2); k <= hi && extra > 0; k++) if (far(k)) (out.push({ at: k, bridge: false }), extra--);
|
|
64
|
+
return out.sort((a, b) => a.at - b.at);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const SEAM_KINDS = ["dive", "portal", "cards", "zoomOut", "fold", "whip"] as const;
|
|
68
|
+
|
|
69
|
+
/** 2D↔3D 경계의 공간 잇기 — 2D→3D 는 주인공 상자가 3D 블록으로 일어서고(rise), 3D→2D 는 한 평면에 내려앉는다(land).
|
|
70
|
+
* 이 판의 transition3d 에 아직 없으면 dive 로(장면이 모르는 kind 로 던지지 않게) */
|
|
71
|
+
const known = new Set<string>(TRANSITIONS_3D);
|
|
72
|
+
export const BRIDGE_KIND = { up: known.has("rise") ? "rise" : "dive", down: known.has("land") ? "land" : "dive" } as const;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 이음매 종류 — 공간 잇기 자리는 BRIDGE_KIND, 나머지 첫째는 틀이 정한 것(기본 dive), 다음부터는 씨앗으로 앞과 다른 것.
|
|
76
|
+
* failed 는 kf check 에서 떨어진 종류(이음매 차례 번호로)
|
|
77
|
+
*/
|
|
78
|
+
export function seamKinds(spots: readonly Spot[], seed: string, first = "dive", failed: Record<number, string[]> = {}): string[] {
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
let plain = 0;
|
|
81
|
+
for (const [i, sp] of spots.entries()) {
|
|
82
|
+
const bad = new Set([out[i - 1], ...(failed[i] ?? [])]);
|
|
83
|
+
if (sp.bridge && !(failed[i] ?? []).includes(BRIDGE_KIND[sp.bridge])) {
|
|
84
|
+
out.push(BRIDGE_KIND[sp.bridge]);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const pool = SEAM_KINDS.filter((k) => !bad.has(k));
|
|
88
|
+
if (plain++ === 0 && !bad.has(first)) out.push(first);
|
|
89
|
+
else out.push(pool.length ? pool[Math.floor(rand(`${seed}|seam`, i) * pool.length)] : first);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Placed {
|
|
95
|
+
id: string;
|
|
96
|
+
role: string;
|
|
97
|
+
dur: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 바탕 정지 — 움직이는 것은 다 내용 하나를 뜻해야 해서 바탕 수(햇살·집중선·모눈·빛 방울)는 감독 장면에 걸지 않는다 */
|
|
101
|
+
export const STILL_BG = "still";
|
|
102
|
+
|
|
103
|
+
/** 장면마다 글 수·경계 덮기 — 연출 수 메뉴의 고름 그대로(같은 씨앗이면 같은 고름), 바탕은 정지 */
|
|
104
|
+
export function dressAll(seed: string, scenes: readonly Placed[], moves: Bundle | undefined, theme: string | null): Dress[] {
|
|
105
|
+
return pickDress(seed, scenes, bundleFor(moves, theme ?? undefined)).map((d) => ({ ...d, bg: STILL_BG }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 감독 기본이 쓰는 글 수 — 낱말을 돌려 띄우되 자르지 않고(slamWords 는 긴 낱말이 화면 끝을 넘는다) 겹쳐 흐리지 않는 것(echoStack·ransom 은 겹친 벌이 장식이 된다)
|
|
109
|
+
const FALLBACK_TEXT = ["splitFlap", "ghostStack"] as const;
|
|
110
|
+
|
|
111
|
+
/** 감독 기본(any.words)의 글 수 — 장면 id 로 고르고, 앞 장면도 감독 기본이면 다른 것 */
|
|
112
|
+
export function cyclingText(seed: string, id: string, prev?: string): string {
|
|
113
|
+
const pool = FALLBACK_TEXT.filter((x) => x !== prev);
|
|
114
|
+
return pool[Math.floor(rand(`${seed}|${id}|text`, 0) * pool.length)];
|
|
115
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 생성 모델 — 비용·속도·라이선스·지역 (2026-10-08 조사)
|
|
2
2
|
|
|
3
|
-
`kf gen models` 가 같은 표를 키 유무와 함께 찍는다. 값이 바뀌면 `src/video/*.ts`·`src/image/og.ts` 의 단가와 이 표를 같이 고친다.
|
|
3
|
+
`kf gen models` 가 같은 표를 키 유무와 함께 찍고, `kf doctor` 「생성 능력」(`capabilities()`)이 그림·영상·3D·음악·목소리마다 지금 쓸 수 있는 공급자와 권장을 찍는다. 값이 바뀌면 `src/video/*.ts`·`src/image/og.ts` 의 단가와 이 표를 같이 고친다.
|
|
4
4
|
|
|
5
5
|
## 그림
|
|
6
6
|
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
| `og-gpt-image-2.5-sunburst` (기본) | OG `openai/gpt-image-2.5-sunburst` | 1M 토큰당 글 입력 $5 · 그림 입력 $8 · 그림 출력 $30 | medium $0.011(실측) · high 약 $0.08 | 생성 20–28초 · 편집 20초 | OpenAI 약관 — 출력물 권리 사용자, 상업 가능 · OG live 전 세계 |
|
|
10
10
|
| `og-gpt-image-2.5-flare` | OG `openai/gpt-image-2.5-flare` | 같음 | 같음 | 더 빠르다고 함(미측정) | 같음 |
|
|
11
11
|
| `og-gpt-image-2` | OG `openai/gpt-image-2` | 같음 | 같은 품질값에서 토큰 더 씀 | — | 같음 |
|
|
12
|
+
| `codex-imagegen` | codex CLI 내장 imagegen(ChatGPT 로그인) | 0원(ChatGPT 요금제 안) | $0 — 1024x1536 으로 그려 잘라 맞춤 | 47초(실측 2026-10-09, 1080x1920) | OpenAI 약관 — 출력물 권리 사용자, 상업 가능 · 한국 사용 가능 |
|
|
12
13
|
|
|
13
14
|
- 투명 배경: 2.5 는 `background: transparent` 를 직접 받는다(실측 알파 61%). 안 되면 한 장 더 써서 초록 바탕 + ffmpeg 키잉으로 간다(`notes` 에 `alpha-failed`).
|
|
14
15
|
- 참조 편집은 `/v1/images/edits` multipart. 참조는 흰 바탕 512px JPEG 로 보낸다(OG 업로드 413 한도). 참조가 먹었는지는 `usage.input_tokens_details.image_tokens > 0` 으로만 안다 — 0 이면 `notes` 에 `refs-ignored`.
|
|
15
|
-
- 크기: 변이 16의 배수여야 해서 1080 은 1088 로 만든 뒤 잘라 맞춘다(`notes` 에 `fit`).
|
|
16
|
+
- 크기: 변이 16의 배수여야 해서 1080 은 1088 로 만든 뒤 잘라 맞춘다(`notes` 에 `fit`). codex 는 1536x1024·1024x1536·1024x1024 중 가까운 꼴로 그린다 — 실제 나온 크기를 재서 맞춘다.
|
|
17
|
+
- codex: kasaterm pane 의 codex 는 래퍼라 PATH 에서 `kasaterm-shim-` 을 뺀 순정 codex 를 부른다(`KF_CODEX` 로 못 박는다). 쓸 수 있는지는 `codex login status`. 투명 배경은 초록 바탕 + 키잉, 참조 그림은 `-i` 로 붙인다(먹었는지는 알 수 없다). 7분 안에 안 끝나면 끊는다(`KF_CODEX_TIMEOUT_MS`).
|
|
16
18
|
|
|
17
19
|
## 영상
|
|
18
20
|
|
|
@@ -20,7 +22,7 @@ OG `/v1/models` 에는 영상 모델이 없다(2026-10-08 — 출력 모달리
|
|
|
20
22
|
|
|
21
23
|
| 공급자 id | 단가 | 5초 720p | 길이 | 첫+끝 프레임 | 속도 | 라이선스 | 지역 | 키 |
|
|
22
24
|
|---|---|---|---|---|---|---|---|---|
|
|
23
|
-
| `h3-pod` (
|
|
25
|
+
| `h3-pod` (0원이지만 지역 제한 — 묻는 보기에만, `--yes`·권장으로는 안 고른다) | GPU 자체 호스팅 — API 요금 없음, GPU 한 장이라 줄 선다 | $0 | 4–15초(서버 검증, 짧은 이음매는 4초로 만들어 맞춰 감는다) | 됨(fl2va 키프레임 0·-1) | L40S 1344x768·5초: 터보 LoRA 9걸음 170초, LoRA 없이 50걸음 940초(파드 운영자 실측) | **공개 가중치 — 지역 제한 확인 필요**: MiniMax H3 커뮤니티 라이선스가 한국을 제외 지역으로 두고 산출물에도 걸린다. 밖에 낼 결과물에는 쓰기 전에 확인 | 사내 | `KF_H3_URL`(SGLang 주소, 터널이면 `http://127.0.0.1:<포트>`) |
|
|
24
26
|
| `fal-wan-2.2-turbo` (기본) | 클립당 720p $0.10 · 580p $0.075 · 480p $0.05 | **$0.10** | 5초 고정(81프레임@16fps) | 됨 | 빠름(수십 초) | 가중치 Apache-2.0 — 출력 상업 가능 | 제한 없음 · fal(미국) API 한국 결제 가능 | `FAL_KEY` |
|
|
25
27
|
| `fal-wan-2.2` | 초당 720p $0.08 · 580p $0.06 · 480p $0.04 (16fps 기준 초) | $0.41 | 1–10초 | 됨 | 1분 안팎 | Apache-2.0 | 제한 없음 | `FAL_KEY` |
|
|
26
28
|
| `fal-kling-3-std` | 초당 $0.084(소리 끔) · $0.126(소리) | $0.42 | 3–15초 | 됨 | 약 2–2.5분 | Kling API 약관 — 유료 생성물 상업 가능 | fal 경유 한국 가능 | `FAL_KEY` |
|
|
@@ -36,9 +38,39 @@ OG `/v1/models` 에는 영상 모델이 없다(2026-10-08 — 출력 모달리
|
|
|
36
38
|
- **Kling 직접 API** — 키(`~/.secrets/gcp-sm/kling-api-credentials`)는 있으나 잔액 충전이 따로다. fal 경유가 같은 값이라 v0 은 fal 로.
|
|
37
39
|
- **MiniMax H3 Max**(fal 후학습 고속판, 480P $0.05/초) — 끝 프레임을 받는지 확인 전.
|
|
38
40
|
|
|
41
|
+
## 목소리(TTS) — `src/tts` (2026-10-09 조사)
|
|
42
|
+
|
|
43
|
+
| `--voice` 엔진 | 모델 | 단가 | 낱말 시각 | 한국어 · 목소리(틀 기본 굵게) | 라이선스 · 지역 | 키 | 검증 |
|
|
44
|
+
|---|---|---|---|---|---|---|---|
|
|
45
|
+
| `gemini` | `gemini-3.8-flash-tts`(Interactions API, `KF_GEMINI_TTS_MODEL`) | 1M 토큰당 입력 $0.50 · 소리 출력 $9(1초 = 25토큰, 2026-12-31 까지 — 2027 부터 $18) · 무료 등급 있음 | 안 줌 → 숨 자리로 맞춤 | 됨 · **Charon**(차분한 정보 전달)·Gacrux·Algenib·Sulafat… 30개, 연기 지시(`speech_metadata.style`) | Google 생성형 AI 약관 — 출력물 소유권 주장 없음 · Gemini API 제공 지역 | `GEMINI_API_KEY`·`GOOGLE_API_KEY`·`~/.config/gemini.key` | **실호출**(51자 $0.0018, 7.8초, 9–11초 걸림) |
|
|
46
|
+
| `elevenlabs` | `eleven_multilingual_v2` with-timestamps(`KF_ELEVENLABS_MODEL`) — `eleven_v4` 는 Text to Dialogue 로만 열려 있어 낱말 시각을 주는 이 길에는 안 쓴다 | 1천 자당 $0.08(flash·turbo $0.04) | **글자마다 줌** → 어절로 묶는다 | 됨 · **George**·Daniel·Brian·Matilda·Alice·Lily(기본 제공), 계정 목소리는 id 그대로 | 유료 요금제 출력물 상업 가능 · 전 세계 | `ELEVENLABS_API_KEY`·`XI_API_KEY`·`~/.config/elevenlabs.key` | 실호출 안 함(키 없음) |
|
|
47
|
+
| `dashscope` | `qwen3-tts-flash`(DashScope 국제, `KF_DASHSCOPE_TTS_MODEL`·`KF_DASHSCOPE_BASE`) | 입력 1만 자당 $0.10(instruct $0.115), 출력 무료 · 첫 90일 11만 자 무료 | 안 줌 → 숨 자리로 맞춤 | 48개 모두 한국어 · **Neil**·Elias·Eldric Sage·Serena·Kai·Sohee, 한 번에 600자 | Alibaba Model Studio 약관 · 싱가포르(베이징은 키가 따로) | `DASHSCOPE_API_KEY`·`~/.config/dashscope.key` | 실호출 안 함(키 없음) |
|
|
48
|
+
| `local` | 공개 가중치 `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice`(`tools/tts/qwen3_tts_server.py`) | 0원(자체 GPU·메모리) | 안 줌 → 숨 자리로 맞춤 | **Sohee**·Ryan·Serena·Aiden·Vivian | 가중치 Apache-2.0 | `KF_QWEN3_TTS_URL`(서버 주소) | 실호출 안 함 — 여러 칸이 같이 쓰는 노트북에서 1.7B 를 띄우지 않는다 |
|
|
49
|
+
|
|
50
|
+
- 고르기(`voiceQuestion`): 늘 묻는다 — 보기 차례 로컬 Qwen3(`KF_QWEN3_TTS_URL` 이 있을 때) · ElevenLabs(키가 없으면 넣는 법 `howToGet` 을 붙인 보기) · 자막만 · 그 밖 엔진(Gemini·DashScope), 첫째가 권장. 엔진마다 목소리는 틀의 `narration.voices`. 로컬도 ElevenLabs 도 없으면 그 밖 엔진으로 넘어가지 않고 missing(넣는 법이 권장). 시리즈 묶음이 목소리를 박았으면 그 엔진이 있을 때 묻지 않는다(없으면 ① 그 엔진 키 넣는 법 ② 지금 되는 다른 엔진 ③ 자막만).
|
|
51
|
+
- 캐시: 글·목소리·모델·연기 지시가 같으면 `~/.cache/kasaframe/gen` 에서 0원. 공급자가 준 시각만 캐시에 두고, 숨 자리 맞추기는 꺼낼 때마다 다시 해서 맞추는 법이 나아지면 캐시 소리에도 먹는다.
|
|
52
|
+
- 숨 자리 맞추기: `silencedetect`(-38dB·0.12초)로 말 사이 숨을 찾아, 쉼표·마침표로 끝나는 어절(마디 끝)의 글자 비율 자리를 숨에 순서대로 짝짓는다(DP — 짝 비용 = 거리 - 긴 숨 덤, 짝 없는 경계는 1초 벌점). 마디 안 낱말은 글자 수 비율.
|
|
53
|
+
- 실측(Gemini Charon, 「차분하고 낮은 다큐멘터리 내레이션」 지시): 한국어 1초에 4–5글자(띄어쓰기·문장부호 빼고). 앞뒤 무음 0.2–0.3초는 깎아 쓴다.
|
|
54
|
+
|
|
55
|
+
## 음악 — `src/music` (2026-10-09 조사)
|
|
56
|
+
|
|
57
|
+
| `--music gen:` | 모델 | 단가 | 길이 | 라이선스 | 키 | 검증 |
|
|
58
|
+
|---|---|---|---|---|---|---|
|
|
59
|
+
| `lyria-3.5`(기본) | Gemini API Interactions `lyria-3.5` | 곡 하나 $0.08(유료 등급만) | 길이 칸이 없다 — 프롬프트에 「Total length exactly N seconds」와 시각 구간(`[0:00 - 0:15]`)을 적어 끌고, 받은 뒤 잘라 끝을 페이드 | Google 생성형 AI 약관 — 출력물 소유권 주장 없음, 상업 이용 제한 조항 없음 · SynthID 소리 워터마크(안 들림) | Gemini 키 | **실호출**(60초 요청 → 60.26초, 24초 걸림, deep 결 확인) |
|
|
60
|
+
| `elevenlabs-music` | `music_v2_5`(`POST /v1/music`, `music_length_ms` 3–600초, `force_instrumental`) | 1분 $0.15 | 정확히 요청한 길이 | 무료~Business 온·오프라인 상업 가능하되 영화·TV·라디오·여러 플랫폼 게임은 Enterprise Music 만, 무료는 출처 표기·내려받기 불가, Starter 는 음원 스트리밍 금지 | ElevenLabs 키 | 실호출 안 함(키 없음) |
|
|
61
|
+
| `lyria-3-clip-preview` | Gemini API `lyria-3-clip-preview` | 곡 하나 $0.04 | 늘 30초 | Lyria 3.5 와 같음 | Gemini 키 | 안 부름 |
|
|
62
|
+
|
|
63
|
+
- 프롬프트는 분위기(`MOOD_PROMPT` — deep·bright·calm·epic)만으로 짓고 요청 글을 섞지 않는다. 길이는 칸(30·45·60·90·120·180·240초)으로 묶는다 — 내레이션이 1–2초 달라도 같은 프롬프트라 캐시에서 같은 곡이 나온다(같은 기계). 다른 기계에서도 같은 곡을 쓰려면 `kf music gen --add` 로 곡 모음에 넣어 둔다(분위기 맞는 곡 모음 곡은 묻지 않고 먼저 고른다).
|
|
64
|
+
- 생성 곡도 `kf beats` 로 잰다 — 앰비언트는 박이 약해 BPM 이 두 배로 잡힐 수 있지만 장면 경계를 붙이는 데는 그대로 쓴다(다음 박이 0.8초 넘게 멀면 붙이지 않는다).
|
|
65
|
+
|
|
66
|
+
## 섞기 — `kf render`(kf.json `audio`)
|
|
67
|
+
|
|
68
|
+
목소리를 -18 LUFS 로 맞추고, 곡은 `sidechaincompress`(threshold 0.025·ratio 3·attack 20ms·release 400ms)로 말할 때 덕킹한다. 곡 크기는 상수로 정하지 않고 재서 맞춘다 — 목소리의 0.3초 넘는 숨을 뺀 「말하는 구간」만 골라(`aselect`) 목소리와 덕킹된 곡을 한 번 재고 그 차가 `musicDb`(-20dB)가 되게 곡을 옮긴다(실측: 같은 덕킹이 말마다 5–10dB 눌렀고, 앰비언트 곡은 앞이 조용해 전체 평균으로 맞추면 말 아래 22–29dB 로 묻혔다 → 재서 맞춘 뒤 평균 19.7dB). 그다음 끝 `fadeOut`(1.5초) 페이드, 전체를 `loudnorm` 두 번(재고 → linear 로 올리기)으로 `lufs`(-14)·`truePeak`(-1.5). 한 번에 loudnorm 을 걸면 동적 모드로 들어가 덕킹 모양이 뭉개진다.
|
|
69
|
+
|
|
39
70
|
## 고르는 법
|
|
40
71
|
|
|
41
|
-
- 이름을 안 주면 **키가 있는 것 중 견적이 가장 싼 것**(끝 프레임이 필요하면 그걸 받는 것만). `
|
|
72
|
+
- `kf gen vid` 에 이름을 안 주면 **키가 있는 것 중 견적이 가장 싼 것**(끝 프레임이 필요하면 그걸 받는 것만). `kf make` 의 묻기는 다르다 — 제한 없는 것 중 가장 싼 것이 권장이고, `h3-pod` 는 보기에 지역 제한 경고와 함께 두되 사람이 짚어야만(`--video h3-pod`) 쓴다.
|
|
73
|
+
- `h3-pod` 의 제한은 공급자 `restrict` 로 박혀 있어 만든 자산 메타(`restrict`)·`kf check` 의 `asset.restrict`·`kf make --finish` 의 `out/<영상>.credits.json` 에 그대로 남는다.
|
|
42
74
|
- `h3-pod` 걸음 수는 `KF_H3_STEPS` 로 꼭 준다(기본값 없음). 서버에 터보 LoRA 가 걸려 있으면 9, 없으면 50 — 터보가 걸린 채 50 을 주면 학습과 다른 스케줄이라 그림이 틀어진다. LoRA 는 서버 전체 상태라 kasaframe 이 걸거나 풀지 않고 파드 운영자에게 상태를 묻는다. 같은 시드라도 걸음 수가 다르면 다른 그림이다(캐시 키에도 들어간다). `quality` 는 보내지 않는다(터보와 `high` 를 같이 쓰면 안 된다). 서버마다 다른 필드는 `KF_H3_EXTRA`(JSON)로 얹는다.
|
|
43
75
|
- 짧은 이음매(1–2초)도 5초로 만들어 장면 길이에 맞춰 빨리 감는다(`retime`). 움직임이 빨라 보이면 `fal-wan-2.2` 로 길이를 맞춰 뽑는다.
|
|
44
76
|
- 소리 없이 쓴다 — 곡이 소리를 쥔다. Kling·LTX 는 `generate_audio:false` 로 부른다.
|
|
@@ -53,3 +85,6 @@ OG `/v1/models` 에는 영상 모델이 없다(2026-10-08 — 출력 모달리
|
|
|
53
85
|
- Veo 3.1: ai.google.dev/gemini-api/docs/veo · 단가 ai.google.dev/gemini-api/docs/pricing (2026-09-24 갱신분)
|
|
54
86
|
- LTX: docs.ltx.io/pricing · ltx.io/model/license
|
|
55
87
|
- gpt-image-2.5: developers.openai.com/api/docs/pricing
|
|
88
|
+
- Gemini TTS: ai.google.dev/gemini-api/docs/speech-generation · Lyria: ai.google.dev/gemini-api/docs/music-generation · 단가 ai.google.dev/gemini-api/docs/pricing · 약관 ai.google.dev/gemini-api/terms
|
|
89
|
+
- ElevenLabs: elevenlabs.io/docs/api-reference/text-to-speech/convert-with-timestamps · /music/compose · 모델 elevenlabs.io/docs/models · 단가 elevenlabs.io/pricing/api · 음악 약관 elevenlabs.io/eleven-music-model-specific-terms
|
|
90
|
+
- Qwen TTS: alibabacloud.com/help/en/model-studio/qwen-tts-api · 목소리 /qwen-tts-voice-list · 단가 /model-pricing · 공개 가중치 github.com/QwenLM/Qwen3-TTS
|