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.
Files changed (200) hide show
  1. package/NOTICE +10 -8
  2. package/README.md +16 -1
  3. package/app/server/engine.ts +133 -15
  4. package/app/server/main.ts +7 -3
  5. package/app/web/main.ts +218 -36
  6. package/app/web/style.css +37 -4
  7. package/bin/hooks.mjs +14 -1
  8. package/bin/kf.mjs +20 -2
  9. package/docs/AGENTS.md +64 -16
  10. package/music/README.md +31 -7
  11. package/package.json +2 -2
  12. package/packages/adapters/src/project.ts +1 -1
  13. package/packages/check/src/check.ts +10 -3
  14. package/packages/check/src/rules/assets.ts +4 -1
  15. package/packages/check/src/rules/frames.ts +2 -1
  16. package/packages/check/src/rules/layout.ts +30 -6
  17. package/packages/cli/package.json +2 -1
  18. package/packages/cli/src/blur.ts +15 -3
  19. package/packages/cli/src/commands/bench.ts +123 -0
  20. package/packages/cli/src/commands/blender.ts +72 -49
  21. package/packages/cli/src/commands/direct.ts +210 -0
  22. package/packages/cli/src/commands/doctor.ts +9 -1
  23. package/packages/cli/src/commands/look.ts +9 -2
  24. package/packages/cli/src/commands/lyrics.ts +47 -1
  25. package/packages/cli/src/commands/make.ts +729 -150
  26. package/packages/cli/src/commands/music.ts +78 -0
  27. package/packages/cli/src/commands/narrate.ts +238 -0
  28. package/packages/cli/src/commands/new.ts +14 -5
  29. package/packages/cli/src/commands/render.ts +94 -60
  30. package/packages/cli/src/commands/series.ts +110 -0
  31. package/packages/cli/src/commands/setup.ts +42 -3
  32. package/packages/cli/src/commands/theme.ts +89 -2
  33. package/packages/cli/src/deps.ts +6 -6
  34. package/packages/cli/src/kf.ts +14 -3
  35. package/packages/cli/src/mix.ts +131 -0
  36. package/packages/cli/src/pool.ts +34 -0
  37. package/packages/cli/src/segments.ts +402 -0
  38. package/packages/core/src/index.ts +1 -1
  39. package/packages/core/src/project.ts +11 -0
  40. package/packages/director/package.json +23 -0
  41. package/packages/director/src/assemble.ts +361 -0
  42. package/packages/director/src/brief.ts +122 -0
  43. package/packages/director/src/catalog.ts +49 -0
  44. package/packages/director/src/content.ts +343 -0
  45. package/packages/director/src/fallback.ts +58 -0
  46. package/packages/director/src/index.ts +13 -0
  47. package/packages/director/src/pattern.ts +90 -0
  48. package/packages/director/src/patterns/fill/cells.ts +115 -0
  49. package/packages/director/src/patterns/formula/stack.ts +93 -0
  50. package/packages/director/src/patterns/util.ts +58 -0
  51. package/packages/director/src/scene.ts +62 -0
  52. package/packages/director/src/select.ts +163 -0
  53. package/packages/director/src/transitions.ts +115 -0
  54. package/packages/gen/PROVIDERS.md +39 -4
  55. package/packages/gen/src/ask.ts +132 -0
  56. package/packages/gen/src/audio.ts +91 -0
  57. package/packages/gen/src/blender.ts +54 -0
  58. package/packages/gen/src/capabilities.ts +151 -0
  59. package/packages/gen/src/generate.ts +4 -2
  60. package/packages/gen/src/image/codex.ts +117 -0
  61. package/packages/gen/src/index.ts +10 -2
  62. package/packages/gen/src/keys.ts +26 -0
  63. package/packages/gen/src/music/elevenlabs.ts +41 -0
  64. package/packages/gen/src/music/index.ts +120 -0
  65. package/packages/gen/src/music/library.ts +73 -0
  66. package/packages/gen/src/music/lyria.ts +52 -0
  67. package/packages/gen/src/music/types.ts +28 -0
  68. package/packages/gen/src/registry.ts +6 -3
  69. package/packages/gen/src/tts/dashscope.ts +64 -0
  70. package/packages/gen/src/tts/elevenlabs.ts +84 -0
  71. package/packages/gen/src/tts/gemini.ts +73 -0
  72. package/packages/gen/src/tts/index.ts +241 -0
  73. package/packages/gen/src/tts/local.ts +42 -0
  74. package/packages/gen/src/tts/types.ts +44 -0
  75. package/packages/gen/src/types.ts +4 -0
  76. package/packages/gen/src/video/h3.ts +5 -0
  77. package/packages/gen/src/video/http.ts +1 -1
  78. package/packages/jizura/src/index.ts +2 -2
  79. package/packages/jizura/src/install.ts +198 -155
  80. package/packages/jizura/src/layer.ts +75 -32
  81. package/packages/jizura/src/pin.ts +4 -1
  82. package/packages/jizura/src/vendor.ts +140 -0
  83. package/packages/jizura/src/vite.ts +34 -7
  84. package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
  85. package/packages/jizura/vendor/NOTICE +43 -0
  86. package/packages/jizura/vendor/engine.json +47 -0
  87. package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
  88. package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
  89. package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
  90. package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
  91. package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  92. package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
  93. package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
  94. package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
  95. package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
  96. package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
  97. package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
  98. package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
  99. package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
  100. package/packages/jizura/vendor/fonts/fonts.json +52 -0
  101. package/packages/jizura/vendor/jizura_engine.js +35024 -0
  102. package/packages/motion/README.md +11 -0
  103. package/packages/motion/package.json +1 -0
  104. package/packages/motion/src/captions.ts +32 -24
  105. package/packages/motion/src/moves/brushWipe.ts +14 -1
  106. package/packages/motion/src/moves/dress.ts +191 -0
  107. package/packages/motion/src/moves/echoStack.ts +3 -3
  108. package/packages/motion/src/moves/fit.ts +36 -0
  109. package/packages/motion/src/moves/ghostStack.ts +14 -5
  110. package/packages/motion/src/moves/imageSwarm.ts +8 -4
  111. package/packages/motion/src/moves/index.ts +23 -4
  112. package/packages/motion/src/moves/karaokeLine.ts +17 -3
  113. package/packages/motion/src/moves/menu.ts +138 -0
  114. package/packages/motion/src/moves/neonSign.ts +2 -1
  115. package/packages/motion/src/moves/slamWords.ts +14 -7
  116. package/packages/motion/src/moves/sparkTrail.ts +16 -0
  117. package/packages/motion/src/moves/splitFlap.ts +4 -2
  118. package/packages/motion/src/moves/stepWords.ts +19 -8
  119. package/packages/motion/src/moves/tickerRows.ts +13 -9
  120. package/packages/motion/src/moves/tokenPicker.ts +4 -3
  121. package/packages/motion/src/moves/wordWall.ts +23 -21
  122. package/packages/motion/src/moves/worldRoll.ts +2 -1
  123. package/packages/motion/src/transition3d/index.ts +10 -3
  124. package/packages/motion/src/transition3d/rig.ts +107 -9
  125. package/packages/motion/src/transition3d/shatter.ts +5 -2
  126. package/packages/motion/src/transition3d/space.ts +173 -0
  127. package/packages/motion/src/transition3d/whip.ts +19 -6
  128. package/packages/motion/src/transition3d/world.ts +54 -0
  129. package/packages/motion/src/transitions2d/catalog.ts +1 -1
  130. package/packages/patterns/package.json +15 -0
  131. package/packages/patterns/src/bars/rank.ts +119 -0
  132. package/packages/patterns/src/before-after/shrink.ts +127 -0
  133. package/packages/patterns/src/before-after/strike.ts +125 -0
  134. package/packages/patterns/src/code/terminal.ts +133 -0
  135. package/packages/patterns/src/compare/bars.ts +142 -0
  136. package/packages/patterns/src/compare/duel.ts +103 -0
  137. package/packages/patterns/src/end/lockup.ts +85 -0
  138. package/packages/patterns/src/end/slam.ts +91 -0
  139. package/packages/patterns/src/flow/graph.ts +250 -0
  140. package/packages/patterns/src/hook-number/counter.ts +101 -0
  141. package/packages/patterns/src/hook-number/roll.ts +179 -0
  142. package/packages/patterns/src/hook-number/slam.ts +91 -0
  143. package/packages/patterns/src/index.ts +38 -0
  144. package/packages/patterns/src/kit.ts +449 -0
  145. package/packages/patterns/src/limit/gauge.ts +117 -0
  146. package/packages/patterns/src/list/cards.ts +131 -0
  147. package/packages/patterns/src/list/cycle.ts +22 -0
  148. package/packages/patterns/src/media/panels.ts +100 -0
  149. package/packages/patterns/src/objects.ts +234 -0
  150. package/packages/patterns/src/quote/karaoke.ts +126 -0
  151. package/packages/patterns/src/quote/slam.ts +22 -0
  152. package/packages/patterns/src/screen/focus.ts +85 -0
  153. package/packages/patterns/src/steps/path.ts +137 -0
  154. package/packages/patterns/src/steps/stack.ts +111 -0
  155. package/packages/patterns/src/term-reveal/chips.ts +92 -0
  156. package/packages/patterns/src/term-reveal/expand.ts +127 -0
  157. package/packages/patterns/src/timeline/axis.ts +144 -0
  158. package/packages/patterns/src/title/mask.ts +85 -0
  159. package/packages/patterns/src/title/slam.ts +23 -0
  160. package/packages/patterns/src/toggle-sim/switch.ts +92 -0
  161. package/packages/patterns/src/trend/columns.ts +108 -0
  162. package/packages/patterns/src/trend/draw.ts +140 -0
  163. package/packages/patterns/src/types.ts +67 -0
  164. package/packages/patterns/src/words/moves.ts +42 -0
  165. package/packages/patterns/src/words/slam.ts +4 -0
  166. package/packages/plan/src/asks.ts +256 -0
  167. package/packages/plan/src/brief.ts +136 -7
  168. package/packages/plan/src/captions.ts +5 -2
  169. package/packages/plan/src/index.ts +6 -2
  170. package/packages/plan/src/llm.ts +64 -2
  171. package/packages/plan/src/look.ts +3 -1
  172. package/packages/plan/src/plan.ts +67 -13
  173. package/packages/plan/src/series.ts +76 -0
  174. package/packages/plan/src/types.ts +53 -2
  175. package/packages/themes/package.json +5 -2
  176. package/packages/themes/src/index.ts +9 -0
  177. package/packages/themes/src/slotkit.ts +79 -0
  178. package/packages/themes/src/slots.ts +46 -0
  179. package/packages/themes/src/stage3d.ts +20 -0
  180. package/series/og-shorts.json +14 -0
  181. package/skills/kasaframe/SKILL.md +4 -3
  182. package/templates/_shared/project/lib/stage.ts +70 -50
  183. package/templates/_shared/scenes/default.ts +10 -7
  184. package/templates/_shared/scenes/drop.ts +11 -8
  185. package/templates/_shared/scenes/end.ts +9 -6
  186. package/templates/_shared/scenes/hook.ts +8 -6
  187. package/templates/_shared/scenes/step.ts +13 -4
  188. package/templates/ad/template.json +20 -1
  189. package/templates/demo/template.json +11 -0
  190. package/templates/explainer/template.json +54 -3
  191. package/templates/launch/template.json +34 -1
  192. package/templates/mv/scenes/default.ts +11 -8
  193. package/templates/mv/template.json +4 -2
  194. package/templates/promo/template.json +42 -1
  195. package/templates/shorts/scenes/hook.ts +8 -5
  196. package/templates/shorts/template.json +72 -3
  197. package/templates/square/template.json +21 -1
  198. package/themes/debug-hud/slots.ts +99 -0
  199. package/themes/og-measure/slots.ts +88 -0
  200. package/themes/riso-zine/slots.ts +81 -0
