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,361 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { dressSeed, type Dress } from "@kasaframe/motion/menu";
|
|
5
|
+
import { loadTemplate, sceneSkeleton, type Template } from "@kasaframe/plan";
|
|
6
|
+
import { catalog } from "./catalog.ts";
|
|
7
|
+
import { TYPES, type Content, type Scene, type SceneType } from "./content.ts";
|
|
8
|
+
import type { AspectName, Energy, TextPolicy } from "./pattern.ts";
|
|
9
|
+
import { type Entry, type Pick, repick, selectAll, type Slot } from "./select.ts";
|
|
10
|
+
import { type BeatsLite, cyclingText, dressAll, energyOf, seamKinds, seamSpots, type Spot } from "./transitions.ts";
|
|
11
|
+
|
|
12
|
+
// content.json → 장면 시간표·장면 파일. 두 단계로 나뉜다 — 그 사이에 kf narrate 가 목소리로 장면 길이를 정한다.
|
|
13
|
+
// 1. layout: 내용 장면 + 이음매를 kf.json 에 놓고 들릴 말을 captions.json 에(길이는 어림)
|
|
14
|
+
// 2. build: 정해진 길이·곡 구간으로 패턴을 고르고 장면 파일·director.json 을 쓴다(LLM 없이 템플릿 조립)
|
|
15
|
+
// 같은 content.json·틀·테마·씨앗이면 쓰는 파일이 바이트까지 같다 — 시각·경로·난수 없이 씨앗으로만 고른다.
|
|
16
|
+
|
|
17
|
+
export const MARK = "// kf direct 가 지은 장면";
|
|
18
|
+
// kf new 뼈대(무대·이음매) — 감독이 처음 들어설 때 지워도 되는 파일
|
|
19
|
+
const SKELETON = /makeStage\(root, ctx, \{ dress: DRESS \}\)|transition3d\(root, ctx, \{ kind: KIND/;
|
|
20
|
+
|
|
21
|
+
interface KfScene {
|
|
22
|
+
id: string;
|
|
23
|
+
from: number;
|
|
24
|
+
dur: number;
|
|
25
|
+
file: string;
|
|
26
|
+
role: string;
|
|
27
|
+
idea: string;
|
|
28
|
+
type?: SceneType;
|
|
29
|
+
space?: "2d" | "3d";
|
|
30
|
+
pattern?: string;
|
|
31
|
+
transition?: string;
|
|
32
|
+
dress?: Dress;
|
|
33
|
+
[k: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const readJson = <T = any>(p: string): T => JSON.parse(readFileSync(p, "utf8"));
|
|
37
|
+
const writeJson = (p: string, v: unknown) => writeFileSync(p, `${JSON.stringify(v, null, 2)}\n`);
|
|
38
|
+
|
|
39
|
+
// say 가 없을 때 장면 길이 — 숫자·이름 하나는 짧게, 구조를 보이는 것은 길게
|
|
40
|
+
const TYPE_SEC: Partial<Record<SceneType, number>> = { "hook-number": 3, title: 2.5, words: 2.5, end: 2.5, quote: 4, limit: 3, "term-reveal": 3.5 };
|
|
41
|
+
const CHARS_PER_SEC = 4.5;
|
|
42
|
+
|
|
43
|
+
function roleOf(s: Scene, i: number): string {
|
|
44
|
+
if (s.type === "end") return "end";
|
|
45
|
+
if (i === 0) return "hook";
|
|
46
|
+
if (s.type === "term-reveal" || s.type === "title") return "reveal";
|
|
47
|
+
if (s.type === "limit") return "limit";
|
|
48
|
+
return "explain";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const spaceOf = (s: { space?: string }): "2d" | "3d" => (s.space === "3d" ? "3d" : "2d");
|
|
52
|
+
|
|
53
|
+
export function contentHash(c: Content): string {
|
|
54
|
+
return createHash("sha256").update(JSON.stringify(c)).digest("hex").slice(0, 16);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 1단계 — 내용 장면과 이음매를 시간표에. 길이는 어림(kf narrate 가 목소리로 고친다) */
|
|
58
|
+
export function layout(dir: string, content: Content, o: { keepTiming?: boolean } = {}): { scenes: KfScene[]; seams: number } {
|
|
59
|
+
const kfPath = join(dir, "kf.json");
|
|
60
|
+
const kf = readJson(kfPath);
|
|
61
|
+
const tpl = loadTemplate(kf.template);
|
|
62
|
+
const fps: number = kf.fps;
|
|
63
|
+
const frame = (x: number) => Math.max(1, Math.round(x * fps)) / fps;
|
|
64
|
+
const n = tpl.narration ?? { lead: 0.25, tail: 0.5 };
|
|
65
|
+
// keepTiming(kf narrate 를 건너뛸 때) — 같은 장면(id·type)에 들릴 말도 같으면 앞 길이(목소리로 잰 것)를 그대로 둔다.
|
|
66
|
+
// 내레이션을 다시 돌릴 땐 늘 어림에서 시작한다 — 말 없는 장면·이음매에 앞 실행의 길이가 남으면 같은 content.json 이 다른 시간표를 낸다
|
|
67
|
+
const capPath = join(dir, "captions.json");
|
|
68
|
+
const oldCaps: { scene: string; text: string; from?: number }[] = existsSync(capPath) ? readJson(capPath) : [];
|
|
69
|
+
const said = (id: string) => oldCaps.filter((l) => l.scene === id).map((l) => l.text.trim()).join(" ");
|
|
70
|
+
const oldScenes = new Map<string, KfScene>((kf.scenes ?? []).map((x: KfScene) => [x.id, x]));
|
|
71
|
+
const est = (s: Scene) => {
|
|
72
|
+
const old = oldScenes.get(s.id);
|
|
73
|
+
if (o.keepTiming && old && old.type === s.type && said(s.id) === (s.say?.trim() ?? "")) return old.dur;
|
|
74
|
+
return s.say ? n.lead + [...s.say].length / CHARS_PER_SEC + n.tail : (s.dur ?? TYPE_SEC[s.type] ?? 5);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const tplSeams = tpl.scenes.filter((s) => s.role === "seam");
|
|
78
|
+
const seamSec = tplSeams[0]?.sec ?? 1.2;
|
|
79
|
+
// 투영을 맞춰 구운 3D 샷(kf blender 가 kf.json 장면에 남긴 matched)은 다시 놓아도 잃지 않는다
|
|
80
|
+
const matched = new Set<string>((kf.scenes ?? []).filter((s: KfScene) => s.matched).map((s: KfScene) => s.id));
|
|
81
|
+
const spots = seamSpots(content.scenes.map((s) => s.type), content.scenes.map(spaceOf), tplSeams.length, content.scenes.map((s) => matched.has(s.id)));
|
|
82
|
+
const taken = new Set(content.scenes.map((s) => s.id));
|
|
83
|
+
const seamIds: string[] = [];
|
|
84
|
+
for (let k = 0; seamIds.length < spots.length; k++) {
|
|
85
|
+
const id = tplSeams[k]?.id ?? `seam-${k + 1}`;
|
|
86
|
+
if (!taken.has(id) && !seamIds.includes(id)) seamIds.push(id);
|
|
87
|
+
}
|
|
88
|
+
const scenes: KfScene[] = [];
|
|
89
|
+
let t = 0;
|
|
90
|
+
const put = (s: { id: string; role: string; dur: number; idea: string; [k: string]: unknown }) => {
|
|
91
|
+
const dur = frame(s.dur);
|
|
92
|
+
scenes.push({ ...s, from: +t.toFixed(4), dur: +dur.toFixed(4), file: `scenes/${s.id}.ts` });
|
|
93
|
+
t += dur;
|
|
94
|
+
};
|
|
95
|
+
for (const [i, s] of content.scenes.entries()) {
|
|
96
|
+
const sp = spots.findIndex((x) => x.at === i);
|
|
97
|
+
// 이음매도 앞 길이(박에 붙인 것)를 그대로 — 안 그러면 뒤 장면 시각이 다 밀린다
|
|
98
|
+
if (sp >= 0) put({ id: seamIds[sp], role: "seam", dur: o.keepTiming && oldScenes.get(seamIds[sp])?.role === "seam" ? oldScenes.get(seamIds[sp])!.dur : seamSec, idea: spots[sp].bridge ? "공간 잇기 — 2D 와 3D 장면 사이(감독이 고른 전환)" : "이음매 — 감독이 고른 3D 전환", ...(spots[sp].bridge ? { bridge: spots[sp].bridge } : {}) });
|
|
99
|
+
put({ id: s.id, role: roleOf(s, i), dur: est(s), idea: s.type === "custom" ? String(s.idea ?? "") : `${s.type} — ${TYPES[s.type].does}`, type: s.type, ...(s.space ? { space: s.space } : {}), ...(matched.has(s.id) ? { matched: true } : {}) });
|
|
100
|
+
}
|
|
101
|
+
kf.title = content.title;
|
|
102
|
+
kf.scenes = scenes;
|
|
103
|
+
kf.duration = +t.toFixed(4);
|
|
104
|
+
writeJson(kfPath, kf);
|
|
105
|
+
|
|
106
|
+
// 낱말 시각(kf narrate 가 단 것)은 말이 같으면 남긴다
|
|
107
|
+
const lines = content.scenes.filter((s) => s.say?.trim()).map((s) => oldCaps.find((l) => l.scene === s.id && l.text.trim() === s.say!.trim() && l.from !== undefined) ?? { scene: s.id, text: s.say!.trim() });
|
|
108
|
+
if (lines.length) writeJson(capPath, lines);
|
|
109
|
+
const planPath = join(dir, "plan.json");
|
|
110
|
+
if (existsSync(planPath)) {
|
|
111
|
+
const plan = readJson(planPath);
|
|
112
|
+
plan.title = content.title;
|
|
113
|
+
plan.duration = kf.duration;
|
|
114
|
+
plan.scenes = scenes.map((s) => ({ id: s.id, role: s.role, from: s.from, dur: s.dur, idea: s.idea, assets: [] }));
|
|
115
|
+
const end = content.scenes.find((s) => s.type === "end");
|
|
116
|
+
if (typeof end?.line === "string") plan.line = end.line;
|
|
117
|
+
writeJson(planPath, plan);
|
|
118
|
+
}
|
|
119
|
+
return { scenes, seams: spots.length };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface BuildOpts {
|
|
123
|
+
seed?: string;
|
|
124
|
+
/** 장면 id → kf check 에서 떨어진 패턴 id */
|
|
125
|
+
failed?: Record<string, string[]>;
|
|
126
|
+
/** 이음매 장면 id → 떨어진 전환 종류 */
|
|
127
|
+
seamFailed?: Record<string, string[]>;
|
|
128
|
+
entries?: Entry[];
|
|
129
|
+
/** 이 장면만 다시 짓는다 — 나머지 장면 파일·고름·이음매는 손대지 않는다(조각 렌더가 바뀐 장면만 다시 굽게) */
|
|
130
|
+
only?: string[];
|
|
131
|
+
/** 사람이 고른 것 — 장면 id → 패턴 id·장면 씨앗. director.json 의 pinned 에 남아 다음 kf direct 가 덮지 않는다 */
|
|
132
|
+
pins?: Record<string, Pin>;
|
|
133
|
+
/** 장면마다 진행 줄(「장면 <id>: 쓰는 중|씀|실패 <까닭>」 — 굽기·검사까지 끝난 「끝」은 작업실·render 몫) */
|
|
134
|
+
log?: (line: string) => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface Pin {
|
|
138
|
+
pattern?: string;
|
|
139
|
+
seed?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface SceneReport {
|
|
143
|
+
id: string;
|
|
144
|
+
role: string;
|
|
145
|
+
from: number;
|
|
146
|
+
dur: number;
|
|
147
|
+
type?: string;
|
|
148
|
+
energy?: Energy;
|
|
149
|
+
pattern?: string;
|
|
150
|
+
transition?: string;
|
|
151
|
+
dress?: Dress;
|
|
152
|
+
custom?: "kept" | "skeleton";
|
|
153
|
+
order?: { id: string; score: number; why: string[] }[];
|
|
154
|
+
rejected?: { id: string; why: string }[];
|
|
155
|
+
attempt?: number;
|
|
156
|
+
/** 그 장면 내용의 해시 — 안 바뀐 장면은 고름을 그대로 둔다 */
|
|
157
|
+
hash?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface Report {
|
|
161
|
+
version: 1;
|
|
162
|
+
seed: string;
|
|
163
|
+
key: string;
|
|
164
|
+
content: string;
|
|
165
|
+
purpose: string;
|
|
166
|
+
theme: string | null;
|
|
167
|
+
aspect: AspectName;
|
|
168
|
+
scenes: SceneReport[];
|
|
169
|
+
failed: Record<string, string[]>;
|
|
170
|
+
seamFailed: Record<string, string[]>;
|
|
171
|
+
pinned: Record<string, Pin>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const aspectOf = (w: number, h: number): AspectName => (w > h ? "16:9" : w < h ? "9:16" : "1:1");
|
|
175
|
+
|
|
176
|
+
function fill(src: string, vars: Record<string, string>): string {
|
|
177
|
+
return src.replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? "").replace(/["\\]/g, "\\$&"));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 장면에서 패턴에 넘길 내용 — 장면 칸(id·type·say·dur·space)을 뺀 나머지 */
|
|
181
|
+
function payload(s: Scene): Record<string, unknown> {
|
|
182
|
+
const { id: _i, type: _t, say: _s, dur: _d, space: _p, ...rest } = s;
|
|
183
|
+
return rest;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const sceneHash = (s: Scene): string => createHash("sha256").update(JSON.stringify([s.type, payload(s)])).digest("hex").slice(0, 12);
|
|
187
|
+
|
|
188
|
+
function patternFile(spec: string, d: Record<string, unknown>): string {
|
|
189
|
+
return `${MARK} — 정본은 content.json 이다. 고치려면 content.json 을 고치고 kf direct 를 다시 부른다(이 파일은 덮인다).
|
|
190
|
+
import pattern from "${spec}";
|
|
191
|
+
import { directed } from "@kasaframe/director/scene";
|
|
192
|
+
import { makeStage } from "../lib/stage.ts";
|
|
193
|
+
|
|
194
|
+
const captions = import.meta.glob("../captions.json", { eager: true, import: "default" });
|
|
195
|
+
|
|
196
|
+
export default directed(pattern, ${JSON.stringify(d, null, 2)}, { makeStage, captions });
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 2단계 — 패턴·전환·바탕을 고르고 장면 파일을 쓴다 */
|
|
201
|
+
export async function build(dir: string, content: Content, o: BuildOpts = {}): Promise<Report> {
|
|
202
|
+
const kf = readJson(join(dir, "kf.json"));
|
|
203
|
+
const plan = existsSync(join(dir, "plan.json")) ? readJson(join(dir, "plan.json")) : {};
|
|
204
|
+
const tpl: Template = loadTemplate(kf.template);
|
|
205
|
+
const entries = o.entries ?? (await catalog());
|
|
206
|
+
const beats: BeatsLite | null = kf.beats && existsSync(join(dir, kf.beats)) ? readJson(join(dir, kf.beats)) : null;
|
|
207
|
+
const theme: string | null = kf.theme ?? null;
|
|
208
|
+
const purpose: string = kf.template;
|
|
209
|
+
const aspect = aspectOf(kf.width, kf.height);
|
|
210
|
+
const policy: TextPolicy = kf.text?.policy ?? tpl.text.policy;
|
|
211
|
+
const seed = o.seed ?? content.seed ?? "0";
|
|
212
|
+
const key = dressSeed(content.title, purpose, theme ?? plan.style, seed);
|
|
213
|
+
const hash = contentHash(content);
|
|
214
|
+
const scenes: KfScene[] = kf.scenes;
|
|
215
|
+
const byId = new Map(content.scenes.map((s) => [s.id, s]));
|
|
216
|
+
|
|
217
|
+
const prevPath = join(dir, "director.json");
|
|
218
|
+
const prev: Report | null = existsSync(prevPath) ? readJson(prevPath) : null;
|
|
219
|
+
// 씨앗 열쇠가 같고 내용이 같은 장면만 앞 기록(떨어진 패턴·핀)을 이어 간다
|
|
220
|
+
const same = !!prev && prev.key === key;
|
|
221
|
+
const prevHash = new Map((same ? prev.scenes : []).map((x) => [x.id, x.hash]));
|
|
222
|
+
const unchanged = (id: string) => byId.has(id) && prevHash.get(id) === sceneHash(byId.get(id)!);
|
|
223
|
+
// 떨어진 기록은 「패턴 id@판」 — 그 패턴이 그 뒤 고쳐졌으면(판이 다르면) 다시 후보로 돌아온다
|
|
224
|
+
const revOf = new Map(entries.map((e) => [e.meta.id, e.rev ?? ""]));
|
|
225
|
+
const tag = (id: string) => `${id}@${revOf.get(id) ?? ""}`;
|
|
226
|
+
const stored: Record<string, string[]> = {};
|
|
227
|
+
for (const [id, list] of Object.entries(same ? prev.failed : {})) if (unchanged(id)) stored[id] = list.filter((x) => x.includes("@") && revOf.has(x.slice(0, x.indexOf("@"))) && x === tag(x.slice(0, x.indexOf("@"))));
|
|
228
|
+
for (const [id, list] of Object.entries(o.failed ?? {})) stored[id] = [...new Set([...(stored[id] ?? []), ...list.map(tag)])];
|
|
229
|
+
const failed: Record<string, string[]> = Object.fromEntries(Object.entries(stored).filter(([, l]) => l.length).map(([id, l]) => [id, l.map((x) => x.slice(0, x.indexOf("@")))]));
|
|
230
|
+
const seamFailed = { ...(same ? prev.seamFailed : {}), ...o.seamFailed };
|
|
231
|
+
const pinned: Record<string, Pin> = { ...(same ? (prev.pinned ?? {}) : {}) };
|
|
232
|
+
for (const [id, p] of Object.entries(o.pins ?? {})) pinned[id] = { ...pinned[id], ...p };
|
|
233
|
+
const only = o.only ? new Set(o.only) : null;
|
|
234
|
+
for (const id of only ?? []) if (!scenes.some((s) => s.id === id)) throw new Error(`--scenes: 시간표에 없는 장면 ${id}`);
|
|
235
|
+
|
|
236
|
+
// 패턴 자리
|
|
237
|
+
const slotScenes = scenes.filter((s) => s.role !== "seam" && s.type && s.type !== "custom");
|
|
238
|
+
const slots: Slot[] = slotScenes.map((s) => ({ id: s.id, type: s.type!, content: payload(byId.get(s.id)!), dur: s.dur, energy: energyOf(beats, s.from, s.dur, s === scenes[0]) }));
|
|
239
|
+
const ctx = { aspect, purpose, theme, policy, seed: key };
|
|
240
|
+
const entryOf = new Map(entries.map((e) => [e.meta.id, e]));
|
|
241
|
+
const prevOf = new Map((same ? prev.scenes : []).filter((s) => s.pattern).map((s) => [s.id, s]));
|
|
242
|
+
// 전체 짓기는 늘 처음부터 같은 길(selectAll) — 고름이 (내용·씨앗·떨어진 기록)에만 달려 앞 실행의 이력이 안 남는다.
|
|
243
|
+
// 고른 장면만(only)일 때만 앞 고름을 핀으로 두고 그 장면만 앞뒤와 다시 따진다
|
|
244
|
+
let picks: Pick[];
|
|
245
|
+
if (only && slots.every((s) => prevOf.has(s.id))) {
|
|
246
|
+
picks = slots.map((s) => {
|
|
247
|
+
const p = prevOf.get(s.id)!;
|
|
248
|
+
return { scene: s.id, pattern: p.pattern!, order: p.order ?? [], rejected: p.rejected ?? [], attempt: (failed[s.id] ?? []).length };
|
|
249
|
+
});
|
|
250
|
+
for (const [i, s] of slots.entries()) {
|
|
251
|
+
if (!only.has(s.id)) continue;
|
|
252
|
+
const r = repick(picks, i, slots, entries, ctx, failed[s.id] ?? []);
|
|
253
|
+
if (!r) throw new Error(`장면 ${s.id}: 남은 패턴 후보가 없다 — 떨어진 것 ${(failed[s.id] ?? []).join(", ")}`);
|
|
254
|
+
picks[i] = r;
|
|
255
|
+
}
|
|
256
|
+
} else picks = selectAll(slots, entries, ctx, failed);
|
|
257
|
+
// 사람이 고른 패턴이 이긴다 — 그 type 의 패턴이어야 한다
|
|
258
|
+
for (const [i, s] of slots.entries()) {
|
|
259
|
+
const want = pinned[s.id]?.pattern;
|
|
260
|
+
if (!want || picks[i].pattern === want) continue;
|
|
261
|
+
const e = entryOf.get(want);
|
|
262
|
+
if (!e || (!e.fallback && e.meta.type !== s.type)) throw new Error(`--pattern ${s.id}=${want}: ${e ? `${s.type} 장면에 ${e.meta.type} 패턴` : "없는 패턴"} — 후보는 director.json 의 order`);
|
|
263
|
+
picks[i] = { ...picks[i], pattern: want };
|
|
264
|
+
}
|
|
265
|
+
const pickOf = new Map(picks.map((p) => [p.scene, p]));
|
|
266
|
+
|
|
267
|
+
// 이음매 종류 — 2D↔3D 경계는 공간 잇기
|
|
268
|
+
const seamIdx = scenes.flatMap((s, i) => (s.role === "seam" ? [i] : []));
|
|
269
|
+
const spots: Spot[] = seamIdx.map((i) => {
|
|
270
|
+
const a = spaceOf(scenes[i - 1] ?? {});
|
|
271
|
+
const b = spaceOf(scenes[i + 1] ?? {});
|
|
272
|
+
return { at: i, bridge: a === b ? false : b === "3d" ? "up" : "down" };
|
|
273
|
+
});
|
|
274
|
+
const kinds = seamKinds(spots, key, tpl.scenes.find((s) => s.role === "seam")?.transition ?? "dive", Object.fromEntries(seamIdx.map((i, k) => [k, seamFailed[scenes[i].id] ?? []])));
|
|
275
|
+
|
|
276
|
+
const dress = dressAll(key, scenes, tpl.moves, theme);
|
|
277
|
+
const scenesDir = join(dir, "scenes");
|
|
278
|
+
mkdirSync(scenesDir, { recursive: true });
|
|
279
|
+
const report: SceneReport[] = [];
|
|
280
|
+
const keep = new Set<string>();
|
|
281
|
+
const log = o.log ?? (() => {});
|
|
282
|
+
for (const [i, s] of scenes.entries()) {
|
|
283
|
+
const file = join(dir, s.file);
|
|
284
|
+
keep.add(s.file);
|
|
285
|
+
const base = { id: s.id, role: s.role, from: s.from, dur: s.dur };
|
|
286
|
+
if (only && !only.has(s.id)) {
|
|
287
|
+
const kept = prev?.scenes.find((x) => x.id === s.id);
|
|
288
|
+
if (kept) report.push(kept);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
log(`장면 ${s.id}: 쓰는 중`);
|
|
292
|
+
if (s.role === "seam") {
|
|
293
|
+
const kind = kinds[seamIdx.indexOf(i)];
|
|
294
|
+
s.transition = kind;
|
|
295
|
+
delete s.dress;
|
|
296
|
+
writeOwned(file, `${MARK} — 이음매(전환은 director.json)\n${fill(sceneSkeleton(kf.template, s.id, "seam"), { transition: kind, title: content.title })}`, !prev);
|
|
297
|
+
report.push({ ...base, transition: kind });
|
|
298
|
+
log(`장면 ${s.id}: 씀`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const c = byId.get(s.id)!;
|
|
302
|
+
if (c.type === "custom") {
|
|
303
|
+
const own = typeof c.file === "string" ? c.file : s.file;
|
|
304
|
+
if (own !== s.file) {
|
|
305
|
+
keep.add(own);
|
|
306
|
+
s.file = own;
|
|
307
|
+
}
|
|
308
|
+
s.dress = dress[i];
|
|
309
|
+
if (existsSync(join(dir, own)) && !SKELETON.test(readFileSync(join(dir, own), "utf8"))) {
|
|
310
|
+
report.push({ ...base, type: "custom", custom: "kept" });
|
|
311
|
+
log(`장면 ${s.id}: 씀`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
writeFileSync(join(dir, own), fill(sceneSkeleton(kf.template, s.id, s.role), { word: content.title, title: content.title, line: String(plan.line ?? ""), move: "rise", transition: "dive", ...dress[i] }));
|
|
315
|
+
report.push({ ...base, type: "custom", custom: "skeleton" });
|
|
316
|
+
log(`장면 ${s.id}: 씀`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const p = pickOf.get(s.id)!;
|
|
320
|
+
const e = entryOf.get(p.pattern)!;
|
|
321
|
+
// 감독 기본은 무대 글 수가 낱말을 돌려 띄운다 — 판 넘김·유령 줄만(자르거나 겹쳐 흐리는 수는 안 쓴다)
|
|
322
|
+
const prevText = scenes[i - 1]?.dress?.text;
|
|
323
|
+
const d = e.fallback ? { ...dress[i], text: cyclingText(key, s.id, prevText) } : dress[i];
|
|
324
|
+
s.dress = d;
|
|
325
|
+
s.pattern = p.pattern;
|
|
326
|
+
s.idea = `${s.type} — ${e.meta.does}`;
|
|
327
|
+
const directedArgs = { scene: s.id, type: s.type, content: payload(c), seed: pinned[s.id]?.seed ?? `${key}|${s.id}`, theme, dress: d, policy, first: i === 0, last: i === scenes.length - 1, loop: !!tpl.loop };
|
|
328
|
+
writeOwned(file, patternFile(e.spec, directedArgs), !prev);
|
|
329
|
+
report.push({ ...base, type: s.type, energy: slots.find((x) => x.id === s.id)!.energy, pattern: p.pattern, dress: d, order: p.order.slice(0, 5), rejected: p.rejected, attempt: p.attempt, hash: sceneHash(c) });
|
|
330
|
+
log(`장면 ${s.id}: 씀`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 시간표에 없는 장면 파일 — 뼈대는 지우고, 손으로 쓴 것은 scenes/_unused/ 로 비켜 둔다(main.ts 가 안 싣는다)
|
|
334
|
+
for (const f of readdirSync(scenesDir)) {
|
|
335
|
+
if (!f.endsWith(".ts") || keep.has(`scenes/${f}`)) continue;
|
|
336
|
+
const p = join(scenesDir, f);
|
|
337
|
+
const body = readFileSync(p, "utf8");
|
|
338
|
+
if (body.startsWith(MARK) || SKELETON.test(body)) unlinkSync(p);
|
|
339
|
+
else stash(dir, f);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
writeJson(join(dir, "kf.json"), kf);
|
|
343
|
+
const out: Report = { version: 1, seed, key, content: hash, purpose, theme, aspect, scenes: report, failed: Object.fromEntries(Object.entries(failed).map(([id, l]) => [id, l.map(tag)])), seamFailed, pinned };
|
|
344
|
+
writeJson(prevPath, out);
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function stash(dir: string, name: string): void {
|
|
349
|
+
mkdirSync(join(dir, "scenes", "_unused"), { recursive: true });
|
|
350
|
+
renameSync(join(dir, "scenes", name), join(dir, "scenes", "_unused", name));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 감독 몫 파일만 덮는다 — 손으로 쓴 파일이 같은 자리에 있으면 _unused 로 비켜 두고 쓴다(처음 들어설 땐 뼈대도 지운다)
|
|
354
|
+
function writeOwned(file: string, body: string, first: boolean): void {
|
|
355
|
+
if (existsSync(file)) {
|
|
356
|
+
const old = readFileSync(file, "utf8");
|
|
357
|
+
if (old === body) return;
|
|
358
|
+
if (!old.startsWith(MARK) && !(first && SKELETON.test(old))) stash(join(file, "..", ".."), file.split("/").at(-1)!);
|
|
359
|
+
}
|
|
360
|
+
writeFileSync(file, body);
|
|
361
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type Question, questionsMarkdown } from "@kasaframe/gen";
|
|
4
|
+
import { loadTemplate, parseDuration } from "@kasaframe/plan";
|
|
5
|
+
import { contentGuide } from "./content.ts";
|
|
6
|
+
|
|
7
|
+
// 감독 모드에서 모델이 쓰는 것은 content.json 하나 — 지시서(Claude Code)와 OG 프롬프트가 같은 칸 표·요령을 쓴다.
|
|
8
|
+
|
|
9
|
+
const readJson = <T = any>(p: string): T => JSON.parse(readFileSync(p, "utf8"));
|
|
10
|
+
|
|
11
|
+
const EXAMPLE = {
|
|
12
|
+
title: "MLA",
|
|
13
|
+
request: "OG 쇼츠 — MLA 가 뭔지 45초",
|
|
14
|
+
scenes: [
|
|
15
|
+
{ id: "hook", type: "hook-number", value: 57, unit: "×", prefix: "1/", label: "KV 캐시", say: "KV 캐시가, 57분의 1로 줄어듭니다.", source: "DeepSeek-V2 논문 표 1" },
|
|
16
|
+
{ id: "cost", type: "fill", total: 80, filled: 80, label: "GPU 한 장 80GB", unit: "GB", say: "…", source: "…" },
|
|
17
|
+
{ id: "how", type: "formula", parts: "K = c × W_UK", say: "…" },
|
|
18
|
+
{ id: "name", type: "term-reveal", term: "MLA", full: "Multi-head Latent Attention", ko: "멀티헤드 잠재 어텐션", say: "…" },
|
|
19
|
+
{ id: "end", type: "end", title: "MLA", line: "캐시를 57배 줄이는 어텐션", say: "…" },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface Brief {
|
|
24
|
+
request: string;
|
|
25
|
+
title: string;
|
|
26
|
+
purpose: string;
|
|
27
|
+
label: string;
|
|
28
|
+
aspect: string;
|
|
29
|
+
target: number;
|
|
30
|
+
policy: string;
|
|
31
|
+
maxOnScreen: number;
|
|
32
|
+
theme: string | null;
|
|
33
|
+
voice: string;
|
|
34
|
+
music: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function facts(dir: string): Brief {
|
|
38
|
+
const kf = readJson(join(dir, "kf.json"));
|
|
39
|
+
const plan = existsSync(join(dir, "plan.json")) ? readJson(join(dir, "plan.json")) : {};
|
|
40
|
+
const tpl = loadTemplate(kf.template);
|
|
41
|
+
return {
|
|
42
|
+
request: plan.request ?? kf.title,
|
|
43
|
+
title: kf.title,
|
|
44
|
+
purpose: kf.template,
|
|
45
|
+
label: tpl.label,
|
|
46
|
+
aspect: plan.aspect ?? (kf.width > kf.height ? "16:9" : kf.width < kf.height ? "9:16" : "1:1"),
|
|
47
|
+
target: parseDuration(plan.request ?? "") ?? tpl.duration.default,
|
|
48
|
+
policy: kf.text?.policy ?? tpl.text.policy,
|
|
49
|
+
maxOnScreen: kf.text?.maxOnScreen ?? tpl.text.maxOnScreen,
|
|
50
|
+
theme: kf.theme ?? null,
|
|
51
|
+
voice: plan.voice ?? plan.answers?.voice ?? tpl.narration?.voice ?? "none",
|
|
52
|
+
music: plan.answers?.music ?? kf.music ?? "none",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rules(b: Brief): string[] {
|
|
57
|
+
const chars = Math.round(b.target * 4.5);
|
|
58
|
+
return [
|
|
59
|
+
`- **say** 가 장면 길이를 정한다(목소리 먼저) — 차분한 설명체, 마디는 쉼표·문장은 마침표, 1초에 4–5글자. 영상 ${b.target}초면 say 를 다 합쳐 ${chars}자 안팎. 훅은 한 마디.`,
|
|
60
|
+
`- **화면 글은 칸에만** — 글 정책 \`${b.policy}\`${b.policy === "terms-only" ? "(전문 낱말·숫자만 — 문장은 say 로)" : ""}, 한 화면 ${b.maxOnScreen}개 안팎. 칸은 짧게.`,
|
|
61
|
+
"- **숫자는 value 와 unit 을 따로**(\"9.2 GB\" 가 아니라 value 9.2 · unit \"GB\"), 숫자가 있는 장면은 **source**(출처) 를 꼭.",
|
|
62
|
+
"- 첫 장면은 결과 먼저(`hook-number`·`compare`·`words`), 마지막은 `end`. 같은 type 을 잇달아 쓰지 않는다 — 내용이 같은 꼴이면 다른 type 으로 바꿔 보인다(막대 → 칸 채우기 → 식).",
|
|
63
|
+
"- 장면 6–9개. 화면 문법·움직임·전환·카메라·바탕은 감독이 고른다 — content.json 에 쓰지 않는다. 패턴에 없는 화면만 `custom`(idea 를 적으면 kf direct 가 뼈대를 두고, 그 파일은 직접 쓴다).",
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Claude Code 감독 모드 지시서 — MAKE.md */
|
|
68
|
+
export function directBrief(dir: string): string {
|
|
69
|
+
const b = facts(dir);
|
|
70
|
+
const plan = existsSync(join(dir, "plan.json")) ? readJson(join(dir, "plan.json")) : {};
|
|
71
|
+
const asks: Question[] = plan.asks ?? [];
|
|
72
|
+
const answers: Record<string, string> = plan.answers ?? {};
|
|
73
|
+
const pending = (id: string) => asks.some((q) => q.id === id);
|
|
74
|
+
const gen = [
|
|
75
|
+
answers.image && answers.image !== "none" ? `- 그림이 필요한 장면(\`media\`·\`screen\`)은 \`npx kf gen img "<구체적 프롬프트>" --id <id> --provider ${answers.image} --project ${dir}\` 로 만들고 칸에 \`gen/<id>.png\`. 받은 자산·실제 화면으로 되면 만들지 않는다.` : "- 그림 생성은 하지 않는다(고른 답 none) — `media`·`screen` 은 받은 자산만.",
|
|
76
|
+
answers.blender && answers.blender !== "none" ? `- 주인공 3D 샷이 맞는 장면은 \`"space": "3d"\` — 3D 장면은 ${answers.blender === "three" ? "three(stage3d)" : "Blender 툰 샷(kf blender)"}로, 2D 장면과의 경계엔 감독이 공간 잇기 전환을 넣는다.` : "",
|
|
77
|
+
answers.video && answers.video !== "none" ? `- 캐릭터가 실제로 움직여야 하면 \`npx kf gen vid … --provider ${answers.video} --yes\` 로 만든 클립을 \`media\` 장면 assets 에.` : "",
|
|
78
|
+
].filter(Boolean);
|
|
79
|
+
return [
|
|
80
|
+
`# 감독 모드 — ${b.title}`,
|
|
81
|
+
"",
|
|
82
|
+
`요청: ${b.request}`,
|
|
83
|
+
`${b.label}(${b.purpose}) · ${b.aspect} · 목표 ${b.target}초 · ${pending("theme") ? `테마 아직 — 맨 위 질문의 답(지금 깔린 것 ${b.theme ?? "프리셋"})` : b.theme ? `테마 ${b.theme}` : "프리셋"} · 목소리 ${pending("voice") ? "아직 — 맨 위 질문의 답" : b.voice} · 곡 ${pending("music") ? "아직 — 맨 위 질문의 답" : b.music}`,
|
|
84
|
+
"",
|
|
85
|
+
...questionsMarkdown(asks, (flags) => `npx kf make --answer ${dir} ${flags}`),
|
|
86
|
+
"## 할 일 — 장면 코드는 쓰지 않는다",
|
|
87
|
+
"",
|
|
88
|
+
`1. \`${dir}/content.json\` 을 쓴다 — **무엇을** 보일지만(장면마다 type·칸·say·source). 아래 칸 표가 정본이다.`,
|
|
89
|
+
`2. \`npx kf direct ${dir}\` — 감독이 장면마다 검증된 장면 문법(패턴)을 테마·용도·곡 구간 에너지·앞뒤 장면과 안 겹치기로 골라 장면 파일을 조립하고, 내레이션으로 길이를 맞추고, \`kf check\` 가 떨어진 장면은 다음 후보로 다시 짓는다. 칸이 틀렸으면 어느 칸인지 찍고 멈춘다.`,
|
|
90
|
+
`3. \`npx kf look ${dir}\` 의 장면 모음·확인표를 직접 본다. 마음에 안 들면 content.json 을 고치거나 \`--seed <아무 글>\` 로 다른 고름을 본다(같은 씨앗이면 늘 같은 그림).`,
|
|
91
|
+
`4. \`npx kf make --finish ${dir}\` — 렌더·검사·표지·자막.`,
|
|
92
|
+
"",
|
|
93
|
+
"## 쓰는 요령",
|
|
94
|
+
"",
|
|
95
|
+
...rules(b),
|
|
96
|
+
...gen,
|
|
97
|
+
"",
|
|
98
|
+
"## content.json 꼴",
|
|
99
|
+
"",
|
|
100
|
+
"```json",
|
|
101
|
+
JSON.stringify(EXAMPLE, null, 2),
|
|
102
|
+
"```",
|
|
103
|
+
"",
|
|
104
|
+
contentGuide(),
|
|
105
|
+
"",
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** OG 모드 — 모델이 content.json 을 한 번에 쓰게 하는 메시지 */
|
|
110
|
+
export function contentPrompt(dir: string, fix?: string): { role: "system" | "user"; content: string }[] {
|
|
111
|
+
const b = facts(dir);
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
role: "system",
|
|
115
|
+
content: `너는 짧은 영상의 내용만 정한다 — 장면마다 무엇을 보일지(type 과 칸)와 들릴 말(say). 화면 문법·움직임·전환·카메라·바탕은 알고리즘이 고르니 쓰지 않는다. JSON 하나로만 답한다.\n\n${contentGuide()}`,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
role: "user",
|
|
119
|
+
content: `요청: ${b.request}\n용도: ${b.label}(${b.purpose}) · ${b.aspect} · 목표 ${b.target}초 · 이름 후보: ${b.title}\n\n요령:\n${rules(b).join("\n")}\n\n꼴(예시 — 내용은 요청에 맞게 새로):\n${JSON.stringify(EXAMPLE)}${fix ? `\n\n앞 답의 고칠 곳:\n${fix}` : ""}`,
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import fallback from "./fallback.ts";
|
|
5
|
+
import type { Pattern } from "./pattern.ts";
|
|
6
|
+
import fillCells from "./patterns/fill/cells.ts";
|
|
7
|
+
import formulaStack from "./patterns/formula/stack.ts";
|
|
8
|
+
import type { Entry } from "./select.ts";
|
|
9
|
+
|
|
10
|
+
// 고를 수 있는 패턴 전부 — 패턴 라이브러리(@kasaframe/patterns, 장면 문법 담당)가 있으면 그 PATTERNS 와, 감독이 엔진을 세우려고 둔 것.
|
|
11
|
+
// 같은 id 면 라이브러리 쪽이 이긴다(감독 쪽은 라이브러리로 옮겨 가는 중인 것).
|
|
12
|
+
|
|
13
|
+
const OWN: Pattern<any>[] = [fillCells, formulaStack];
|
|
14
|
+
|
|
15
|
+
const specOf = (pkg: string, id: string): string => {
|
|
16
|
+
const dot = id.indexOf(".");
|
|
17
|
+
return `${pkg}/${id.slice(0, dot)}/${id.slice(dot + 1)}`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function catalog(): Promise<Entry[]> {
|
|
21
|
+
let lib: Pattern<any>[] = [];
|
|
22
|
+
try {
|
|
23
|
+
const mod = (await import("@kasaframe/patterns" as string)) as { PATTERNS?: Pattern<any>[] };
|
|
24
|
+
lib = mod.PATTERNS ?? [];
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// 라이브러리가 아직 없으면 감독 쪽 것만 — 깨진 패턴은 숨기지 않는다
|
|
27
|
+
if (!/Cannot find (package|module)|ERR_MODULE_NOT_FOUND/.test(String(e))) throw e;
|
|
28
|
+
}
|
|
29
|
+
const ids = new Set(lib.map((p) => p.id));
|
|
30
|
+
const entries: Entry[] = [
|
|
31
|
+
...lib.map((p) => ({ meta: p, spec: specOf("@kasaframe/patterns", p.id) })),
|
|
32
|
+
...OWN.filter((p) => !ids.has(p.id)).map((p) => ({ meta: p, spec: specOf("@kasaframe/director/patterns", p.id) })),
|
|
33
|
+
{ meta: fallback, spec: "@kasaframe/director/fallback", fallback: true },
|
|
34
|
+
];
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
if (!e.fallback && !/^[a-z0-9-]+\.[a-z0-9-]+$/.test(e.meta.id)) throw new Error(`패턴 id "${e.meta.id}" 는 <type>.<이름> 꼴이어야 한다`);
|
|
37
|
+
e.rev = revOf(e.spec);
|
|
38
|
+
}
|
|
39
|
+
return entries.sort((a, b) => (a.meta.id < b.meta.id ? -1 : a.meta.id > b.meta.id ? 1 : 0));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 패턴 파일 내용의 해시 — 장면 문법 쪽이 패턴을 고치면 판이 바뀐다
|
|
43
|
+
function revOf(spec: string): string | undefined {
|
|
44
|
+
try {
|
|
45
|
+
return createHash("sha256").update(readFileSync(fileURLToPath(import.meta.resolve(spec)))).digest("hex").slice(0, 8);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|