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,402 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import type { ViteDevServer } from "vite";
|
|
5
|
+
import type { Session, Tab } from "./browser.ts";
|
|
6
|
+
import { type BlurOpts, captureBlurred } from "./blur.ts";
|
|
7
|
+
import { concat, encoder, type VideoFormat } from "./ffmpeg.ts";
|
|
8
|
+
import { repoRoot } from "./serve.ts";
|
|
9
|
+
|
|
10
|
+
// 장면 조각 렌더 — 장면마다 out/segments/<장면>-<해시>.mp4 를 두고, 해시가 같으면 다시 찍지 않는다.
|
|
11
|
+
// 해시는 그 장면이 실제로 불러온 모듈(Vite 모듈 그래프 — 장면 파일·lib·@kasaframe 소스·테마·glob 로 읽은 json)·
|
|
12
|
+
// kf.json 의 그 장면 칸·자산·자막 줄·굽기 설정이다. seek 모드는 프레임이 시각의 순수 함수라 조각을 이어도 통짜로 구운 것과 프레임이 같다.
|
|
13
|
+
|
|
14
|
+
// 조각 꼴(인코딩·해시 재료)이 바뀌면 올린다 — 옛 조각을 다시 쓰지 않게
|
|
15
|
+
const VERSION = "seg-1";
|
|
16
|
+
// 화면을 안 바꾸는 kf.json 칸 — 소리·굽기 기본값·검사 예외. 굽기 값(모션 블러)은 KeyOpts 로 따로 들어간다
|
|
17
|
+
const NOT_FRAMES = new Set(["scenes", "music", "audio", "voice", "narration", "render", "check"]);
|
|
18
|
+
const AUDIO = /\.(mp3|wav|m4a|aac|flac|ogg)$/i;
|
|
19
|
+
|
|
20
|
+
export interface SceneEntry {
|
|
21
|
+
id: string;
|
|
22
|
+
from: number;
|
|
23
|
+
dur: number;
|
|
24
|
+
role?: string;
|
|
25
|
+
file?: string;
|
|
26
|
+
[k: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SceneSpan {
|
|
30
|
+
id: string;
|
|
31
|
+
role?: string;
|
|
32
|
+
/** 프레임 [start, end) */
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 프레임 f 의 장면 — 페이지(mountProject)와 같은 판정 from ≤ f/fps. 조각들이 0..total 을 빈틈·겹침 없이 한 번씩 덮는다 */
|
|
38
|
+
export function sceneSpans(scenes: Pick<SceneEntry, "id" | "from" | "role">[], fps: number, total: number): SceneSpan[] {
|
|
39
|
+
const firstFrame = (from: number) => {
|
|
40
|
+
let f = Math.max(0, Math.round(from * fps) - 2);
|
|
41
|
+
while (f / fps < from) f++;
|
|
42
|
+
return Math.min(total, f);
|
|
43
|
+
};
|
|
44
|
+
return scenes
|
|
45
|
+
.map((s, i) => ({ id: s.id, role: s.role, start: i === 0 ? 0 : firstFrame(s.from), end: i === scenes.length - 1 ? total : firstFrame(scenes[i + 1].from) }))
|
|
46
|
+
.filter((s) => s.end > s.start);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface KeyOpts {
|
|
50
|
+
width: number;
|
|
51
|
+
height: number;
|
|
52
|
+
fps: number;
|
|
53
|
+
format: VideoFormat;
|
|
54
|
+
crf?: number;
|
|
55
|
+
blur: BlurOpts | null;
|
|
56
|
+
captions?: string;
|
|
57
|
+
/** 크롬 판 — 래스터가 판마다 다를 수 있다 */
|
|
58
|
+
browser: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 굽기 설정 묶음 하나 — 자막 켠 판·끈 판처럼 같은 장면을 다른 설정으로 구운 조각을 따로 센다 */
|
|
62
|
+
export const variantOf = (o: KeyOpts): string => sha(JSON.stringify([VERSION, o])).slice(0, 10);
|
|
63
|
+
|
|
64
|
+
const sha = (...parts: (string | Buffer)[]) => {
|
|
65
|
+
const h = createHash("sha256");
|
|
66
|
+
for (const p of parts) h.update(p).update("\0");
|
|
67
|
+
return h.digest("hex");
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const omit = (o: Record<string, unknown>, keys: Set<string>) => Object.fromEntries(Object.entries(o).filter(([k]) => !keys.has(k)));
|
|
71
|
+
|
|
72
|
+
function listFiles(dir: string): string[] {
|
|
73
|
+
if (!existsSync(dir)) return [];
|
|
74
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? listFiles(join(dir, e.name)) : [join(dir, e.name)]));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface Line {
|
|
78
|
+
scene?: string;
|
|
79
|
+
from?: number;
|
|
80
|
+
to?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SceneKey {
|
|
84
|
+
key: string;
|
|
85
|
+
/** 해시 재료 이름 → 그 해시. 조각 기록에 남겨 다음에 「무엇이 바뀌어 다시 찍나」를 짚는다 */
|
|
86
|
+
inputs: Record<string, string>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 장면마다 조각 해시. 장면이 다른 장면 모듈을 불러오면(이음매 — 앞 장면 끝·뒤 장면 처음을 살아 있는 화면으로 잇는다) 그 장면들의 해시도 묻는다.
|
|
91
|
+
* 이음매는 앞뒤 둘만 쓰므로(transition3d live) 이웃 둘로 좁힌다 — 멀리 있는 장면을 고쳐도 이음매는 그대로.
|
|
92
|
+
*/
|
|
93
|
+
export function sceneKeys(server: ViteDevServer, root: string, o: KeyOpts): Map<string, SceneKey> {
|
|
94
|
+
const kf = JSON.parse(readFileSync(join(root, "kf.json"), "utf8")) as { scenes: SceneEntry[]; [k: string]: unknown };
|
|
95
|
+
const graph = server.environments.client.moduleGraph;
|
|
96
|
+
const kfFile = join(root, "kf.json");
|
|
97
|
+
const capFile = join(root, "captions.json");
|
|
98
|
+
const sceneFile = new Map(kf.scenes.map((s) => [join(root, s.file ?? `scenes/${s.id}.ts`), s.id]));
|
|
99
|
+
const memo = new Map<string, string>();
|
|
100
|
+
const fileHash = (f: string) => {
|
|
101
|
+
let h = memo.get(f);
|
|
102
|
+
if (h === undefined) {
|
|
103
|
+
h = existsSync(f) && statSync(f).isFile() ? sha(readFileSync(f)) : "-";
|
|
104
|
+
memo.set(f, h);
|
|
105
|
+
}
|
|
106
|
+
return h;
|
|
107
|
+
};
|
|
108
|
+
// 레포 소스와 프로젝트 파일만 — node_modules(외부 꾸러미·미리 묶은 의존)는 판이 바뀔 일이 드물고 수가 많다
|
|
109
|
+
const label = (f: string): string | null => {
|
|
110
|
+
for (const [base, tag] of [[root, ""], [repoRoot, "kf:"]] as const) {
|
|
111
|
+
const rel = relative(base, f);
|
|
112
|
+
if (rel.startsWith("..") || rel.split(sep).includes("node_modules")) continue;
|
|
113
|
+
if (tag === "" && rel.split(sep)[0] === "out") return null;
|
|
114
|
+
return `${tag}${rel}`;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function walk(start: string): { files: Map<string, string>; scenes: Set<string> } {
|
|
120
|
+
const files = new Map<string, string>();
|
|
121
|
+
const scenes = new Set<string>();
|
|
122
|
+
const stack = [...(graph.getModulesByFile(start) ?? [])];
|
|
123
|
+
const seen = new Set<unknown>();
|
|
124
|
+
while (stack.length) {
|
|
125
|
+
const m = stack.pop()!;
|
|
126
|
+
if (seen.has(m)) continue;
|
|
127
|
+
seen.add(m);
|
|
128
|
+
const f = m.file;
|
|
129
|
+
if (f && f !== start && sceneFile.has(f)) {
|
|
130
|
+
scenes.add(sceneFile.get(f)!);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const name = f ? label(f) : null;
|
|
134
|
+
if (f && !name) continue;
|
|
135
|
+
if (f && name && f !== kfFile && f !== capFile) files.set(name, fileHash(f));
|
|
136
|
+
stack.push(...m.importedModules);
|
|
137
|
+
}
|
|
138
|
+
if (!files.size && existsSync(start)) files.set(label(start) ?? start, fileHash(start));
|
|
139
|
+
return { files, scenes };
|
|
140
|
+
}
|
|
141
|
+
const listed = (m: Map<string, string>) => JSON.stringify([...m].sort((a, b) => (a[0] < b[0] ? -1 : 1)));
|
|
142
|
+
|
|
143
|
+
const base = walk(join(root, "main.ts")).files;
|
|
144
|
+
base.set("index.html", fileHash(join(root, "index.html")));
|
|
145
|
+
base.set("kf.json 화면 칸", sha(JSON.stringify(omit(kf, NOT_FRAMES))));
|
|
146
|
+
base.set("굽기 설정", sha(JSON.stringify([VERSION, o])));
|
|
147
|
+
const common = sha(listed(base));
|
|
148
|
+
const lines: Line[] = existsSync(capFile) ? JSON.parse(readFileSync(capFile, "utf8")) : [];
|
|
149
|
+
const assets = listFiles(join(root, "assets")).filter((f) => !AUDIO.test(f)).map((f) => ({ f, rel: relative(join(root, "assets"), f).split(sep).join("/") }));
|
|
150
|
+
|
|
151
|
+
const own = new Map<string, string>();
|
|
152
|
+
const inputs = new Map<string, Record<string, string>>();
|
|
153
|
+
const deps = new Map<string, Set<string>>();
|
|
154
|
+
for (const [i, s] of kf.scenes.entries()) {
|
|
155
|
+
const file = join(root, s.file ?? `scenes/${s.id}.ts`);
|
|
156
|
+
const w = walk(file);
|
|
157
|
+
// 자산은 장면이 글자로 적은 경로(프로젝트 안 소스) + 장면 id 로 시작하는 파일(gen/<id>.png·blender/<id>.mp4·seam/<id>-from.png)
|
|
158
|
+
const text = [...w.files.keys()].filter((n) => !n.startsWith("kf:")).map((n) => (existsSync(join(root, n)) ? readFileSync(join(root, n), "utf8") : "")).join("\n");
|
|
159
|
+
const used = assets
|
|
160
|
+
.filter(({ f, rel }) => text.includes(rel) || new RegExp(`^${s.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([.\\-_]|$)`).test(f.split(sep).pop()!))
|
|
161
|
+
.map(({ f, rel }) => [rel, fileHash(f)]);
|
|
162
|
+
const cap = lines.filter((l) => l.scene === s.id || (l.scene === undefined && l.from !== undefined && l.from < s.from + s.dur && (l.to ?? l.from) >= s.from));
|
|
163
|
+
own.set(s.id, sha(common, listed(w.files), JSON.stringify(omit(s, new Set(["idea"]))), JSON.stringify(cap), JSON.stringify(used)));
|
|
164
|
+
inputs.set(s.id, {
|
|
165
|
+
...Object.fromEntries(base),
|
|
166
|
+
...Object.fromEntries(w.files),
|
|
167
|
+
"kf.json 장면 칸": sha(JSON.stringify(omit(s, new Set(["idea"])))),
|
|
168
|
+
"자막 줄": sha(JSON.stringify(cap)),
|
|
169
|
+
...Object.fromEntries(used.map(([rel, h]) => [`assets/${rel}`, h])),
|
|
170
|
+
});
|
|
171
|
+
const d = new Set(s.role === "seam" ? [kf.scenes[i - 1]?.id, kf.scenes[i + 1]?.id].filter((x): x is string => !!x) : w.scenes);
|
|
172
|
+
deps.set(s.id, d);
|
|
173
|
+
}
|
|
174
|
+
// 경계 프레임에서 앞 장면이 아직 켜져 있으면(from+dur 이 다음 from 보다 끝자리만큼 큰 경우) 그 프레임은 두 장면이 겹쳐 그린다
|
|
175
|
+
const spans = sceneSpans(kf.scenes, o.fps, Math.round(Number(kf.duration) * o.fps));
|
|
176
|
+
for (const [i, sp] of spans.entries()) {
|
|
177
|
+
const prev = kf.scenes.find((s) => s.id === spans[i - 1]?.id);
|
|
178
|
+
if (prev && sp.start / o.fps < prev.from + prev.dur) deps.get(sp.id)!.add(prev.id);
|
|
179
|
+
}
|
|
180
|
+
return new Map(
|
|
181
|
+
kf.scenes.map((s) => {
|
|
182
|
+
const ds = [...deps.get(s.id)!].sort();
|
|
183
|
+
const key = sha(own.get(s.id)!, ...ds.map((d) => own.get(d) ?? d)).slice(0, 12);
|
|
184
|
+
return [s.id, { key, inputs: { ...inputs.get(s.id)!, ...Object.fromEntries(ds.map((d) => [`장면 ${d}`, own.get(d) ?? d])) } }];
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 그 장면의 가장 최근 조각 기록(해시가 달라도) — 바뀐 재료와 장당 걸린 시간을 여기서 읽는다
|
|
190
|
+
function lastRecord(root: string, id: string): Partial<SegmentReport> & { tabSeconds?: number } | null {
|
|
191
|
+
const dir = segmentDir(root);
|
|
192
|
+
if (!existsSync(dir)) return null;
|
|
193
|
+
const prev = readdirSync(dir)
|
|
194
|
+
.filter((f) => f.startsWith(`${id}-`) && f.endsWith(".json") && /^[0-9a-f]{12}$/.test(f.slice(id.length + 1, -5)))
|
|
195
|
+
.map((f) => join(dir, f))
|
|
196
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
197
|
+
if (!prev) return null;
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(readFileSync(prev, "utf8"));
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 같은 장면의 지난 조각 기록과 견줘 바뀐 재료 이름 — 「왜 다시 찍나」 */
|
|
206
|
+
export function changedInputs(root: string, id: string, now: Record<string, string>): string[] | null {
|
|
207
|
+
const old = lastRecord(root, id)?.inputs;
|
|
208
|
+
if (!old) return null;
|
|
209
|
+
return [...new Set([...Object.keys(old), ...Object.keys(now)])].filter((k) => old[k] !== now[k]).sort();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface SegmentReport {
|
|
213
|
+
id: string;
|
|
214
|
+
key: string;
|
|
215
|
+
file: string;
|
|
216
|
+
frames: number;
|
|
217
|
+
seconds: number;
|
|
218
|
+
events: MotionEvent[];
|
|
219
|
+
inputs?: Record<string, string>;
|
|
220
|
+
/** 탭들이 이 장면을 찍는 데 쓴 초의 합 — 다음 굽기가 장면 무게를 어림한다 */
|
|
221
|
+
tabSeconds?: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface MotionEvent {
|
|
225
|
+
t: number;
|
|
226
|
+
kind: string;
|
|
227
|
+
source: string;
|
|
228
|
+
scene?: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const segmentDir = (root: string) => join(root, "out", "segments");
|
|
232
|
+
export const segmentFile = (root: string, id: string, key: string, ext: string) => join(segmentDir(root), `${id}-${key}${ext}`);
|
|
233
|
+
|
|
234
|
+
/** 이미 구운 조각 — 영상과 기록(.json) 둘 다 있어야 다시 쓴다 */
|
|
235
|
+
export function cachedSegment(root: string, id: string, key: string, ext: string): SegmentReport | null {
|
|
236
|
+
const file = segmentFile(root, id, key, ext);
|
|
237
|
+
const meta = segmentFile(root, id, key, ".json");
|
|
238
|
+
if (!existsSync(file) || !existsSync(meta)) return null;
|
|
239
|
+
try {
|
|
240
|
+
return { ...JSON.parse(readFileSync(meta, "utf8")), file };
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function split(start: number, end: number, n: number): [number, number][] {
|
|
247
|
+
const out: [number, number][] = [];
|
|
248
|
+
for (let i = 0; i < n; i++) {
|
|
249
|
+
const a = start + Math.floor(((end - start) * i) / n);
|
|
250
|
+
const b = start + Math.floor(((end - start) * (i + 1)) / n);
|
|
251
|
+
if (b > a) out.push([a, b]);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface SegmentWork {
|
|
257
|
+
span: SceneSpan;
|
|
258
|
+
key: string;
|
|
259
|
+
inputs?: Record<string, string>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 고른 장면 조각을 탭 여러 개로 굽는다. 장면 하나가 길면 탭 몫으로 쪼개 찍고 그 장면 조각 하나로 잇는다(탭이 놀지 않게).
|
|
264
|
+
* 장면마다 「장면 <id>: 렌더」「장면 <id>: 구움」 줄을 찍는다 — 작업실이 타임라인 상태로 읽는다
|
|
265
|
+
*/
|
|
266
|
+
export async function renderSegments(o: {
|
|
267
|
+
session: Session;
|
|
268
|
+
root: string;
|
|
269
|
+
work: SegmentWork[];
|
|
270
|
+
tabs: number;
|
|
271
|
+
format: VideoFormat;
|
|
272
|
+
ext: string;
|
|
273
|
+
crf?: number;
|
|
274
|
+
blur: BlurOpts | null;
|
|
275
|
+
tmp: string;
|
|
276
|
+
onFrame?: () => void;
|
|
277
|
+
}): Promise<SegmentReport[]> {
|
|
278
|
+
const { session: s, root } = o;
|
|
279
|
+
const { fps, width, height } = s.info.meta;
|
|
280
|
+
mkdirSync(segmentDir(root), { recursive: true });
|
|
281
|
+
mkdirSync(o.tmp, { recursive: true });
|
|
282
|
+
// 장면마다 장당 무게가 다르다(3D 이음매는 글 장면의 두 배 넘게) — 지난 기록으로 어림해 탭마다 두 몫쯤 되게 쪼개고
|
|
283
|
+
// 무거운 몫부터 나눠 준다. 기록이 없으면 장 수로 어림한다
|
|
284
|
+
const perFrame = new Map(o.work.map((w) => {
|
|
285
|
+
const r = lastRecord(root, w.span.id);
|
|
286
|
+
return [w.span.id, r?.frames ? (r.tabSeconds ?? r.seconds ?? 0) / r.frames : 0];
|
|
287
|
+
}));
|
|
288
|
+
const known = [...perFrame.values()].filter((x) => x > 0);
|
|
289
|
+
const guess = known.length ? known.reduce((a, b) => a + b, 0) / known.length : 1;
|
|
290
|
+
const cost = (w: SegmentWork) => (w.span.end - w.span.start) * (perFrame.get(w.span.id) || guess);
|
|
291
|
+
const unit = o.work.reduce((n, w) => n + cost(w), 0) / Math.max(1, o.tabs * 2);
|
|
292
|
+
const minFrames = Math.ceil(fps / 2);
|
|
293
|
+
const parts = o.work
|
|
294
|
+
.flatMap((w) => {
|
|
295
|
+
const frames = w.span.end - w.span.start;
|
|
296
|
+
const n = Math.max(1, Math.min(Math.round(cost(w) / unit), Math.floor(frames / minFrames)));
|
|
297
|
+
return split(w.span.start, w.span.end, n).map(([a, b], k) => ({ w, k, a, b, cost: (cost(w) * (b - a)) / frames }));
|
|
298
|
+
})
|
|
299
|
+
.sort((x, y) => y.cost - x.cost);
|
|
300
|
+
const busy = new Map<string, number>();
|
|
301
|
+
const left = new Map(o.work.map((w) => [w.span.id, parts.filter((p) => p.w === w).length]));
|
|
302
|
+
const started = new Map<string, number>();
|
|
303
|
+
const finished: Promise<void>[] = [];
|
|
304
|
+
const queue = [...parts];
|
|
305
|
+
const tabs: Tab[] = [];
|
|
306
|
+
const workers = Math.min(o.tabs, parts.length);
|
|
307
|
+
|
|
308
|
+
async function join1(w: SegmentWork): Promise<void> {
|
|
309
|
+
const n = parts.filter((p) => p.w === w).length;
|
|
310
|
+
const list = join(o.tmp, `${w.span.id}.txt`);
|
|
311
|
+
writeFileSync(list, Array.from({ length: n }, (_, k) => `file '${join(o.tmp, `${w.span.id}-${k}${o.ext}`).replace(/'/g, "'\\''")}'`).join("\n") + "\n");
|
|
312
|
+
const tmpOut = join(o.tmp, `${w.span.id}${o.ext}`);
|
|
313
|
+
await concat({ list, out: tmpOut, format: o.format, from: 0, dur: (w.span.end - w.span.start) / fps });
|
|
314
|
+
renameSync(tmpOut, segmentFile(root, w.span.id, w.key, o.ext));
|
|
315
|
+
console.error(`장면 ${w.span.id}: 구움 — ${w.span.end - w.span.start}장 ${((Date.now() - started.get(w.span.id)!) / 1000).toFixed(1)}초`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await Promise.all(
|
|
319
|
+
Array.from({ length: workers }, async (_, i) => {
|
|
320
|
+
const tab = i === 0 ? s.first : await s.tab();
|
|
321
|
+
tabs.push(tab);
|
|
322
|
+
for (let p = queue.shift(); p; p = queue.shift()) {
|
|
323
|
+
if (!started.has(p.w.span.id)) {
|
|
324
|
+
started.set(p.w.span.id, Date.now());
|
|
325
|
+
const why = p.w.inputs && changedInputs(root, p.w.span.id, p.w.inputs);
|
|
326
|
+
console.error(`장면 ${p.w.span.id}: 렌더${why?.length ? ` — 바뀜: ${why.slice(0, 3).join(", ")}${why.length > 3 ? ` 외 ${why.length - 3}` : ""}` : ""}`);
|
|
327
|
+
}
|
|
328
|
+
const raw = o.blur ? { width, height } : undefined;
|
|
329
|
+
const t0 = Date.now();
|
|
330
|
+
const enc = encoder(join(o.tmp, `${p.w.span.id}-${p.k}${o.ext}`), fps, o.format, o.crf, raw);
|
|
331
|
+
for (let f = p.a; f < p.b; f++) {
|
|
332
|
+
if (o.blur) await enc.write((await captureBlurred(tab, s.info, f / fps, o.blur)).data);
|
|
333
|
+
else {
|
|
334
|
+
await tab.seek(f / fps);
|
|
335
|
+
await enc.write(await tab.capture());
|
|
336
|
+
}
|
|
337
|
+
o.onFrame?.();
|
|
338
|
+
}
|
|
339
|
+
await enc.end();
|
|
340
|
+
busy.set(p.w.span.id, (busy.get(p.w.span.id) ?? 0) + (Date.now() - t0) / 1000);
|
|
341
|
+
const rest = left.get(p.w.span.id)! - 1;
|
|
342
|
+
left.set(p.w.span.id, rest);
|
|
343
|
+
if (rest === 0) finished.push(join1(p.w));
|
|
344
|
+
}
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
await Promise.all(finished);
|
|
348
|
+
|
|
349
|
+
// 움직임 기록은 탭에 쌓인다 — 장면 시각으로 갈라 조각마다 남긴다(검사가 영상 옆 .json 으로 센다)
|
|
350
|
+
const events = await collectEvents(tabs);
|
|
351
|
+
return o.work.map((w) => {
|
|
352
|
+
const from = w.span.start / fps;
|
|
353
|
+
const to = w.span.end / fps;
|
|
354
|
+
const rep: SegmentReport = {
|
|
355
|
+
id: w.span.id,
|
|
356
|
+
key: w.key,
|
|
357
|
+
file: segmentFile(root, w.span.id, w.key, o.ext),
|
|
358
|
+
frames: w.span.end - w.span.start,
|
|
359
|
+
seconds: Math.round((Date.now() - started.get(w.span.id)!) / 100) / 10,
|
|
360
|
+
events: events.filter((e) => e.t >= from - 1e-9 && e.t < to - 1e-9),
|
|
361
|
+
tabSeconds: Math.round((busy.get(w.span.id) ?? 0) * 10) / 10,
|
|
362
|
+
...(w.inputs ? { inputs: w.inputs } : {}),
|
|
363
|
+
};
|
|
364
|
+
const { file: _f, ...meta } = rep;
|
|
365
|
+
writeFileSync(segmentFile(root, w.span.id, w.key, ".json"), JSON.stringify(meta));
|
|
366
|
+
return rep;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function collectEvents(tabs: Tab[]): Promise<MotionEvent[]> {
|
|
371
|
+
const seen = new Set<string>();
|
|
372
|
+
const out: MotionEvent[] = [];
|
|
373
|
+
for (const tab of tabs) {
|
|
374
|
+
const rows = await tab.page.evaluate(() => (window.__kf?.events ?? []) as unknown[]).catch(() => []);
|
|
375
|
+
for (const r of rows as MotionEvent[]) {
|
|
376
|
+
const key = `${r.t}|${r.kind}|${r.source}|${r.scene ?? ""}`;
|
|
377
|
+
if (seen.has(key)) continue;
|
|
378
|
+
seen.add(key);
|
|
379
|
+
out.push(r);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return out.sort((a, b) => a.t - b.t);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 굽기 설정마다 지금 쓰는 조각을 적고, 어느 설정에서도 안 쓰는 옛 조각은 지운다(최근 설정 넷까지 둔다) */
|
|
386
|
+
export function pruneSegments(root: string, variant: string, current: Record<string, string>): void {
|
|
387
|
+
const dir = segmentDir(root);
|
|
388
|
+
const indexFile = join(dir, "index.json");
|
|
389
|
+
let index: Record<string, { at: number; scenes: Record<string, string> }> = {};
|
|
390
|
+
try {
|
|
391
|
+
index = JSON.parse(readFileSync(indexFile, "utf8"));
|
|
392
|
+
} catch {}
|
|
393
|
+
index[variant] = { at: Date.now(), scenes: current };
|
|
394
|
+
const keep = Object.entries(index).sort((a, b) => b[1].at - a[1].at).slice(0, 4);
|
|
395
|
+
index = Object.fromEntries(keep);
|
|
396
|
+
writeFileSync(indexFile, JSON.stringify(index, null, 1));
|
|
397
|
+
const used = new Set(keep.flatMap(([, v]) => Object.entries(v.scenes).map(([id, key]) => `${id}-${key}`)));
|
|
398
|
+
for (const f of readdirSync(dir)) {
|
|
399
|
+
if (f === "index.json") continue;
|
|
400
|
+
if (!used.has(f.replace(/\.[^.]+$/, ""))) rmSync(join(dir, f), { force: true });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
@@ -24,4 +24,4 @@ export type { Style, CssStyle } from "./style.ts";
|
|
|
24
24
|
export { setFrameTime, frameTime, onFrame, emit, motionEvents, resetMotionEvents, setFrameSample, frameSample } from "./frame.ts";
|
|
25
25
|
export type { KfMotionEvent, MotionEventKind, FrameSample } from "./frame.ts";
|
|
26
26
|
export { silentBeats, scaleOf, cssVars, fontCss } from "./project.ts";
|
|
27
|
-
export type { KfMeta, KfStyle, MotionRules, SafeArea, SceneCtx, FontFace } from "./project.ts";
|
|
27
|
+
export type { CaptionLook, KfMeta, KfStyle, MotionRules, SafeArea, SceneCtx, FontFace } from "./project.ts";
|
|
@@ -90,10 +90,21 @@ export interface KfStyle {
|
|
|
90
90
|
radius: { sm: number; md: number; lg: number; pill: number };
|
|
91
91
|
shadow: { soft: string; lift: string };
|
|
92
92
|
motion: MotionRules;
|
|
93
|
+
/** 화면 자막 판 — 안 주면 화면 크기로 정한다(세로 폭의 8%·안전 영역 아래쪽·두 줄). srt·vtt 마디 나누기도 같은 값을 쓴다 */
|
|
94
|
+
captions?: CaptionLook;
|
|
93
95
|
/** 디자인 출처·지킬 점 — 사람과 모델이 읽는다 */
|
|
94
96
|
notes?: string[];
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
export interface CaptionLook {
|
|
100
|
+
/** 글자 크기(px) — 틀의 text.minPx 아래로는 안 간다 */
|
|
101
|
+
size?: number;
|
|
102
|
+
/** 판 가운데의 y(px, 화면 위에서) — 안 주면 안전 영역 아래 가장자리에 붙인다 */
|
|
103
|
+
position?: number;
|
|
104
|
+
/** 한 마디가 차지할 줄 수(기본 2) — 넘치면 마디를 더 잘게 나눈다 */
|
|
105
|
+
maxLines?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
export interface SceneCtx {
|
|
98
109
|
id: string;
|
|
99
110
|
/** 영상 전체에서 장면이 시작하는 시각(초) */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kasaframe/director",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./pattern": "./src/pattern.ts",
|
|
9
|
+
"./scene": "./src/scene.ts",
|
|
10
|
+
"./fallback": "./src/fallback.ts",
|
|
11
|
+
"./patterns/*": "./src/patterns/*.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@kasaframe/core": "0.0.0",
|
|
15
|
+
"@kasaframe/gen": "*",
|
|
16
|
+
"@kasaframe/motion": "^0.0.0",
|
|
17
|
+
"@kasaframe/plan": "*",
|
|
18
|
+
"@kasaframe/themes": "*"
|
|
19
|
+
},
|
|
20
|
+
"optionalDependencies": {
|
|
21
|
+
"@kasaframe/patterns": "*"
|
|
22
|
+
}
|
|
23
|
+
}
|