package/app/web/main.ts CHANGED
@@ -12,11 +12,24 @@ interface Plan {
12
12
  title: string; aspect: string; width: number; height: number; duration: number; style: string; music: string | null;
13
13
  captions: string; deliverables: string[]; scenes: Scene[]; gen: { id: string; kind: string; scene?: string; seam?: boolean }[];
14
14
  questions?: { id: string; ask: string; choices: string[] }[];
15
+ // 돈이 들거나 없는 생성 능력만 — 보기 첫째가 권장. 고른 답은 일을 만들 때 answers 로
16
+ asks?: { id: string; ask: string; header: string; choices: { value: string; label: string; costUsd?: number; note?: string }[]; default: string; missing?: boolean }[];
17
+ // 물을 것 없이 정해진 답 — 쇼츠·설명은 분위기 맞는 곡 모음 곡이 여기 music 으로 온다
18
+ answers?: Record<string, string>;
15
19
  }
20
+ type SceneState = "writing" | "written" | "assets" | "render" | "rendered" | "reused" | "check" | "done" | "failed";
16
21
  interface Job {
17
22
  id: string; input: { request: string; purpose?: Choice; music?: string; model?: string; assets?: string[] }; dir: string; phase: string; progress: number;
18
23
  running: boolean; createdAt: number; previewUrl?: string; error?: string; checkPassed?: boolean;
19
- plan?: Plan; kf?: { width: number; height: number; scenes: Scene[]; captions?: string }; files: { video?: string; cover?: string; check?: string; alt?: string; captions?: string };
24
+ plan?: Plan; kf?: { width: number; height: number; scenes: Scene[]; captions?: string }; files: { video?: string; cover?: string; check?: string; alt?: string; captions?: string; videoAt?: number };
25
+ // 장면마다 따로 흐르는 상태(kf make·direct·render 의 「장면 <id>: <상태>」 줄) · 사람이 고쳐 다시 굽는 장면
26
+ scenes?: Record<string, { state: SceneState; note?: string; at: number }>; focus?: string;
27
+ }
28
+ // 장면 하나 고치는 자리 — 감독 프로젝트는 content.json 칸·패턴 후보·씨앗, 아니면 할 일과 다시 쓰는 모델
29
+ interface SceneDetail {
30
+ scene: Scene; direct: boolean; model: string | null; content: Record<string, unknown> | null;
31
+ pattern: string | null; candidates: { id: string; score: number; why: string[] }[]; pinned: { pattern?: string; seed?: string } | null;
32
+ assets: { path: string; url: string; kind: "image" | "video" }[];
20
33
  }
21
34
  interface Template {
22
35
  id: Purpose; label: string; summary?: string; aspect: string; width?: number; height?: number;
@@ -92,8 +105,8 @@ const iconLabel = (name: IconName, text: string, size?: number) => [icon(name, s
92
105
  // 시험은 Opus 로 하고 만족하면 DeepSeek 로 내린다 — 사람이 직접 고르기 전에는 기본값을 따른다
93
106
  // theme 은 테마 id 거나 "auto". auto 면 계획 단계에서 모델이 낸 후보 셋 중 하나를 themePick 에 고른다
94
107
  // captions 가 "" 면 틀 기본을 따른다
95
- interface Draft { purpose: Choice | null; theme: string | null; themePick: string | null; request: string; music: string; model: string; modelPicked: boolean; assets: string[]; approve: boolean; captions: "" | "on" | "off" }
96
- const D: Draft = { purpose: null, theme: null, themePick: null, request: "", music: "", model: MODELS[0].id, modelPicked: false, assets: [], approve: false, captions: "", ...JSON.parse(sessionStorage.getItem("kf-draft") ?? "{}") };
108
+ interface Draft { purpose: Choice | null; theme: string | null; themePick: string | null; request: string; music: string; model: string; modelPicked: boolean; assets: string[]; captions: "" | "on" | "off"; answers: Record<string, string> }
109
+ const D: Draft = { purpose: null, theme: null, themePick: null, request: "", music: "", model: MODELS[0].id, modelPicked: false, assets: [], captions: "", answers: {}, ...JSON.parse(sessionStorage.getItem("kf-draft") ?? "{}") };
97
110
  if (!D.modelPicked) D.model = MODELS[0].id;
98
111
  const save = () => sessionStorage.setItem("kf-draft", JSON.stringify(D));
99
112
 
@@ -720,14 +733,15 @@ function viewPlan(): HTMLElement {
720
733
  }
721
734
  const p = plan;
722
735
  const t = tpl(p.purpose);
723
- const musicMissing = needsMusic(p.purpose) && !D.music;
724
- const hasSeam = p.gen.some((g) => g.kind === "vid");
736
+ // 곡을 안 골랐어도 계획이 곡 모음에서 정했거나(answers.music) 곡 질문(생성·없음)이 떠 있으면 막지 않는다
737
+ const musicMissing = needsMusic(p.purpose) && !D.music && !p.answers?.music && !(p.asks ?? []).some((q) => q.id === "music");
725
738
  const judged = purpose === "auto"
726
739
  ? h("div", { class: "judged" }, ...[{ purpose: p.purpose, p: p.confidence }, ...p.alternatives].slice(0, 4).map((a, i) =>
727
740
  h("button", { type: "button", class: "chip", "aria-pressed": String(i === 0), onclick: () => { D.purpose = a.purpose; save(); plan = null; render(); } },
728
741
  tpl(a.purpose)?.label ?? a.purpose, h("span", { class: "p" }, `${Math.round(a.p * 100)}%`))))
729
742
  : null;
730
743
  const asks = (p.questions ?? []).filter((q) => q.id !== "music" || !D.music);
744
+ const unpicked = (p.asks ?? []).some((q) => !pick(q));
731
745
  const auto = D.theme === "auto";
732
746
  const cands = auto ? (suggest?.ids ?? []).map((id) => themeOf(id)).filter((t): t is Theme => !!t) : [];
733
747
  const needPick = cands.length > 0 && !D.themePick;
@@ -759,25 +773,32 @@ function viewPlan(): HTMLElement {
759
773
  : null,
760
774
  ...asks.map((q) => h("p", { class: "ask" }, icon("warn", 16), h("span", {}, q.ask))),
761
775
  musicMissing ? h("p", { class: "ask" }, icon("music", 16), h("span", {}, "곡 필요"), h("button", { type: "button", class: "ghost small", onclick: () => go("input") }, "고르기")) : null,
762
- hasSeam
763
- ? h("label", { class: "approve" }, h("input", { type: "checkbox", checked: D.approve, onchange: (e: Event) => { D.approve = (e.target as HTMLInputElement).checked; save(); } }),
764
- h("span", {}, "영상 생성 승인"), h("span", { class: "dim" }, "유료 · 끄면 대체 화면"))
765
- : null,
776
+ ...(p.asks ?? []).map((q) => h("section", { class: "gen-ask" }, h("h2", {}, q.header), h("p", { class: "dim" }, q.ask),
777
+ h("div", { class: "judged" }, ...q.choices.map((c) => h("button", {
778
+ type: "button", class: "chip", title: c.note ?? "", "aria-pressed": String(pick(q) === c.value),
779
+ onclick: () => { D.answers = { ...D.answers, [q.id]: c.value }; save(); render(); },
780
+ }, c.label, c.costUsd ? h("span", { class: "p" }, `$${c.costUsd.toFixed(2)}`) : null))),
781
+ ...q.choices.filter((c) => c.note && pick(q) === c.value).map((c) => h("p", { class: "dim" }, c.note!)))),
766
782
  ),
767
783
  ),
768
784
  h("footer", { class: "foot" },
769
785
  h("button", { type: "button", class: "ghost", onclick: () => go("input") }, ...iconLabel("back", "재료")),
770
- h("button", { type: "button", class: "primary", disabled: musicMissing || needPick, onclick: start }, h("span", {}, musicMissing ? "곡이 필요하다" : needPick ? "테마를 고른다" : "만들기"), icon("arrow"))),
786
+ h("button", { type: "button", class: "primary", disabled: musicMissing || needPick || unpicked, onclick: start }, h("span", {}, musicMissing ? "곡이 필요하다" : needPick ? "테마를 고른다" : unpicked ? "없는 능력을 고른다" : "만들기"), icon("arrow"))),
771
787
  );
772
788
  }
773
789
 
790
+ // 없는 능력은 미리 골라 두지 않는다 — 사람이 넣는 법·다른 엔진·빼기 중 하나를 눌러야 만들기가 열린다
791
+ function pick(q: NonNullable<Plan["asks"]>[number]): string | undefined {
792
+ return D.answers[q.id] ?? (q.missing ? undefined : q.default);
793
+ }
794
+
774
795
  async function start(e: Event): Promise<void> {
775
796
  const btn = e.currentTarget as HTMLButtonElement;
776
797
  btn.disabled = true;
777
798
  try {
778
799
  const job = await api<Job>("/api/jobs", {
779
800
  method: "POST",
780
- body: JSON.stringify({ request: D.request, purpose: D.purpose ?? "auto", theme: themeId(), music: D.music || undefined, model: D.model, approve: D.approve, assets: D.assets, captions: D.captions || undefined }),
801
+ body: JSON.stringify({ request: D.request, purpose: D.purpose ?? "auto", theme: themeId(), music: D.music || undefined, model: D.model, assets: D.assets, captions: D.captions || undefined, answers: Object.fromEntries((plan?.asks ?? []).map((q) => [q.id, pick(q)]).filter(([, v]) => v)) }),
781
802
  });
782
803
  current = job;
783
804
  go("job", { job: job.id });
@@ -822,6 +843,9 @@ function follow(id: string): void {
822
843
 
823
844
  let shownPreview = "";
824
845
  let shownVideo = "";
846
+ // 고르고 있는 장면과 그 고치기 자리
847
+ // stale — 그 일이 돌았다: 끝나면 고른 패턴·내용이 바뀌었을 수 있어 다시 받는다
848
+ let picked: { job: string; id: string; detail: SceneDetail | null; error: string; stale?: boolean } | null = null;
825
849
  // 결과 화면에서 자막을 반대로 구운 판(files.alt)을 틀고 있나
826
850
  let showAlt = false;
827
851
 
@@ -868,16 +892,23 @@ function renderJob(): void {
868
892
  f.screen.style.aspectRatio = `${w} / ${ht}`;
869
893
  f.screen.style.setProperty("--ar", String(w / ht));
870
894
  const shown = showAlt && j.files.alt ? j.files.alt : j.files.video;
871
- if (done && shownVideo !== shown) {
872
- shownVideo = shown!;
895
+ // 장면 하나를 고쳐 다시 굽는 중이면 미리보기도 다 구운 영상도 그 장면 시각부터
896
+ const from = j.kf?.scenes.find((s) => s.id === j.focus)?.from;
897
+ const shownKey = `${shown}@${j.files.videoAt ?? j.createdAt}`;
898
+ if (done && shownVideo !== shownKey) {
899
+ shownVideo = shownKey;
873
900
  shownPreview = "";
874
901
  f.screen.classList.remove("studio");
875
- f.screen.replaceChildren(h("video", { src: `${file(shown!)}?v=${j.createdAt}`, controls: true, playsInline: true, poster: j.files.cover ? file(j.files.cover) : undefined }));
876
- } else if (!done && j.previewUrl && shownPreview !== j.previewUrl) {
877
- shownPreview = j.previewUrl;
902
+ const v = h("video", { src: `${file(shown!)}?v=${j.files.videoAt ?? j.createdAt}`, controls: true, playsInline: true, poster: from === undefined && j.files.cover ? file(j.files.cover) : undefined });
903
+ if (from !== undefined) v.addEventListener("loadedmetadata", () => { v.currentTime = from; }, { once: true });
904
+ f.screen.replaceChildren(v);
905
+ } else if (!done && j.previewUrl && shownPreview !== `${j.previewUrl}@${from}`) {
906
+ shownPreview = `${j.previewUrl}@${from}`;
878
907
  shownVideo = "";
879
908
  f.screen.classList.add("studio");
880
- f.screen.replaceChildren(h("iframe", { src: j.previewUrl, title: "미리보기", allow: "autoplay" }));
909
+ const at = new URL(j.previewUrl);
910
+ if (from !== undefined) at.searchParams.set("t", from.toFixed(2));
911
+ f.screen.replaceChildren(h("iframe", { src: at.href, title: "미리보기", allow: "autoplay" }));
881
912
  } else if (!done && !j.previewUrl && !shownPreview) {
882
913
  const want = j.running ? "run" : j.files.cover ? "cover" : "idle";
883
914
  if (f.screen.dataset.empty !== want) {
@@ -938,29 +969,180 @@ function bar(p: number): HTMLElement {
938
969
  return fill;
939
970
  }
940
971
 
941
- // 장면 카드: 할 일·길이를 고치고 그 장면만 다시 쓴다
972
+ // ---- 장면 타임라인과 고치기
973
+
974
+ const STATE_LABEL: Record<SceneState, string> = { writing: "쓰는 중", written: "씀", assets: "재료", render: "렌더", rendered: "구움", reused: "그대로", check: "검사", done: "끝", failed: "실패" };
975
+ const BUSY = new Set<SceneState>(["writing", "assets", "render", "check"]);
976
+
977
+ // 장면마다 상태가 동시에 흐르는 타임라인 — 칸을 누르면 그 장면을 고치는 자리가 열린다
942
978
  function renderScenes(box: HTMLElement, j: Job): void {
943
979
  const scenes = j.kf?.scenes;
944
980
  if (!scenes?.length) return void box.replaceChildren();
945
- if (box.contains(document.activeElement) && document.activeElement !== document.body) return;
981
+ if (!box.querySelector(".timeline")) box.replaceChildren(h("h2", {}, "장면"), h("div", { class: "timeline", role: "group", "aria-label": "장면 타임라인" }), h("div", { class: "scene-edit" }));
946
982
  const total = scenes.at(-1)!.from + scenes.at(-1)!.dur;
947
- box.replaceChildren(
948
- h("h2", {}, "장면"),
949
- sceneBar(scenes, total),
950
- h("div", { class: "scene-list" }, ...scenes.map((s, i) => {
951
- const idea = h("textarea", { rows: 2, value: s.idea, "aria-label": `${s.id} 할 일` });
952
- const dur = h("input", { type: "number", step: "0.1", min: "0.5", value: s.dur.toFixed(2), disabled: i === scenes.length - 1, "aria-label": `${s.id} 길이(초)` });
953
- const patch = () => api(`/api/jobs/${j.id}/scenes/${s.id}`, { method: "PATCH", body: JSON.stringify({ idea: idea.value, dur: Number(dur.value) }) });
954
- return h("article", { class: "scene" },
955
- h("header", {}, h("span", { class: `dot ${ROLE_TINT[s.role] ?? ""}` }), h("b", {}, s.id), h("span", { class: "st" }, `${sec(s.from)}–${sec(s.from + s.dur)} · ${s.role}`)),
956
- idea,
957
- h("div", { class: "scene-acts" },
958
- h("label", { class: "dur" }, h("span", {}, "초"), dur),
959
- h("button", { type: "button", class: "ghost small", disabled: j.running, onclick: patch }, "저장"),
960
- h("button", { type: "button", class: "ghost small", disabled: j.running, onclick: async () => { await patch(); await api(`/api/jobs/${j.id}/rewrite`, { method: "POST", body: JSON.stringify({ scenes: [s.id] }) }); } }, ...iconLabel("edit", "다시 쓰기", 14)),
961
- ),
962
- );
963
- })),
983
+ const line = box.querySelector<HTMLElement>(".timeline")!;
984
+ const focused = line.contains(document.activeElement) ? (document.activeElement as HTMLElement).dataset.id : undefined;
985
+ line.replaceChildren(...scenes.map((s) => {
986
+ const st = j.scenes?.[s.id];
987
+ const cell = h("button", {
988
+ type: "button", class: `cell ${ROLE_TINT[s.role] ?? ""}`, "data-id": s.id, "data-state": st?.state ?? "", "aria-pressed": String(picked?.job === j.id && picked.id === s.id),
989
+ title: `${s.id} · ${sec(s.from)}–${sec(s.from + s.dur)}${st?.note ? ` · ${st.note}` : ""}`,
990
+ onclick: () => void pickScene(j, s.id),
991
+ }, h("b", {}, s.id), h("span", { class: "cs" }, st ? STATE_LABEL[st.state] : sec(s.dur)), BUSY.has(st?.state as SceneState) && j.running ? h("i", { class: "run", "aria-hidden": "true" }) : null);
992
+ cell.style.flexGrow = String(s.dur / total);
993
+ return cell;
994
+ }));
995
+ if (focused) line.querySelector<HTMLElement>(`[data-id="${focused}"]`)?.focus();
996
+ const edit = box.querySelector<HTMLElement>(".scene-edit")!;
997
+ const mine = picked?.job === j.id ? picked : null;
998
+ if (mine && j.running) mine.stale = true;
999
+ else if (mine?.stale) {
1000
+ mine.stale = false;
1001
+ void pickScene(j, mine.id, true);
1002
+ }
1003
+ // 진행 줄마다 새로 그리면 덜 저장한 글이 날아간다 — 고른 장면·받은 정보·그 장면 상태가 바뀔 때만
1004
+ const key = mine ? `${mine.id}|${mine.detail ? "d" : mine.error}|${j.running}|${j.scenes?.[mine.id]?.state ?? ""}` : "none";
1005
+ const typing = edit.contains(document.activeElement) && /^(INPUT|TEXTAREA)$/.test(document.activeElement!.tagName) && edit.dataset.id === mine?.id;
1006
+ if (edit.dataset.key === key || typing) return;
1007
+ edit.dataset.key = key;
1008
+ edit.dataset.id = mine?.id ?? "";
1009
+ edit.replaceChildren(mine ? sceneEditor(j) : h("p", { class: "dim hint" }, "장면을 누르면 그 장면만 고쳐 다시 굽는다 — 나머지 장면은 구운 조각을 그대로 잇는다"));
1010
+ }
1011
+
1012
+ async function pickScene(j: Job, id: string, refresh = false): Promise<void> {
1013
+ if (!refresh && picked?.job === j.id && picked.id === id) picked = null;
1014
+ else {
1015
+ picked = { job: j.id, id, detail: refresh && picked?.id === id ? picked.detail : null, error: "" };
1016
+ try {
1017
+ picked.detail = await api<SceneDetail>(`/api/jobs/${j.id}/scenes/${id}`);
1018
+ } catch (e) {
1019
+ if (picked?.id === id) picked.error = (e as Error).message;
1020
+ }
1021
+ }
1022
+ if (current?.id !== j.id) return;
1023
+ const box = jobFrame(j.id).scenes;
1024
+ delete box.querySelector<HTMLElement>(".scene-edit")?.dataset.key;
1025
+ renderScenes(box, current);
1026
+ }
1027
+
1028
+ // 줄 글은 글 칸, 숫자는 숫자 칸, 목록·묶음은 JSON 칸 — content.json 의 그 장면 칸 그대로
1029
+ function contentFields(c: Record<string, unknown>): { el: HTMLElement; read: () => [string, unknown] }[] {
1030
+ // 길이는 아래 「초」 칸 하나로 — content 의 dur 와 시간표 길이를 같이 고친다
1031
+ return Object.entries(c).filter(([k]) => k !== "id" && k !== "type" && k !== "dur").map(([k, v]) => {
1032
+ const label = k === "say" ? "들릴 말" : k;
1033
+ if (typeof v === "number") {
1034
+ const input = h("input", { type: "number", step: "any", value: String(v), "aria-label": label });
1035
+ return { el: h("label", { class: "field" }, h("span", {}, label), input), read: () => [k, Number(input.value)] as [string, unknown] };
1036
+ }
1037
+ const text = typeof v === "string" ? v : JSON.stringify(v, null, 2);
1038
+ const area = h("textarea", { rows: typeof v === "string" ? Math.min(4, Math.max(1, Math.ceil(text.length / 48))) : Math.min(10, text.split("\n").length), value: text, "aria-label": label, class: typeof v === "string" ? "" : "mono" });
1039
+ return {
1040
+ el: h("label", { class: "field" }, h("span", {}, label), area),
1041
+ read: () => {
1042
+ if (typeof v === "string") return [k, area.value];
1043
+ try {
1044
+ return [k, JSON.parse(area.value)];
1045
+ } catch {
1046
+ throw new Error(`${label}: JSON 이 아니다`);
1047
+ }
1048
+ },
1049
+ };
1050
+ });
1051
+ }
1052
+
1053
+ function sceneEditor(j: Job): HTMLElement {
1054
+ const p = picked!;
1055
+ const s = j.kf?.scenes.find((x) => x.id === p.id);
1056
+ if (!s) return h("p", { class: "err" }, `장면 ${p.id} 가 없다`);
1057
+ const d = p.detail;
1058
+ const st = j.scenes?.[s.id];
1059
+ const last = j.kf!.scenes.at(-1)!.id === s.id;
1060
+ const headRow = h("header", {},
1061
+ h("span", { class: `dot ${ROLE_TINT[s.role] ?? ""}` }), h("b", {}, s.id),
1062
+ h("span", { class: "st" }, `${sec(s.from)}–${sec(s.from + s.dur)} · ${s.role}${d?.pattern ? ` · ${d.pattern}` : ""}`),
1063
+ st ? h("span", { class: "chip-state", "data-state": st.state }, STATE_LABEL[st.state]) : null,
1064
+ h("button", { type: "button", class: "icon-btn", "aria-label": "닫기", onclick: () => { picked = null; renderScenes(jobFrame(j.id).scenes, j); } }, icon("close", 16)));
1065
+ const redraw = () => {
1066
+ const box = jobFrame(j.id).scenes;
1067
+ delete box.querySelector<HTMLElement>(".scene-edit")?.dataset.key;
1068
+ (document.activeElement as HTMLElement | null)?.blur();
1069
+ renderScenes(box, j);
1070
+ };
1071
+ if (p.error) return h("article", { class: "scene" }, headRow, h("p", { class: "err" }, p.error));
1072
+ if (!d) return h("article", { class: "scene" }, headRow, h("p", { class: "dim" }, "불러오는 중"));
1073
+
1074
+ const msg = h("p", { class: "err", hidden: true });
1075
+ const fail = (e: unknown) => { msg.hidden = false; msg.textContent = (e as Error).message; };
1076
+ const dur = h("input", { type: "number", step: "0.1", min: "0.5", value: s.dur.toFixed(2), disabled: last, "aria-label": `${s.id} 길이(초)` });
1077
+ const fields = d.direct && d.content ? contentFields(d.content) : [];
1078
+ const idea = d.direct ? null : h("textarea", { rows: 3, value: s.idea, "aria-label": `${s.id} 할 일` });
1079
+ // 감독 후보 — 고른 패턴·씨앗은 director.json pinned 에 남아 다음 짓기도 지킨다
1080
+ let pattern = d.pattern ?? "";
1081
+ const seed = h("input", { type: "text", value: d.pinned?.seed ?? "", placeholder: "씨앗(비우면 감독 기본)", "aria-label": "장면 씨앗", class: "seed" });
1082
+ const cands = d.candidates.length
1083
+ ? h("div", { class: "chips", role: "radiogroup", "aria-label": "패턴 후보" }, ...d.candidates.map((c) => h("button", {
1084
+ type: "button", class: "chip", "aria-pressed": String(c.id === pattern), title: c.why.join(" · "),
1085
+ onclick: (e: Event) => { pattern = c.id; for (const b of (e.currentTarget as HTMLElement).parentElement!.children) b.setAttribute("aria-pressed", String(b === e.currentTarget)); },
1086
+ }, h("span", {}, c.id), h("span", { class: "p" }, c.score.toFixed(2)))))
1087
+ : null;
1088
+
1089
+ const save = async () => {
1090
+ const keepDur = d.content?.dur !== undefined && !last ? [["dur", Number(dur.value)]] : d.content?.dur !== undefined ? [["dur", d.content.dur]] : [];
1091
+ const content = fields.length ? Object.fromEntries([["type", d.content!.type], ...fields.map((f) => f.read()), ...keepDur]) : undefined;
1092
+ await api(`/api/jobs/${j.id}/scenes/${s.id}`, { method: "PATCH", body: JSON.stringify({ ...(idea ? { idea: idea.value } : {}), ...(last ? {} : { dur: Number(dur.value) }), ...(content ? { content } : {}) }) });
1093
+ };
1094
+ const again = async () => {
1095
+ msg.hidden = true;
1096
+ try {
1097
+ await save();
1098
+ // 감독 프로젝트의 이음매는 content.json 에 없다 — 전환은 앞뒤 장면이 정하니 조각만 다시 굽는다
1099
+ if (d.direct && d.content) {
1100
+ const pin = pattern && pattern !== d.pattern ? { pattern: { [s.id]: pattern } } : {};
1101
+ const sd = seed.value.trim() && seed.value.trim() !== (d.pinned?.seed ?? "") ? { seed: { [s.id]: seed.value.trim() } } : {};
1102
+ await api(`/api/jobs/${j.id}/rewrite`, { method: "POST", body: JSON.stringify({ scenes: [s.id], ...pin, ...sd }) });
1103
+ } else if (idea && idea.value !== s.idea && d.model && d.model !== "claude-code") {
1104
+ await api(`/api/jobs/${j.id}/rewrite`, { method: "POST", body: JSON.stringify({ scenes: [s.id] }) });
1105
+ } else await api(`/api/jobs/${j.id}/redo`, { method: "POST", body: JSON.stringify({ scenes: [s.id] }) });
1106
+ } catch (e) {
1107
+ fail(e);
1108
+ }
1109
+ };
1110
+ const replace = async (path: string, fs: File[]) => {
1111
+ msg.hidden = true;
1112
+ try {
1113
+ const up = await upload(fs[0]);
1114
+ p.detail = await api<SceneDetail>(`/api/jobs/${j.id}/scenes/${s.id}/asset`, { method: "PUT", body: JSON.stringify({ path, upload: up.path }) });
1115
+ redraw();
1116
+ } catch (e) {
1117
+ fail(e);
1118
+ }
1119
+ };
1120
+
1121
+ const how = d.direct && d.content
1122
+ ? "감독이 이 장면만 다시 짓고 그 조각만 다시 굽는다"
1123
+ : d.direct
1124
+ ? "이음매 — 앞뒤 장면을 고치면 같이 다시 굽힌다. 여기서는 조각만 다시 굽는다"
1125
+ : idea && d.model && d.model !== "claude-code"
1126
+ ? `할 일을 바꾸면 ${d.model} 이 이 장면만 다시 쓴다 — 아니면 조각만 다시 굽는다`
1127
+ : "장면 파일을 세션이 고친 뒤 — 바뀐 조각만 다시 굽는다";
1128
+ return h("article", { class: "scene" },
1129
+ headRow,
1130
+ ...(fields.length ? [h("div", { class: "fields" }, ...fields.map((f) => f.el))] : []),
1131
+ idea ? h("label", { class: "field" }, h("span", {}, "할 일"), idea) : null,
1132
+ cands ? h("div", { class: "field" }, h("span", {}, "패턴 후보"), cands) : null,
1133
+ d.direct && d.content ? h("div", { class: "field" }, h("span", {}, "씨앗 — 같은 패턴의 다른 그림"), h("div", { class: "seed-row" }, seed,
1134
+ h("button", { type: "button", class: "ghost small", onclick: () => { seed.value = Math.random().toString(36).slice(2, 7); } }, ...iconLabel("redo", "다른 씨앗", 14)))) : null,
1135
+ d.assets.length
1136
+ ? h("div", { class: "field" }, h("span", {}, "재료"), h("div", { class: "scene-assets" }, ...d.assets.map((a) => h("figure", {},
1137
+ a.kind === "video" ? h("video", { src: a.url, muted: true, playsInline: true, preload: "metadata" }) : h("img", { src: `${a.url}?v=${Date.now()}`, alt: a.path, loading: "lazy" }),
1138
+ h("figcaption", {}, h("span", {}, a.path), filePicker("바꾸기", a.kind === "video" ? "video/*" : "image/*", false, (fs) => void replace(a.path, fs)))))))
1139
+ : null,
1140
+ msg,
1141
+ h("div", { class: "scene-acts" },
1142
+ h("label", { class: "dur" }, h("span", {}, "초"), dur),
1143
+ h("button", { type: "button", class: "ghost small", disabled: j.running, onclick: () => void save().then(() => (msg.hidden = true)).catch(fail) }, "저장"),
1144
+ h("button", { type: "button", class: "primary small", disabled: j.running, onclick: () => void again() }, ...iconLabel("redo", "이 장면만 다시", 14))),
1145
+ h("p", { class: "dim hint" }, how),
964
1146
  );
965
1147
  }
966
1148
 
package/app/web/style.css CHANGED
@@ -265,9 +265,11 @@ input[type="radio"]:checked::after { content: ""; width: 8px; height: 8px; borde
265
265
  .gen li { display: flex; align-items: center; gap: 8px; font-size: 14px; }
266
266
  .ask { display: flex; align-items: center; gap: 8px; margin: 0; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255, 213, 138, 0.25); background: rgba(255, 213, 138, 0.07); color: var(--warn); font-size: 14px; }
267
267
  .ask .ghost { margin-left: auto; color: var(--ink); }
268
- .approve { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 14px; border: 1px solid var(--line); border-radius: 10px; font-size: 14px; cursor: pointer; }
269
- .approve input { accent-color: var(--sky); width: 18px; height: 18px; margin: 0; }
270
- .approve .dim { margin-left: auto; font-size: 12px; }
268
+ .gen-ask { display: grid; gap: 8px; }
269
+ .gen-ask h2 { margin: 0; font-size: 13px; font-weight: 600; color: var(--dim); }
270
+ .gen-ask p { margin: 0; font-size: 13px; }
271
+ .gen-ask .judged { margin: 0; min-width: 0; }
272
+ .gen-ask .chip { max-width: 100%; white-space: normal; text-align: left; }
271
273
 
272
274
  /* ---- 04 만들기 */
273
275
  .job-grid { display: grid; gap: 24px; align-items: start; }
@@ -303,7 +305,34 @@ input[type="radio"]:checked::after { content: ""; width: 8px; height: 8px; borde
303
305
  .job-acts .primary { flex: 1 1 100%; }
304
306
  .job-scenes:not(:empty) { margin-top: 32px; }
305
307
  .job-scenes h2 { margin: 0 0 10px; font-size: 13px; font-weight: 600; color: var(--dim); }
306
- .job-scenes .scene-bar { margin-bottom: 12px; }
308
+ /* 장면 타임라인 — 칸마다 상태가 따로 흐른다. 글자색이 상태를, 아래 띠가 도는 중을 말한다 */
309
+ .timeline { display: flex; gap: 2px; height: 56px; margin-bottom: 12px; border-radius: 10px; overflow: hidden; }
310
+ .timeline .cell { position: relative; min-height: 0; height: 100%; padding: 0 10px; border: 0; border-radius: 0; justify-content: stretch; text-align: left; font-size: 13px; color: var(--ink); }
311
+ .timeline .cell[aria-pressed="true"] { box-shadow: inset 0 0 0 2px var(--sky); }
312
+ @media (hover: hover) { .timeline .cell:hover { filter: brightness(1.25); } }
313
+ .timeline .cs { font-family: var(--sans); font-size: 12px; color: var(--dim); }
314
+ .timeline [data-state="done"] .cs, .timeline [data-state="reused"] .cs { color: var(--ok); }
315
+ .timeline [data-state="failed"] .cs { color: var(--bad); }
316
+ .timeline [data-state="writing"] .cs, .timeline [data-state="assets"] .cs, .timeline [data-state="render"] .cs, .timeline [data-state="check"] .cs { color: var(--sky); }
317
+ .timeline .run { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: linear-gradient(90deg, transparent, var(--sky), transparent) 0 0 / 50% 100% no-repeat; animation: sweep 1.1s linear infinite; }
318
+ @keyframes sweep { from { background-position: -50% 0; } to { background-position: 150% 0; } }
319
+ .scene-edit .hint { margin: 0; font-size: 13px; }
320
+ .scene-edit .scene { animation: enter 200ms var(--ease); }
321
+ .scene-edit .scene header .st { margin-left: 0; margin-right: auto; }
322
+ .chip-state { padding: 2px 8px; border-radius: 999px; font-size: 12px; color: var(--dim); background: var(--raised); }
323
+ .chip-state[data-state="done"], .chip-state[data-state="reused"] { color: var(--ok); }
324
+ .chip-state[data-state="failed"] { color: var(--bad); }
325
+ .scene-edit .fields { display: grid; gap: 10px; }
326
+ .scene-edit .field { display: grid; gap: 6px; font-size: 12px; color: var(--dim); }
327
+ .scene-edit .field textarea.mono { font-family: var(--mono); font-size: 13px; }
328
+ .scene-edit input[type="number"]:not(.dur input), .scene-edit .seed { min-height: 38px; padding: 0 10px; font-size: 14px; color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; }
329
+ .seed-row { display: flex; gap: 8px; }
330
+ .seed-row .seed { flex: 1; min-width: 0; font-family: var(--mono); }
331
+ .scene-assets { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
332
+ .scene-assets figure { margin: 0; display: grid; gap: 6px; }
333
+ .scene-assets img, .scene-assets video { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; border-radius: 8px; border: 1px solid var(--line); background: var(--stage); }
334
+ .scene-assets figcaption { display: grid; gap: 4px; font-family: var(--mono); font-size: 11px; color: var(--dim); overflow-wrap: anywhere; }
335
+ .scene-assets .pick { margin-top: 0; min-height: 34px; justify-content: center; }
307
336
  .scene-list { display: grid; gap: 12px; }
308
337
  .scene { display: grid; gap: 10px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
309
338
  .scene header { display: flex; align-items: center; gap: 8px; }
@@ -383,6 +412,10 @@ input[type="radio"]:checked::after { content: ""; width: 8px; height: 8px; borde
383
412
  .plan-stage { height: 180px; }
384
413
  .scene textarea, .dur input { font-size: 16px; }
385
414
  .scene-bar.big { height: 52px; }
415
+ /* 장면이 많으면 폰 폭에 안 들어간다 — 칸 최소 폭을 두고 옆으로 민다 */
416
+ .timeline { overflow-x: auto; }
417
+ .timeline .cell { min-width: 64px; }
418
+ .scene-edit .seed, .scene-edit input[type="number"] { font-size: 16px; }
386
419
  .scene-rows li { grid-template-columns: 10px minmax(0, 1fr) auto; row-gap: 4px; }
387
420
  .scene-rows .si { grid-column: 2 / -1; }
388
421
  .foot {
package/bin/hooks.mjs CHANGED
@@ -21,11 +21,24 @@ function packageExports(name) {
21
21
  return exportsCache.get(name);
22
22
  }
23
23
 
24
+ // 「./*」 같은 무늬 키도 Node 의 exports 처럼 * 하나를 갈아 끼워 푼다 — 감독이 짓는 장면이 @kasaframe/patterns/<type>/<이름> 으로 부른다
25
+ function exportTarget(exp, sub) {
26
+ if (typeof exp[sub] === "string") return exp[sub];
27
+ for (const [k, v] of Object.entries(exp)) {
28
+ const star = k.indexOf("*");
29
+ if (star < 0 || typeof v !== "string") continue;
30
+ const pre = k.slice(0, star);
31
+ const post = k.slice(star + 1);
32
+ if (sub.length > pre.length + post.length && sub.startsWith(pre) && sub.endsWith(post)) return v.replace("*", sub.slice(pre.length, sub.length - post.length));
33
+ }
34
+ return null;
35
+ }
36
+
24
37
  function kasaframeUrl(specifier) {
25
38
  const m = /^@kasaframe\/([^/]+)(\/.+)?$/.exec(specifier);
26
39
  if (!m) return null;
27
40
  const pkg = packageExports(m[1]);
28
- const target = pkg?.exp?.[m[2] ? `.${m[2]}` : "."];
41
+ const target = pkg?.exp && exportTarget(pkg.exp, m[2] ? `.${m[2]}` : ".");
29
42
  return typeof target === "string" ? new URL(target, pkg.dir).href : null;
30
43
  }
31
44
 
package/bin/kf.mjs CHANGED
@@ -1,11 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  // 설치된 패키지의 kf 입구. 소스는 .ts 그대로 실려 있는데, Node 는 node_modules 안의 .ts 는 형을 지워 주지 않는다 — hooks.mjs 가 대신 지워 들인다.
3
+ import { existsSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { delimiter, join } from "node:path";
6
+
7
+ // 형 지우기는 esbuild 가 하고 Node 에선 module.registerHooks 만 빌린다 — 그게 22.15 에 들어왔다
3
8
  const [major, minor] = process.versions.node.split(".").map(Number);
4
- if (major < 22 || (major === 22 && minor < 18)) {
5
- console.error(`kasaframe 은 Node 22.18 이상에서 돈다(지금 ${process.version}). https://nodejs.org 에서 22 LTS 이상을 깔고 다시.`);
9
+ if (major < 22 || (major === 22 && minor < 15)) {
10
+ console.error(`kasaframe 은 Node 22.15 이상에서 돈다(지금 ${process.version}). https://nodejs.org 에서 22 LTS 이상을 깔고 다시.`);
6
11
  process.exit(1);
7
12
  }
8
13
 
14
+ // ffmpeg 이 PATH 에 없고 다른 이름·자리(정적 빌드 등)에만 있는 환경 — 코드 곳곳이 "ffmpeg" 이름으로 부르니 이름 맞춘 링크를 PATH 앞에 둔다
15
+ const tools = { ffmpeg: process.env.KF_FFMPEG ?? process.env.FFMPEG_PATH, ffprobe: process.env.KF_FFPROBE ?? process.env.FFPROBE_PATH };
16
+ if (tools.ffmpeg || tools.ffprobe) {
17
+ const dir = join(homedir(), ".cache", "kasaframe", "bin");
18
+ mkdirSync(dir, { recursive: true });
19
+ for (const [name, target] of Object.entries(tools)) {
20
+ if (!target || !existsSync(target)) continue;
21
+ rmSync(join(dir, name), { force: true });
22
+ symlinkSync(target, join(dir, name));
23
+ }
24
+ if (!(process.env.PATH ?? "").split(delimiter).includes(dir)) process.env.PATH = `${dir}${delimiter}${process.env.PATH ?? ""}`;
25
+ }
26
+
9
27
  const hooks = new URL("./hooks.mjs", import.meta.url).href;
10
28
  // kf 가 자식으로 띄우는 kf(make → new·render·check 따위)도 같은 훅을 타야 .ts 를 읽는다
11
29
  if (!(process.env.NODE_OPTIONS ?? "").includes(hooks)) process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ""} --import=${hooks}`.trim();