kasaframe 0.0.0-stage → 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 (442) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +148 -0
  3. package/README.md +92 -2
  4. package/app/package.json +12 -0
  5. package/app/server/engine.ts +533 -0
  6. package/app/server/main.ts +209 -0
  7. package/app/web/env.d.ts +2 -0
  8. package/app/web/icons.ts +31 -0
  9. package/app/web/index.html +21 -0
  10. package/app/web/main.ts +1195 -0
  11. package/app/web/style.css +463 -0
  12. package/bin/hooks.mjs +64 -0
  13. package/bin/kf.mjs +32 -0
  14. package/docs/AGENTS.md +286 -0
  15. package/music/README.md +37 -0
  16. package/package.json +44 -4
  17. package/packages/adapters/package.json +36 -0
  18. package/packages/adapters/src/css.ts +11 -0
  19. package/packages/adapters/src/gsap.ts +26 -0
  20. package/packages/adapters/src/index.ts +9 -0
  21. package/packages/adapters/src/lottie.ts +22 -0
  22. package/packages/adapters/src/media.ts +59 -0
  23. package/packages/adapters/src/page.ts +229 -0
  24. package/packages/adapters/src/project.ts +214 -0
  25. package/packages/adapters/src/react.ts +36 -0
  26. package/packages/adapters/src/svelte.ts +22 -0
  27. package/packages/adapters/src/three.ts +18 -0
  28. package/packages/adapters/src/vue.ts +27 -0
  29. package/packages/check/package.json +19 -0
  30. package/packages/check/src/check.ts +280 -0
  31. package/packages/check/src/cli.ts +70 -0
  32. package/packages/check/src/image.ts +146 -0
  33. package/packages/check/src/index.ts +9 -0
  34. package/packages/check/src/ocr.swift +37 -0
  35. package/packages/check/src/probe.ts +328 -0
  36. package/packages/check/src/report.ts +44 -0
  37. package/packages/check/src/rules/assets.ts +45 -0
  38. package/packages/check/src/rules/beats.ts +212 -0
  39. package/packages/check/src/rules/camera.ts +217 -0
  40. package/packages/check/src/rules/captions.ts +49 -0
  41. package/packages/check/src/rules/flash.ts +235 -0
  42. package/packages/check/src/rules/frames.ts +136 -0
  43. package/packages/check/src/rules/layout.ts +281 -0
  44. package/packages/check/src/rules/page.ts +87 -0
  45. package/packages/check/src/rules/runs.ts +20 -0
  46. package/packages/check/src/rules/text.ts +326 -0
  47. package/packages/check/src/scan.ts +240 -0
  48. package/packages/check/src/serve.ts +67 -0
  49. package/packages/check/src/types.ts +80 -0
  50. package/packages/check/src/video.ts +137 -0
  51. package/packages/cli/package.json +30 -0
  52. package/packages/cli/src/blur.ts +80 -0
  53. package/packages/cli/src/browser.ts +203 -0
  54. package/packages/cli/src/command.ts +44 -0
  55. package/packages/cli/src/commands/app.ts +25 -0
  56. package/packages/cli/src/commands/beats.ts +36 -0
  57. package/packages/cli/src/commands/bench.ts +123 -0
  58. package/packages/cli/src/commands/blender.ts +293 -0
  59. package/packages/cli/src/commands/check.ts +25 -0
  60. package/packages/cli/src/commands/direct.ts +210 -0
  61. package/packages/cli/src/commands/doctor.ts +30 -0
  62. package/packages/cli/src/commands/gen.ts +173 -0
  63. package/packages/cli/src/commands/look.ts +46 -0
  64. package/packages/cli/src/commands/lyrics.ts +157 -0
  65. package/packages/cli/src/commands/make.ts +1121 -0
  66. package/packages/cli/src/commands/music.ts +78 -0
  67. package/packages/cli/src/commands/narrate.ts +238 -0
  68. package/packages/cli/src/commands/new.ts +185 -0
  69. package/packages/cli/src/commands/preview.ts +43 -0
  70. package/packages/cli/src/commands/render.ts +250 -0
  71. package/packages/cli/src/commands/series.ts +110 -0
  72. package/packages/cli/src/commands/setup.ts +102 -0
  73. package/packages/cli/src/commands/sheet.ts +114 -0
  74. package/packages/cli/src/commands/skill.ts +43 -0
  75. package/packages/cli/src/commands/still.ts +40 -0
  76. package/packages/cli/src/commands/theme.ts +172 -0
  77. package/packages/cli/src/commands/transitions.ts +40 -0
  78. package/packages/cli/src/deps.ts +122 -0
  79. package/packages/cli/src/ffmpeg.ts +81 -0
  80. package/packages/cli/src/index.ts +8 -0
  81. package/packages/cli/src/kf.ts +64 -0
  82. package/packages/cli/src/lock.ts +72 -0
  83. package/packages/cli/src/mix.ts +131 -0
  84. package/packages/cli/src/out.ts +13 -0
  85. package/packages/cli/src/pool.ts +34 -0
  86. package/packages/cli/src/segments.ts +402 -0
  87. package/packages/cli/src/serve.ts +124 -0
  88. package/packages/clock/package.json +14 -0
  89. package/packages/clock/src/index.ts +25 -0
  90. package/packages/clock/src/install.ts +351 -0
  91. package/packages/clock/src/types.ts +45 -0
  92. package/packages/core/package.json +9 -0
  93. package/packages/core/src/beats.ts +283 -0
  94. package/packages/core/src/ease.ts +87 -0
  95. package/packages/core/src/frame.ts +99 -0
  96. package/packages/core/src/index.ts +27 -0
  97. package/packages/core/src/project.ts +157 -0
  98. package/packages/core/src/rand.ts +56 -0
  99. package/packages/core/src/spring.ts +52 -0
  100. package/packages/core/src/style.ts +100 -0
  101. package/packages/core/src/time.ts +88 -0
  102. package/packages/director/package.json +23 -0
  103. package/packages/director/src/assemble.ts +361 -0
  104. package/packages/director/src/brief.ts +122 -0
  105. package/packages/director/src/catalog.ts +49 -0
  106. package/packages/director/src/content.ts +343 -0
  107. package/packages/director/src/fallback.ts +58 -0
  108. package/packages/director/src/index.ts +13 -0
  109. package/packages/director/src/pattern.ts +90 -0
  110. package/packages/director/src/patterns/fill/cells.ts +115 -0
  111. package/packages/director/src/patterns/formula/stack.ts +93 -0
  112. package/packages/director/src/patterns/util.ts +58 -0
  113. package/packages/director/src/scene.ts +62 -0
  114. package/packages/director/src/select.ts +163 -0
  115. package/packages/director/src/transitions.ts +115 -0
  116. package/packages/gen/PROVIDERS.md +90 -0
  117. package/packages/gen/package.json +9 -0
  118. package/packages/gen/src/ask.ts +132 -0
  119. package/packages/gen/src/audio.ts +91 -0
  120. package/packages/gen/src/blender.ts +54 -0
  121. package/packages/gen/src/capabilities.ts +151 -0
  122. package/packages/gen/src/generate.ts +206 -0
  123. package/packages/gen/src/image/codex.ts +117 -0
  124. package/packages/gen/src/image/og.ts +133 -0
  125. package/packages/gen/src/index.ts +26 -0
  126. package/packages/gen/src/keys.ts +86 -0
  127. package/packages/gen/src/media.ts +150 -0
  128. package/packages/gen/src/music/elevenlabs.ts +41 -0
  129. package/packages/gen/src/music/index.ts +120 -0
  130. package/packages/gen/src/music/library.ts +73 -0
  131. package/packages/gen/src/music/lyria.ts +52 -0
  132. package/packages/gen/src/music/types.ts +28 -0
  133. package/packages/gen/src/plan.ts +106 -0
  134. package/packages/gen/src/registry.ts +54 -0
  135. package/packages/gen/src/seam.ts +103 -0
  136. package/packages/gen/src/tts/dashscope.ts +64 -0
  137. package/packages/gen/src/tts/elevenlabs.ts +84 -0
  138. package/packages/gen/src/tts/gemini.ts +73 -0
  139. package/packages/gen/src/tts/index.ts +241 -0
  140. package/packages/gen/src/tts/local.ts +42 -0
  141. package/packages/gen/src/tts/types.ts +44 -0
  142. package/packages/gen/src/types.ts +90 -0
  143. package/packages/gen/src/video/fal.ts +140 -0
  144. package/packages/gen/src/video/h3.ts +112 -0
  145. package/packages/gen/src/video/http.ts +44 -0
  146. package/packages/gen/src/video/ltx.ts +60 -0
  147. package/packages/gen/src/video/minimax.ts +55 -0
  148. package/packages/gen/src/video/veo.ts +71 -0
  149. package/packages/jizura/package.json +16 -0
  150. package/packages/jizura/src/deny.ts +73 -0
  151. package/packages/jizura/src/index.ts +7 -0
  152. package/packages/jizura/src/install.ts +269 -0
  153. package/packages/jizura/src/layer.ts +325 -0
  154. package/packages/jizura/src/lyrics.ts +81 -0
  155. package/packages/jizura/src/pin.ts +8 -0
  156. package/packages/jizura/src/vendor.ts +140 -0
  157. package/packages/jizura/src/vite.ts +63 -0
  158. package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
  159. package/packages/jizura/vendor/NOTICE +43 -0
  160. package/packages/jizura/vendor/engine.json +47 -0
  161. package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
  162. package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
  163. package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
  164. package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
  165. package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  166. package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
  167. package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
  168. package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
  169. package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
  170. package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
  171. package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
  172. package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
  173. package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
  174. package/packages/jizura/vendor/fonts/fonts.json +52 -0
  175. package/packages/jizura/vendor/jizura_engine.js +35024 -0
  176. package/packages/motion/README.md +153 -0
  177. package/packages/motion/package.json +30 -0
  178. package/packages/motion/src/camera.ts +97 -0
  179. package/packages/motion/src/captions.ts +487 -0
  180. package/packages/motion/src/enter.ts +128 -0
  181. package/packages/motion/src/hangul.ts +129 -0
  182. package/packages/motion/src/impact.ts +100 -0
  183. package/packages/motion/src/index.ts +17 -0
  184. package/packages/motion/src/moves/brushWipe.ts +103 -0
  185. package/packages/motion/src/moves/cloneArray.ts +111 -0
  186. package/packages/motion/src/moves/curtains.ts +81 -0
  187. package/packages/motion/src/moves/dress.ts +191 -0
  188. package/packages/motion/src/moves/echoStack.ts +70 -0
  189. package/packages/motion/src/moves/fit.ts +70 -0
  190. package/packages/motion/src/moves/flood.ts +61 -0
  191. package/packages/motion/src/moves/ghostStack.ts +88 -0
  192. package/packages/motion/src/moves/gridFloor.ts +100 -0
  193. package/packages/motion/src/moves/imageSwarm.ts +113 -0
  194. package/packages/motion/src/moves/index.ts +38 -0
  195. package/packages/motion/src/moves/irisBlades.ts +113 -0
  196. package/packages/motion/src/moves/karaokeLine.ts +108 -0
  197. package/packages/motion/src/moves/ledBoard.ts +94 -0
  198. package/packages/motion/src/moves/liquidTab.ts +72 -0
  199. package/packages/motion/src/moves/markerWrite.ts +63 -0
  200. package/packages/motion/src/moves/menu.ts +138 -0
  201. package/packages/motion/src/moves/morphBox.ts +96 -0
  202. package/packages/motion/src/moves/neonSign.ts +78 -0
  203. package/packages/motion/src/moves/panelPop.ts +78 -0
  204. package/packages/motion/src/moves/paperTear.ts +75 -0
  205. package/packages/motion/src/moves/path.ts +64 -0
  206. package/packages/motion/src/moves/pathText.ts +107 -0
  207. package/packages/motion/src/moves/pointSphere.ts +109 -0
  208. package/packages/motion/src/moves/ransomLetters.ts +73 -0
  209. package/packages/motion/src/moves/shapeMorph.ts +143 -0
  210. package/packages/motion/src/moves/slamWords.ts +96 -0
  211. package/packages/motion/src/moves/sparkTrail.ts +130 -0
  212. package/packages/motion/src/moves/speedLines.ts +91 -0
  213. package/packages/motion/src/moves/splitFlap.ts +149 -0
  214. package/packages/motion/src/moves/stepWords.ts +128 -0
  215. package/packages/motion/src/moves/strikeOut.ts +98 -0
  216. package/packages/motion/src/moves/sunburst.ts +58 -0
  217. package/packages/motion/src/moves/tickerRows.ts +91 -0
  218. package/packages/motion/src/moves/tokenPicker.ts +115 -0
  219. package/packages/motion/src/moves/truchet.ts +85 -0
  220. package/packages/motion/src/moves/wordWall.ts +78 -0
  221. package/packages/motion/src/moves/worldRoll.ts +32 -0
  222. package/packages/motion/src/particles.ts +102 -0
  223. package/packages/motion/src/react/index.tsx +21 -0
  224. package/packages/motion/src/react/parts.tsx +431 -0
  225. package/packages/motion/src/react/time.tsx +97 -0
  226. package/packages/motion/src/text.ts +103 -0
  227. package/packages/motion/src/transition.ts +82 -0
  228. package/packages/motion/src/transition3d/cards.ts +65 -0
  229. package/packages/motion/src/transition3d/chain.ts +84 -0
  230. package/packages/motion/src/transition3d/dive.ts +181 -0
  231. package/packages/motion/src/transition3d/fold.ts +110 -0
  232. package/packages/motion/src/transition3d/gl.ts +219 -0
  233. package/packages/motion/src/transition3d/index.ts +235 -0
  234. package/packages/motion/src/transition3d/live.ts +70 -0
  235. package/packages/motion/src/transition3d/portal.ts +115 -0
  236. package/packages/motion/src/transition3d/react.tsx +29 -0
  237. package/packages/motion/src/transition3d/rig.ts +600 -0
  238. package/packages/motion/src/transition3d/shatter.ts +104 -0
  239. package/packages/motion/src/transition3d/space.ts +173 -0
  240. package/packages/motion/src/transition3d/whip.ts +78 -0
  241. package/packages/motion/src/transition3d/world.ts +737 -0
  242. package/packages/motion/src/transition3d/zoomOut.ts +114 -0
  243. package/packages/motion/src/transitions2d/catalog.ts +127 -0
  244. package/packages/motion/src/transitions2d/css.d.ts +2 -0
  245. package/packages/motion/src/transitions2d/cult-ui/LICENSE.md +9 -0
  246. package/packages/motion/src/transitions2d/cult-ui/RollingNumber.tsx +43 -0
  247. package/packages/motion/src/transitions2d/gl-transitions/LICENSE +25 -0
  248. package/packages/motion/src/transitions2d/gl-transitions/shaders.ts +1931 -0
  249. package/packages/motion/src/transitions2d/index.ts +2 -0
  250. package/packages/motion/src/transitions2d/kokonutui/LICENSE +21 -0
  251. package/packages/motion/src/transitions2d/kokonutui/MatrixText.css +16 -0
  252. package/packages/motion/src/transitions2d/kokonutui/MatrixText.tsx +94 -0
  253. package/packages/motion/src/transitions2d/magicui/HyperText.css +15 -0
  254. package/packages/motion/src/transitions2d/magicui/HyperText.tsx +115 -0
  255. package/packages/motion/src/transitions2d/magicui/LICENSE.md +21 -0
  256. package/packages/motion/src/transitions2d/magicui/MorphingText.css +25 -0
  257. package/packages/motion/src/transitions2d/magicui/MorphingText.tsx +130 -0
  258. package/packages/motion/src/transitions2d/magicui/WordRotate.tsx +42 -0
  259. package/packages/motion/src/transitions2d/react.ts +13 -0
  260. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.css +39 -0
  261. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.tsx +32 -0
  262. package/packages/motion/src/transitions2d/smoothui/LICENSE +21 -0
  263. package/packages/motion/src/transitions2d/smoothui/SharedAxisY.tsx +50 -0
  264. package/packages/motion/src/transitions2d/smoothui/TextMorph.tsx +90 -0
  265. package/packages/motion/src/transitions2d/smoothui/shaders.ts +223 -0
  266. package/packages/motion/src/transitions2d/unlumen/LICENSE +21 -0
  267. package/packages/motion/src/transitions2d/unlumen/ScrambleText.tsx +114 -0
  268. package/packages/motion/src/transitions2d/vengeance-ui/LICENSE +21 -0
  269. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.css +69 -0
  270. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.tsx +45 -0
  271. package/packages/motion/src/type.ts +131 -0
  272. package/packages/motion/src/ui.ts +172 -0
  273. package/packages/patterns/package.json +15 -0
  274. package/packages/patterns/src/bars/rank.ts +119 -0
  275. package/packages/patterns/src/before-after/shrink.ts +127 -0
  276. package/packages/patterns/src/before-after/strike.ts +125 -0
  277. package/packages/patterns/src/code/terminal.ts +133 -0
  278. package/packages/patterns/src/compare/bars.ts +142 -0
  279. package/packages/patterns/src/compare/duel.ts +103 -0
  280. package/packages/patterns/src/end/lockup.ts +85 -0
  281. package/packages/patterns/src/end/slam.ts +91 -0
  282. package/packages/patterns/src/flow/graph.ts +250 -0
  283. package/packages/patterns/src/hook-number/counter.ts +101 -0
  284. package/packages/patterns/src/hook-number/roll.ts +179 -0
  285. package/packages/patterns/src/hook-number/slam.ts +91 -0
  286. package/packages/patterns/src/index.ts +38 -0
  287. package/packages/patterns/src/kit.ts +449 -0
  288. package/packages/patterns/src/limit/gauge.ts +117 -0
  289. package/packages/patterns/src/list/cards.ts +131 -0
  290. package/packages/patterns/src/list/cycle.ts +22 -0
  291. package/packages/patterns/src/media/panels.ts +100 -0
  292. package/packages/patterns/src/objects.ts +234 -0
  293. package/packages/patterns/src/quote/karaoke.ts +126 -0
  294. package/packages/patterns/src/quote/slam.ts +22 -0
  295. package/packages/patterns/src/screen/focus.ts +85 -0
  296. package/packages/patterns/src/steps/path.ts +137 -0
  297. package/packages/patterns/src/steps/stack.ts +111 -0
  298. package/packages/patterns/src/term-reveal/chips.ts +92 -0
  299. package/packages/patterns/src/term-reveal/expand.ts +127 -0
  300. package/packages/patterns/src/timeline/axis.ts +144 -0
  301. package/packages/patterns/src/title/mask.ts +85 -0
  302. package/packages/patterns/src/title/slam.ts +23 -0
  303. package/packages/patterns/src/toggle-sim/switch.ts +92 -0
  304. package/packages/patterns/src/trend/columns.ts +108 -0
  305. package/packages/patterns/src/trend/draw.ts +140 -0
  306. package/packages/patterns/src/types.ts +67 -0
  307. package/packages/patterns/src/words/moves.ts +42 -0
  308. package/packages/patterns/src/words/slam.ts +4 -0
  309. package/packages/plan/package.json +15 -0
  310. package/packages/plan/src/asks.ts +256 -0
  311. package/packages/plan/src/brief.ts +383 -0
  312. package/packages/plan/src/captions.ts +78 -0
  313. package/packages/plan/src/detect.ts +93 -0
  314. package/packages/plan/src/index.ts +27 -0
  315. package/packages/plan/src/layout.ts +176 -0
  316. package/packages/plan/src/llm.ts +172 -0
  317. package/packages/plan/src/look.ts +222 -0
  318. package/packages/plan/src/plan.ts +179 -0
  319. package/packages/plan/src/request.ts +55 -0
  320. package/packages/plan/src/series.ts +76 -0
  321. package/packages/plan/src/studio.ts +53 -0
  322. package/packages/plan/src/templates.ts +42 -0
  323. package/packages/plan/src/themes.ts +48 -0
  324. package/packages/plan/src/types.ts +157 -0
  325. package/packages/studio/package.json +12 -0
  326. package/packages/studio/src/index.ts +1 -0
  327. package/packages/studio/src/plugin.ts +236 -0
  328. package/packages/studio/src/ui/main.ts +503 -0
  329. package/packages/studio/src/ui/studio.css +165 -0
  330. package/packages/studio/src/ui/timeline.ts +317 -0
  331. package/packages/themes/kit/THEME-MAKE.md +68 -0
  332. package/packages/themes/kit/THEME.md +30 -0
  333. package/packages/themes/kit/briefs/og-intro-15s/brief.json +16 -0
  334. package/packages/themes/kit/briefs/og-intro-15s/facts.ts +29 -0
  335. package/packages/themes/kit/briefs/og-intro-15s/logo.svg +12 -0
  336. package/packages/themes/kit/effects.ts +5 -0
  337. package/packages/themes/kit/sample/index.html +11 -0
  338. package/packages/themes/kit/sample/main.ts +9 -0
  339. package/packages/themes/kit/sample/scenes/scene.ts +15 -0
  340. package/packages/themes/package.json +19 -0
  341. package/packages/themes/src/factory.ts +296 -0
  342. package/packages/themes/src/fx.ts +14 -0
  343. package/packages/themes/src/index.ts +114 -0
  344. package/packages/themes/src/slotkit.ts +79 -0
  345. package/packages/themes/src/slots.ts +46 -0
  346. package/packages/themes/src/stage3d.ts +197 -0
  347. package/packages/themes/src/types.ts +58 -0
  348. package/presets/kawaii-pastel.json +94 -0
  349. package/presets/neutral-dark.json +82 -0
  350. package/presets/og.json +98 -0
  351. package/series/og-shorts.json +14 -0
  352. package/skills/kasaframe/SKILL.md +33 -0
  353. package/templates/_shared/project/index.html +11 -0
  354. package/templates/_shared/project/lib/react.ts +22 -0
  355. package/templates/_shared/project/lib/stage.ts +292 -0
  356. package/templates/_shared/project/main.ts +9 -0
  357. package/templates/_shared/scenes/default.ts +21 -0
  358. package/templates/_shared/scenes/drop.ts +33 -0
  359. package/templates/_shared/scenes/end.ts +26 -0
  360. package/templates/_shared/scenes/hook.ts +18 -0
  361. package/templates/_shared/scenes/seam.ts +41 -0
  362. package/templates/_shared/scenes/step.ts +49 -0
  363. package/templates/ad/preview.png +0 -0
  364. package/templates/ad/template.json +151 -0
  365. package/templates/demo/preview.png +0 -0
  366. package/templates/demo/template.json +141 -0
  367. package/templates/explainer/preview.png +0 -0
  368. package/templates/explainer/template.json +193 -0
  369. package/templates/launch/preview.png +0 -0
  370. package/templates/launch/template.json +163 -0
  371. package/templates/mv/preview.png +0 -0
  372. package/templates/mv/scenes/default.ts +98 -0
  373. package/templates/mv/template.json +178 -0
  374. package/templates/promo/preview.png +0 -0
  375. package/templates/promo/template.json +185 -0
  376. package/templates/shorts/preview.png +0 -0
  377. package/templates/shorts/scenes/hook.ts +37 -0
  378. package/templates/shorts/template.json +196 -0
  379. package/templates/square/preview.png +0 -0
  380. package/templates/square/template.json +133 -0
  381. package/themes/debug-hud/THEME.md +39 -0
  382. package/themes/debug-hud/effects.ts +389 -0
  383. package/themes/debug-hud/sample/assets/og/logo.svg +12 -0
  384. package/themes/debug-hud/sample/beats.json +1 -0
  385. package/themes/debug-hud/sample/index.html +11 -0
  386. package/themes/debug-hud/sample/kf.json +59 -0
  387. package/themes/debug-hud/sample/lib/hudkit.ts +49 -0
  388. package/themes/debug-hud/sample/lib/og.ts +29 -0
  389. package/themes/debug-hud/sample/main.ts +9 -0
  390. package/themes/debug-hud/sample/scenes/end.ts +62 -0
  391. package/themes/debug-hud/sample/scenes/gather.ts +67 -0
  392. package/themes/debug-hud/sample/scenes/models.ts +70 -0
  393. package/themes/debug-hud/sample/scenes/price.ts +79 -0
  394. package/themes/debug-hud/sample/style.json +91 -0
  395. package/themes/debug-hud/slots.ts +99 -0
  396. package/themes/debug-hud/still.png +0 -0
  397. package/themes/debug-hud/style.json +91 -0
  398. package/themes/debug-hud/theme.json +68 -0
  399. package/themes/og-measure/THEME.md +47 -0
  400. package/themes/og-measure/effects.ts +432 -0
  401. package/themes/og-measure/sample/assets/brand/og-logo-full.svg +12 -0
  402. package/themes/og-measure/sample/assets/brand/og-logo.svg +12 -0
  403. package/themes/og-measure/sample/assets/data/measure.json +353 -0
  404. package/themes/og-measure/sample/beats.json +1 -0
  405. package/themes/og-measure/sample/index.html +11 -0
  406. package/themes/og-measure/sample/kf.json +66 -0
  407. package/themes/og-measure/sample/lib/data.ts +62 -0
  408. package/themes/og-measure/sample/main.ts +9 -0
  409. package/themes/og-measure/sample/measure.ts +78 -0
  410. package/themes/og-measure/sample/scenes/end.ts +47 -0
  411. package/themes/og-measure/sample/scenes/gather.ts +51 -0
  412. package/themes/og-measure/sample/scenes/models.ts +34 -0
  413. package/themes/og-measure/sample/scenes/one.ts +39 -0
  414. package/themes/og-measure/sample/scenes/price.ts +56 -0
  415. package/themes/og-measure/sample/style.json +97 -0
  416. package/themes/og-measure/slots.ts +88 -0
  417. package/themes/og-measure/still.png +0 -0
  418. package/themes/og-measure/style.json +97 -0
  419. package/themes/og-measure/theme.json +79 -0
  420. package/themes/riso-zine/THEME.md +43 -0
  421. package/themes/riso-zine/effects.ts +321 -0
  422. package/themes/riso-zine/sample/assets/og/logo.svg +12 -0
  423. package/themes/riso-zine/sample/beats.json +1 -0
  424. package/themes/riso-zine/sample/index.html +11 -0
  425. package/themes/riso-zine/sample/kf.json +59 -0
  426. package/themes/riso-zine/sample/lib/og.ts +29 -0
  427. package/themes/riso-zine/sample/lib/shot.ts +86 -0
  428. package/themes/riso-zine/sample/lib/zine.ts +42 -0
  429. package/themes/riso-zine/sample/main.ts +9 -0
  430. package/themes/riso-zine/sample/scenes/end.ts +61 -0
  431. package/themes/riso-zine/sample/scenes/gather.ts +62 -0
  432. package/themes/riso-zine/sample/scenes/models.ts +52 -0
  433. package/themes/riso-zine/sample/scenes/price.ts +82 -0
  434. package/themes/riso-zine/sample/style.json +99 -0
  435. package/themes/riso-zine/slots.ts +81 -0
  436. package/themes/riso-zine/still.png +0 -0
  437. package/themes/riso-zine/style.json +99 -0
  438. package/themes/riso-zine/theme.json +75 -0
  439. package/tools/beats/beats.py +372 -0
  440. package/tools/beats/requirements.txt +3 -0
  441. package/tools/lyrics/align.py +164 -0
  442. package/tools/lyrics/requirements.txt +9 -0
@@ -0,0 +1,1195 @@
1
+ // 작업실 화면 — 엔진 서버의 /api 만 쓴다. 상태는 서버가 갖고, 여기는 받은 것을 그린다.
2
+ // <link> 로 걸면 Vite 가 고친 CSS 를 옛것으로 내준다 — 모듈로 가져와야 감시·HMR 을 탄다
3
+ import "./style.css";
4
+ import { icon, type IconName } from "./icons.ts";
5
+
6
+ type Purpose = "promo" | "shorts" | "explainer" | "launch" | "demo" | "ad" | "square" | "mv";
7
+ type Choice = Purpose | "auto";
8
+ type Step = "purpose" | "theme" | "input" | "plan" | "job";
9
+ interface Scene { id: string; role: string; from: number; dur: number; idea: string; assets?: string[] }
10
+ interface Plan {
11
+ request: string; purpose: Purpose; confidence: number; alternatives: { purpose: Purpose; p: number }[]; reasons: string[];
12
+ title: string; aspect: string; width: number; height: number; duration: number; style: string; music: string | null;
13
+ captions: string; deliverables: string[]; scenes: Scene[]; gen: { id: string; kind: string; scene?: string; seam?: boolean }[];
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>;
19
+ }
20
+ type SceneState = "writing" | "written" | "assets" | "render" | "rendered" | "reused" | "check" | "done" | "failed";
21
+ interface Job {
22
+ id: string; input: { request: string; purpose?: Choice; music?: string; model?: string; assets?: string[] }; dir: string; phase: string; progress: number;
23
+ running: boolean; createdAt: number; previewUrl?: string; error?: string; checkPassed?: boolean;
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" }[];
33
+ }
34
+ interface Template {
35
+ id: Purpose; label: string; summary?: string; aspect: string; width?: number; height?: number;
36
+ duration: { min: number; max: number; default: number; allowed?: number[] };
37
+ music: "required" | "optional" | "none"; captions?: string; deliverables?: string[]; still?: string | null;
38
+ }
39
+ interface Track { name: string; path: string; url?: string }
40
+ // SPEC 9번 테마 팩. aspect 는 배열이면 첫째가 견본 화면비
41
+ interface Theme { id: string; name: string; metaphor?: string; status: string; still?: string | null; sample?: string | null; purposes?: string[]; aspect?: string | string[] }
42
+ interface Asset { name: string; path: string; kind: "image" | "video"; size?: number; url?: string }
43
+
44
+ const MODELS = [
45
+ { id: "anthropic/claude-opus-5-5", name: "Claude Opus 5.5", by: "Anthropic" },
46
+ { id: "anthropic/claude-sonnet-5-5", name: "Claude Sonnet 5.5", by: "Anthropic" },
47
+ { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", by: "DeepSeek" },
48
+ { id: "openai/gpt-5.6", name: "GPT-5.6", by: "OpenAI" },
49
+ { id: "z-ai/glm-5.3", name: "GLM 5.3", by: "Z.ai" },
50
+ ];
51
+ const EXAMPLES: Partial<Record<Choice, string>> = {
52
+ auto: "카사텀 홍보 영상 1분, 카와이 퓨처베이스",
53
+ promo: "카사텀 홍보 영상 1분, 카와이 퓨처베이스",
54
+ shorts: "OG 쇼츠 — MLA 가 뭔지 45초",
55
+ explainer: "어텐션이 어떻게 도는지 90초",
56
+ launch: "kasaframe 공개 영상 45초",
57
+ demo: "kf new 부터 렌더까지 시연 1분",
58
+ ad: "카사텀 범퍼 광고 15초",
59
+ square: "카사텀 기능 셋 피드 30초",
60
+ mv: "카와이 퓨처베이스 뮤비 3분",
61
+ };
62
+ const STEPS: [Step, string][] = [["purpose", "용도"], ["theme", "테마"], ["input", "재료"], ["plan", "계획"], ["job", "만들기"]];
63
+ const PHASES: [string, string][] = [["plan", "판별"], ["scaffold", "뼈대"], ["gen", "생성"], ["scenes", "장면"], ["render", "렌더"], ["check", "검사"], ["done", "끝"]];
64
+ const CAPTIONS: Record<string, string> = { off: "트랙만(srt·vtt)", on: "화면 자막 + 트랙", none: "자막 없음", track: "트랙만(srt·vtt)", burned: "화면 자막 + 트랙" };
65
+ // 옛 이름(track·burned)까지 — @kasaframe/motion/captions 의 captionMode 와 같은 뜻
66
+ const capMode = (v?: string) => (v === "on" || v === "fit" || v === "burned" ? "on" : v === "off" || v === "track" ? "off" : "none");
67
+ // 길이 막대는 모든 틀이 같은 눈금이어야 서로 견줘진다. 6초 범퍼와 5분 뮤비가 한 줄에 서도록 로그 눈금
68
+ const RANGE_MIN = 5;
69
+ let rangeMax = 300;
70
+ const rangePos = (v: number) => Math.log(Math.max(v, RANGE_MIN) / RANGE_MIN) / Math.log(rangeMax / RANGE_MIN);
71
+ const span = (min: number, max: number) =>
72
+ min >= 60 && min % 60 === 0 && max % 60 === 0 ? `${min / 60}–${max / 60}분` : `${min}–${max}초`;
73
+
74
+ type Kid = Node | string | null | undefined | false;
75
+ function h<K extends keyof HTMLElementTagNameMap>(tag: K, props: Record<string, unknown> = {}, ...kids: Kid[]): HTMLElementTagNameMap[K] {
76
+ const el = document.createElement(tag);
77
+ for (const [k, v] of Object.entries(props)) {
78
+ if (v === undefined || v === null || v === false) continue;
79
+ if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v as EventListener);
80
+ else if (k === "class") el.className = String(v);
81
+ else if (k in el && k !== "list" && k !== "style") (el as unknown as Record<string, unknown>)[k] = v;
82
+ else el.setAttribute(k, String(v));
83
+ }
84
+ for (const c of kids) if (c !== null && c !== undefined && c !== false) el.append(c);
85
+ return el;
86
+ }
87
+ const $ = <T extends HTMLElement>(sel: string) => document.querySelector(sel) as T;
88
+
89
+ async function api<T>(path: string, init?: RequestInit): Promise<T> {
90
+ const r = await fetch(path, { ...init, headers: { "content-type": "application/json", ...(init?.headers ?? {}) } });
91
+ const j = await r.json().catch(() => ({}));
92
+ if (!r.ok) throw new Error((j as { error?: string }).error ?? r.statusText);
93
+ return j as T;
94
+ }
95
+
96
+ const sec = (x: number) => `${+x.toFixed(x < 10 ? 2 : 1)}초`;
97
+ const ratio = (aspect: string) => {
98
+ const [w, hh] = aspect.split(":").map(Number);
99
+ return { w: w || 16, h: hh || 9 };
100
+ };
101
+ const iconLabel = (name: IconName, text: string, size?: number) => [icon(name, size), h("span", {}, text)];
102
+
103
+ // ---- 고른 것 — 새로 고쳐도 남게 세션에 둔다
104
+
105
+ // 시험은 Opus 로 하고 만족하면 DeepSeek 로 내린다 — 사람이 직접 고르기 전에는 기본값을 따른다
106
+ // theme 은 테마 id 거나 "auto". auto 면 계획 단계에서 모델이 낸 후보 셋 중 하나를 themePick 에 고른다
107
+ // captions 가 "" 면 틀 기본을 따른다
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") ?? "{}") };
110
+ if (!D.modelPicked) D.model = MODELS[0].id;
111
+ const save = () => sessionStorage.setItem("kf-draft", JSON.stringify(D));
112
+
113
+ let templates: Template[] = [];
114
+ // null 이면 서버에 테마 목록이 아직 없다 — 테마 단계는 「자동」만 낸다
115
+ let themes: Theme[] | null = null;
116
+ // draft 까지 — 테마 늘리기 칸에서만 쓴다(만드는 중인 테마의 견본 굽기)
117
+ let themesAll: Theme[] = [];
118
+ let suggest: { key: string; ids: string[]; loading: boolean } | null = null;
119
+ let tracks: Track[] = [];
120
+ let assets: Asset[] = [];
121
+ let plan: Plan | null = null;
122
+ let planKey = "";
123
+ let current: Job | null = null;
124
+ let stream: EventSource | null = null;
125
+ let logLines: string[] = [];
126
+ let error = "";
127
+
128
+ const tpl = (id: Choice | null | undefined) => templates.find((t) => t.id === id);
129
+
130
+ // ---- 길
131
+
132
+ function readUrl(): { step: Step; job: string | null } {
133
+ const q = new URLSearchParams(location.search);
134
+ const job = q.get("job");
135
+ if (job) return { step: "job", job };
136
+ const step = q.get("step") as Step | null;
137
+ return { step: step && STEPS.some(([s]) => s === step) && step !== "job" ? step : "purpose", job: null };
138
+ }
139
+
140
+ function go(step: Step, o: { job?: string; replace?: boolean } = {}): void {
141
+ const q = new URLSearchParams();
142
+ if (step === "job" && o.job) q.set("job", o.job);
143
+ else if (step !== "purpose") q.set("step", step);
144
+ const url = q.size ? `/?${q}` : "/";
145
+ if (url !== location.pathname + location.search) history[o.replace ? "replaceState" : "pushState"](null, "", url);
146
+ error = "";
147
+ render();
148
+ window.scrollTo({ top: 0 });
149
+ }
150
+ window.addEventListener("popstate", () => render());
151
+
152
+ function reachable(step: Step): boolean {
153
+ if (step === "purpose") return true;
154
+ if (step === "theme") return !!D.purpose;
155
+ if (step === "input") return !!D.purpose && !!D.theme;
156
+ if (step === "plan") return !!D.purpose && !!D.theme && !!D.request.trim();
157
+ return !!current;
158
+ }
159
+
160
+ function render(): void {
161
+ const { step, job } = readUrl();
162
+ if (step !== "job" && !reachable(step)) {
163
+ const back = (["input", "theme", "purpose"] as Step[]).find((s) => reachable(s)) ?? "purpose";
164
+ return go(back, { replace: true });
165
+ }
166
+ renderRail(step);
167
+ const view = $<HTMLElement>("#view");
168
+ if (step === "job") {
169
+ if (job && current?.id !== job) follow(job);
170
+ renderJob();
171
+ return;
172
+ }
173
+ stream?.close();
174
+ stream = null;
175
+ const pane = step === "purpose" ? viewPurpose() : step === "theme" ? viewTheme() : step === "input" ? viewInput() : viewPlan();
176
+ view.replaceChildren(...(error ? [h("p", { class: "banner", role: "alert" }, icon("warn"), error)] : []), pane);
177
+ }
178
+
179
+ function renderRail(step: Step): void {
180
+ const at = STEPS.findIndex(([s]) => s === step);
181
+ $<HTMLElement>("#rail").replaceChildren(
182
+ ...STEPS.map(([s, label], i) =>
183
+ h("button", {
184
+ type: "button", class: "rail-step", disabled: !reachable(s) || s === step,
185
+ "aria-current": s === step ? "step" : undefined, "data-done": String(i < at),
186
+ onclick: () => (s === "job" && current ? go("job", { job: current.id }) : go(s)),
187
+ }, h("span", { class: "n" }, String(i + 1).padStart(2, "0")), h("span", { class: "t" }, label)),
188
+ ),
189
+ );
190
+ }
191
+
192
+ const head = (n: number, title: string, ...extra: Kid[]) =>
193
+ h("header", { class: "pane-head" }, h("span", { class: "n" }, String(n).padStart(2, "0")), h("h1", {}, title), ...extra);
194
+ const pane = (step: Step, ...kids: Kid[]) => h("section", { class: "pane", "data-step": step }, ...kids);
195
+
196
+ // ---- 01 용도
197
+
198
+ function frame(t: Template | undefined, aspect: string): HTMLElement {
199
+ const { w, h: hh } = ratio(aspect);
200
+ const box = h("div", { class: "frame" });
201
+ box.style.aspectRatio = `${w} / ${hh}`;
202
+ box.dataset.shape = w > hh ? "wide" : w < hh ? "tall" : "square";
203
+ if (t?.still) {
204
+ const img = h("img", { src: t.still, alt: "", loading: "lazy", decoding: "async", width: w * 40, height: hh * 40 });
205
+ img.addEventListener("error", () => img.replaceWith(h("span", { class: "frame-ratio" }, aspect)));
206
+ box.append(img);
207
+ } else box.append(h("span", { class: "frame-ratio" }, aspect));
208
+ return box;
209
+ }
210
+
211
+ function autoFrame(): HTMLElement {
212
+ const t = document.createElement("template");
213
+ // 어느 화면비든 받는다는 뜻 — 세 틀을 겹쳐 그린다
214
+ t.innerHTML = `<svg class="auto-shapes" viewBox="0 0 120 96" aria-hidden="true">
215
+ <rect x="16" y="26" width="88" height="49.5" rx="3"/><rect x="42" y="12" width="36" height="64" rx="3"/><rect x="36" y="22" width="48" height="48" rx="3"/></svg>`;
216
+ return h("div", { class: "frame auto" }, t.content.firstChild as SVGElement);
217
+ }
218
+
219
+ function rangeBar(d: Template["duration"]): HTMLElement {
220
+ const pct = (v: number) => `${Math.min(1, rangePos(v)) * 100}%`;
221
+ const fill = h("span", { class: "fill" });
222
+ fill.style.left = pct(d.min);
223
+ fill.style.width = `calc(${pct(d.max)} - ${pct(d.min)})`;
224
+ return h("span", { class: "range", role: "img", "aria-label": span(d.min, d.max) }, fill,
225
+ ...(d.allowed ?? [d.default]).map((v) => {
226
+ const tick = h("span", { class: "tick" });
227
+ tick.style.left = pct(v);
228
+ return tick;
229
+ }));
230
+ }
231
+
232
+ function badges(t: Template): HTMLElement | null {
233
+ const out: Node[] = [];
234
+ if (t.music === "required") out.push(h("span", { class: "badge", title: "곡 필요", "aria-label": "곡 필요" }, icon("music", 14)));
235
+ if (capMode(t.captions) !== "none") out.push(h("span", { class: "badge", title: CAPTIONS[t.captions!], "aria-label": CAPTIONS[t.captions!] }, icon("caption", 14)));
236
+ return out.length ? h("span", { class: "badges" }, ...out) : null;
237
+ }
238
+
239
+ function kindCard(id: Choice, label: string, stage: HTMLElement, meta: string, range: HTMLElement, tags: Kid, title?: string): HTMLElement {
240
+ return h("button", {
241
+ class: "kind", type: "button", "aria-pressed": String(D.purpose === id), title,
242
+ onclick: () => {
243
+ D.purpose = id;
244
+ plan = null;
245
+ save();
246
+ go("theme");
247
+ },
248
+ },
249
+ h("span", { class: "kind-stage" }, stage, tags),
250
+ h("span", { class: "kind-body" },
251
+ h("span", { class: "kind-top" }, h("span", { class: "kind-name" }, label), h("span", { class: "kind-meta" }, meta)),
252
+ range),
253
+ );
254
+ }
255
+
256
+ function viewPurpose(): HTMLElement {
257
+ if (!templates.length) {
258
+ return pane("purpose", head(1, "용도"), h("div", { class: "kinds" }, ...Array.from({ length: 8 }, () => h("div", { class: "kind skeleton", "aria-hidden": "true" }))));
259
+ }
260
+ const all = { min: Math.min(...templates.map((t) => t.duration.min)), max: Math.max(...templates.map((t) => t.duration.max)), default: 0, allowed: [] };
261
+ return pane("purpose",
262
+ head(1, "용도"),
263
+ h("div", { class: "kinds" },
264
+ kindCard("auto", "자동", autoFrame(), "요청 글로", rangeBar(all), null),
265
+ ...templates.map((t) => kindCard(t.id, t.label, frame(t, t.aspect), `${t.aspect} · ${span(t.duration.min, t.duration.max)}`, rangeBar(t.duration), badges(t), t.summary)),
266
+ ),
267
+ );
268
+ }
269
+
270
+ // ---- 02 테마
271
+
272
+ const themeOf = (id: string | null | undefined) => (id && id !== "auto" ? themes?.find((t) => t.id === id) : undefined);
273
+
274
+ function autoSwatch(): SVGElement {
275
+ const t = document.createElement("template");
276
+ // 후보 셋을 겹쳐 그린다 — 「모델이 셋을 낸다」를 그림으로
277
+ t.innerHTML = `<svg class="auto-swatch" viewBox="0 0 160 90" aria-hidden="true">
278
+ <rect class="a" x="18" y="22" width="72" height="44" rx="5"/><rect class="b" x="44" y="14" width="72" height="44" rx="5"/><rect class="c" x="70" y="30" width="72" height="44" rx="5"/></svg>`;
279
+ return t.content.firstChild as SVGElement;
280
+ }
281
+
282
+ let sampleOn: HTMLElement | null = null;
283
+
284
+ function stopSample(): void {
285
+ sampleOn?.querySelector("video")?.remove();
286
+ sampleOn?.querySelector(".theme-play")?.replaceChildren(icon("play", 16));
287
+ sampleOn = null;
288
+ }
289
+
290
+ function playSample(stage: HTMLElement, url: string): void {
291
+ if (sampleOn === stage) return;
292
+ stopSample();
293
+ const v = h("video", { src: url, muted: true, loop: true, playsInline: true, autoplay: true, preload: "auto" });
294
+ stage.append(v);
295
+ void v.play().catch(() => undefined);
296
+ stage.querySelector(".theme-play")?.replaceChildren(icon("pause", 16));
297
+ sampleOn = stage;
298
+ }
299
+
300
+ function pickTheme(id: string): void {
301
+ D.theme = id;
302
+ D.themePick = null;
303
+ plan = null;
304
+ save();
305
+ stopSample();
306
+ go("input");
307
+ }
308
+
309
+ function themeCard(t: Theme, onPick: () => void, pressed: boolean): HTMLElement {
310
+ const first = Array.isArray(t.aspect) ? t.aspect[0] : t.aspect;
311
+ const { w, h: hh } = ratio(first ?? "16:9");
312
+ const stage = h("span", { class: "theme-stage", "data-tall": String(hh > w) }, t.still ? h("img", { src: t.still, alt: "", loading: "lazy", decoding: "async", width: 320, height: 180 }) : h("span", { class: "frame-ratio" }, t.name));
313
+ if (t.sample) {
314
+ const url = t.sample;
315
+ stage.append(h("button", {
316
+ type: "button", class: "theme-play icon-btn", "aria-label": `${t.name} 견본 재생`,
317
+ onclick: (e: Event) => { e.stopPropagation(); sampleOn === stage ? stopSample() : playSample(stage, url); },
318
+ }, icon("play", 16)));
319
+ }
320
+ const card = h("article", { class: "theme" },
321
+ stage,
322
+ h("span", { class: "theme-body" },
323
+ h("button", { type: "button", class: "theme-pick", "aria-pressed": String(pressed), onclick: onPick },
324
+ h("span", { class: "theme-name" }, t.name), t.status !== "approved" ? h("span", { class: "review-tag" }, "검토 중") : null),
325
+ t.metaphor ? h("span", { class: "theme-meta" }, t.metaphor) : null),
326
+ );
327
+ // 마우스가 있는 기기는 올리기만 해도 견본이 돈다 — 폰은 재생 단추로
328
+ if (t.sample && matchMedia("(hover: hover)").matches) {
329
+ card.addEventListener("pointerenter", () => playSample(stage, t.sample!));
330
+ card.addEventListener("pointerleave", () => { if (sampleOn === stage) stopSample(); });
331
+ }
332
+ return card;
333
+ }
334
+
335
+ function autoThemeCard(): HTMLElement {
336
+ return h("article", { class: "theme" },
337
+ h("span", { class: "theme-stage auto" }, autoSwatch()),
338
+ h("span", { class: "theme-body" },
339
+ h("button", { type: "button", class: "theme-pick", "aria-pressed": String(D.theme === "auto"), onclick: () => pickTheme("auto") }, h("span", { class: "theme-name" }, "자동")),
340
+ h("span", { class: "theme-meta" }, "요청 글을 읽고 후보 셋")),
341
+ );
342
+ }
343
+
344
+ function viewTheme(): HTMLElement {
345
+ const fits = (t: Theme) => !t.purposes?.length || D.purpose === "auto" || t.purposes.includes(D.purpose ?? "");
346
+ // 지금 용도에 맞는 테마를 앞에 — 안 맞는 것도 지우지는 않는다
347
+ const order = (xs: Theme[]) => [...xs.filter(fits), ...xs.filter((t) => !fits(t))];
348
+ const approved = order((themes ?? []).filter((t) => t.status === "approved"));
349
+ const review = order((themes ?? []).filter((t) => t.status !== "approved"));
350
+ return pane("theme",
351
+ head(2, "테마", chosen(false)),
352
+ h("div", { class: "themes" }, autoThemeCard(), ...approved.map((t) => themeCard(t, () => pickTheme(t.id), D.theme === t.id))),
353
+ review.length
354
+ ? h("section", { class: "review" },
355
+ h("h2", {}, "검토 중", h("span", { class: "count" }, String(review.length))),
356
+ h("div", { class: "themes" }, ...review.map((t) => themeCard(t, () => pickTheme(t.id), D.theme === t.id))))
357
+ : null,
358
+ themeFactory(),
359
+ h("footer", { class: "foot" },
360
+ h("button", { type: "button", class: "ghost", onclick: () => go("purpose") }, ...iconLabel("back", "용도")),
361
+ D.theme ? h("button", { type: "button", class: "primary", onclick: () => go("input") }, h("span", {}, "재료"), icon("arrow")) : null),
362
+ );
363
+ }
364
+
365
+ // ---- 테마 늘리기 — 참고 자료로 뼈대·지시서를 만들고 그 지시서를 받을 세션 명령을 보여 준다(테마는 그 세션이 만든다).
366
+ // 견본이 나오면 「검토 중」으로 올라오고, 승인은 사람이 견본을 보고 OK 할 때만
367
+
368
+ interface Made { ok: boolean; command: string | null; log: string[] }
369
+ const F = { id: "", name: "", like: "", threeD: false, refs: [] as { name: string; path: string }[], urls: "", made: null as Made | null, busy: false, error: "" };
370
+
371
+ async function refreshThemes(): Promise<void> {
372
+ const th = await api<Theme[]>("/api/themes?all=1").catch(() => null);
373
+ themes = th?.filter((x) => x.status !== "draft") ?? null;
374
+ themesAll = th ?? [];
375
+ render();
376
+ }
377
+
378
+ async function makeTheme(): Promise<void> {
379
+ F.busy = true;
380
+ F.error = "";
381
+ render();
382
+ try {
383
+ const refs = [...F.refs.map((r) => r.path), ...F.urls.split(/\s+/).filter((u) => /^https?:\/\//.test(u))];
384
+ F.made = await api<Made>("/api/themes/new", { method: "POST", body: JSON.stringify({ id: F.id.trim(), name: F.name.trim() || undefined, like: F.like || undefined, threeD: F.threeD, refs }) });
385
+ if (!F.made.ok) F.error = F.made.log.slice(-4).join("\n");
386
+ await refreshThemes();
387
+ } catch (e) {
388
+ F.error = (e as Error).message;
389
+ } finally {
390
+ F.busy = false;
391
+ render();
392
+ }
393
+ }
394
+
395
+ const reviewing = new Set<string>();
396
+
397
+ async function bakeSample(id: string): Promise<void> {
398
+ reviewing.add(id);
399
+ render();
400
+ await api(`/api/themes/${id}/review`, { method: "POST" }).catch((e: Error) => alert(e.message));
401
+ // 굽기는 몇 분 걸린다 — 끝날 때까지 몇 초마다 본다
402
+ for (;;) {
403
+ await new Promise((r) => setTimeout(r, 4000));
404
+ const st = await api<{ running: boolean; code: number | null; log: string[] }>(`/api/themes/${id}/review`).catch(() => null);
405
+ if (!st?.running) {
406
+ if (st && st.code !== 0) alert(`${id} 견본 굽기가 실패했다:\n${st.log.slice(-5).join("\n")}`);
407
+ break;
408
+ }
409
+ }
410
+ reviewing.delete(id);
411
+ await refreshThemes();
412
+ }
413
+
414
+ async function approveTheme(t: Theme): Promise<void> {
415
+ if (!confirm(`${t.name} — 사람이 견본을 보고 OK 했을 때만 승인한다. 승인할까?`)) return;
416
+ const r = await api<{ code: number; log: string[] }>(`/api/themes/${t.id}/approve`, { method: "POST" }).catch((e: Error) => ({ code: 1, log: [e.message] }));
417
+ if (r.code !== 0) alert(r.log.slice(-4).join("\n"));
418
+ await refreshThemes();
419
+ }
420
+
421
+ // 글을 칠 때마다 다시 그리면 입력 칸이 초점을 잃는다 — 만들기 단추만 바로 켜고 끈다
422
+ function canMakeTheme(): boolean {
423
+ return /^[a-z0-9][a-z0-9-]*$/.test(F.id.trim()) && (F.refs.length > 0 || /https?:\/\//.test(F.urls)) && !F.busy;
424
+ }
425
+ function syncMake(): void {
426
+ const b = document.querySelector<HTMLButtonElement>(".factory .make");
427
+ if (b) b.disabled = !canMakeTheme();
428
+ }
429
+
430
+ function themeFactory(): HTMLElement {
431
+ const drafts = themesAll.filter((t) => t.status === "draft");
432
+ const review = themesAll.filter((t) => t.status === "review");
433
+ const row = (t: Theme, act: HTMLElement) => h("li", { class: "factory-row" }, h("span", { class: "theme-name" }, t.name), h("span", { class: "dim" }, t.id), act);
434
+ return h("details", { class: "factory", open: !!F.made || F.busy || undefined },
435
+ h("summary", {}, ...iconLabel("plus", "테마 늘리기", 16)),
436
+ h("p", { class: "dim" }, "참고 그림·영상·주소로 테마 뼈대와 지시서를 만든다. 테마는 아래 명령으로 연 세션이 만들고, 견본을 구우면 「검토 중」에 올라온다."),
437
+ h("div", { class: "factory-form" },
438
+ h("label", { class: "field" }, h("span", {}, "테마 id(영문 소문자·숫자·-)"), h("input", { value: F.id, placeholder: "glass-light", oninput: (e: Event) => { F.id = (e.target as HTMLInputElement).value; syncMake(); } })),
439
+ h("label", { class: "field" }, h("span", {}, "이름"), h("input", { value: F.name, placeholder: "유리·빛", oninput: (e: Event) => { F.name = (e.target as HTMLInputElement).value; } })),
440
+ h("label", { class: "field" }, h("span", {}, "비슷한 테마"),
441
+ h("select", { onchange: (e: Event) => { F.like = (e.target as HTMLSelectElement).value; } },
442
+ h("option", { value: "" }, "없음"), ...themesAll.map((t) => h("option", { value: t.id, selected: F.like === t.id }, t.name)))),
443
+ h("label", { class: "check" }, h("input", { type: "checkbox", checked: F.threeD, onchange: (e: Event) => { F.threeD = (e.target as HTMLInputElement).checked; } }), h("span", {}, "입체(3D)")),
444
+ h("label", { class: "field wide" }, h("span", {}, "참고 주소(줄마다 하나)"), h("textarea", { rows: 2, value: F.urls, placeholder: "https://…", oninput: (e: Event) => { F.urls = (e.target as HTMLTextAreaElement).value; syncMake(); } })),
445
+ h("div", { class: "wide" },
446
+ filePicker("참고 그림·영상 올리기", "image/*,video/*", true, async (fs) => {
447
+ for (const f of fs) F.refs.push(await upload(f));
448
+ render();
449
+ }),
450
+ F.refs.length ? h("p", { class: "dim" }, F.refs.map((r) => r.name).join(" · ")) : null),
451
+ ),
452
+ h("button", { type: "button", class: "primary make", disabled: !canMakeTheme(), onclick: makeTheme }, F.busy ? "만드는 중…" : "뼈대·지시서 만들기"),
453
+ F.error ? h("pre", { class: "factory-log" }, F.error) : null,
454
+ F.made?.command
455
+ ? h("div", { class: "factory-cmd" },
456
+ h("p", {}, "레포 뿌리에서 이 명령으로 세션을 연다:"),
457
+ h("code", {}, F.made.command),
458
+ h("button", { type: "button", class: "ghost", onclick: () => navigator.clipboard.writeText(F.made!.command!) }, ...iconLabel("copy", "복사", 16)))
459
+ : null,
460
+ drafts.length
461
+ ? h("section", {}, h("h3", {}, "만드는 중"), h("ul", { class: "factory-list" }, ...drafts.map((t) => row(t, h("button", { type: "button", class: "ghost", disabled: reviewing.has(t.id), onclick: () => bakeSample(t.id) }, reviewing.has(t.id) ? "굽는 중…" : "견본 굽기")))))
462
+ : null,
463
+ review.length
464
+ ? h("section", {}, h("h3", {}, "검토 중 — 승인은 사람만"), h("ul", { class: "factory-list" }, ...review.map((t) => row(t, h("button", { type: "button", class: "ghost", onclick: () => approveTheme(t) }, "승인")))))
465
+ : null,
466
+ );
467
+ }
468
+
469
+ // ---- 03 재료
470
+
471
+ function chosen(withTheme = true): HTMLElement {
472
+ const t = tpl(D.purpose);
473
+ const th = themeOf(D.theme);
474
+ return h("div", { class: "chosen" },
475
+ h("button", { type: "button", class: "chosen-part", "aria-label": "용도 바꾸기", onclick: () => go("purpose") },
476
+ h("span", { class: "chosen-frame" }, t ? frame(t, t.aspect) : autoFrame()),
477
+ h("span", { class: "chosen-text" }, h("b", {}, t?.label ?? "자동"), h("span", {}, t ? `${t.aspect} · ${span(t.duration.min, t.duration.max)}` : "요청 글로"))),
478
+ withTheme && D.theme
479
+ ? h("button", { type: "button", class: "chosen-part", "aria-label": "테마 바꾸기", onclick: () => go("theme") },
480
+ h("span", { class: "chosen-swatch" }, th?.still ? h("img", { src: th.still, alt: "", width: 56, height: 32 }) : autoSwatch()),
481
+ h("span", { class: "chosen-text" }, h("b", {}, th?.name ?? "자동"), h("span", {}, th ? (th.status === "approved" ? "테마" : "검토 중") : "후보 셋")))
482
+ : null,
483
+ );
484
+ }
485
+
486
+ const block = (title: string, kids: Kid[], aside?: Kid) => h("section", { class: "block" }, h("h2", {}, title, aside), ...kids);
487
+
488
+ let player: HTMLAudioElement | null = null;
489
+ let playing = "";
490
+
491
+ function trackList(): HTMLElement {
492
+ const box = h("div", { class: "tracks", role: "radiogroup", "aria-label": "곡" });
493
+ const row = (path: string, name: string, url?: string) => {
494
+ const radio = h("input", { type: "radio", name: "music", value: path, checked: D.music === path, onchange: () => { D.music = path; save(); renderInputFoot(); } });
495
+ const play: HTMLElement = url
496
+ ? h("button", { type: "button", class: "icon-btn", "aria-label": `${name} 미리 듣기`, onclick: () => toggle(url, play as HTMLButtonElement) }, icon(playing === url ? "pause" : "play", 16))
497
+ : h("span", { class: "icon-btn placeholder", "aria-hidden": "true" }, icon(path ? "music" : "close", 16));
498
+ return h("div", { class: "track" }, play, h("label", {}, radio, h("span", { class: "track-name" }, name)));
499
+ };
500
+ box.append(row("", "없음"), ...tracks.map((t) => row(t.path, t.name, trackUrl(t))));
501
+ return box;
502
+ }
503
+
504
+ // 서버가 곡 주소를 주면 그걸, 아니면 올린 곡만 /api/uploads 로 들을 수 있다
505
+ const trackUrl = (t: Track) => t.url ?? (/\/uploads\/[^/]+$/.test(t.path) ? `/api/uploads/${encodeURIComponent(t.path.split("/").pop()!)}` : undefined);
506
+
507
+ function toggle(url: string, btn: HTMLButtonElement): void {
508
+ player ??= new Audio();
509
+ document.querySelectorAll("button.icon-btn svg").forEach((s) => s.replaceWith(icon("play", 16)));
510
+ if (playing === url) {
511
+ player.pause();
512
+ playing = "";
513
+ return;
514
+ }
515
+ player.src = url;
516
+ void player.play().catch(() => (playing = ""));
517
+ playing = url;
518
+ btn.replaceChildren(icon("pause", 16));
519
+ player.onended = () => {
520
+ playing = "";
521
+ btn.replaceChildren(icon("play", 16));
522
+ };
523
+ }
524
+
525
+ async function upload(file: File): Promise<{ name: string; path: string }> {
526
+ const r = await fetch(`/api/upload?name=${encodeURIComponent(file.name)}`, { method: "POST", body: file });
527
+ if (!r.ok) throw new Error(`올리기 실패: ${file.name}`);
528
+ return (await r.json()) as { name: string; path: string };
529
+ }
530
+
531
+ function filePicker(label: string, accept: string, multiple: boolean, onFiles: (fs: File[]) => void): HTMLElement {
532
+ const input = h("input", { type: "file", accept, multiple, class: "sr", onchange: () => onFiles(Array.from(input.files ?? [])) });
533
+ return h("label", { class: "pick" }, input, ...iconLabel("upload", label, 16));
534
+ }
535
+
536
+ async function addTracks(files: File[]): Promise<void> {
537
+ for (const f of files) {
538
+ const r = await upload(f);
539
+ D.music = r.path;
540
+ }
541
+ save();
542
+ tracks = await api<Track[]>("/api/music").catch(() => tracks);
543
+ render();
544
+ }
545
+
546
+ async function addAssets(files: File[]): Promise<void> {
547
+ try {
548
+ for (const f of files) D.assets.push((await upload(f)).path);
549
+ save();
550
+ assets = await api<Asset[]>("/api/assets").catch(() => assets);
551
+ } catch (e) {
552
+ error = (e as Error).message;
553
+ }
554
+ render();
555
+ }
556
+
557
+ function assetBox(): HTMLElement {
558
+ const drop = h("div", { class: "dropzone", tabindex: "-1" },
559
+ h("span", { class: "drop-icons" }, icon("image", 22), icon("video", 22), icon("caption", 22)),
560
+ filePicker("올리기", "image/*,video/*,.txt,.json,.lrc", true, (fs) => void addAssets(fs)),
561
+ );
562
+ drop.addEventListener("dragover", (e) => {
563
+ e.preventDefault();
564
+ drop.classList.add("over");
565
+ });
566
+ drop.addEventListener("dragleave", () => drop.classList.remove("over"));
567
+ drop.addEventListener("drop", (e) => {
568
+ e.preventDefault();
569
+ drop.classList.remove("over");
570
+ void addAssets(Array.from(e.dataTransfer?.files ?? []).filter((f) => /^(image|video)\//.test(f.type) || /\.(txt|json|lrc)$/i.test(f.name)));
571
+ });
572
+ const list = assets.length
573
+ ? h("div", { class: "asset-grid" }, ...assets.map((a) => {
574
+ const on = D.assets.includes(a.path);
575
+ const thumb = a.kind === "video"
576
+ ? h("video", { src: a.url ? `${a.url}#t=0.5` : undefined, muted: true, preload: "metadata", playsInline: true })
577
+ : h("img", { src: a.url, alt: a.name, loading: "lazy", width: 160, height: 120 });
578
+ return h("button", {
579
+ type: "button", class: "asset", "aria-pressed": String(on), title: a.name,
580
+ onclick: () => {
581
+ D.assets = on ? D.assets.filter((p) => p !== a.path) : [...D.assets, a.path];
582
+ save();
583
+ render();
584
+ },
585
+ }, thumb, h("span", { class: "asset-mark" }, icon(on ? "check" : a.kind === "video" ? "video" : "image", 14)), h("span", { class: "asset-name" }, a.name));
586
+ }))
587
+ : null;
588
+ // 가사(lyrics.txt·json) 같은 글 파일은 그림 목록에 안 나온다 — 고른 것만 이름표로
589
+ const others = D.assets.filter((p) => !assets.some((a) => a.path === p));
590
+ const files = others.length
591
+ ? h("div", { class: "file-chips" }, ...others.map((p) => h("span", { class: "file-chip" }, icon("caption", 14), h("span", {}, p.split("/").pop()!.replace(/^[a-z0-9]+-/, "")),
592
+ h("button", { type: "button", class: "icon-btn", "aria-label": "빼기", onclick: () => { D.assets = D.assets.filter((x) => x !== p); save(); render(); } }, icon("close", 14)))))
593
+ : null;
594
+ return h("div", { class: "assets" }, drop, files, list);
595
+ }
596
+
597
+ function modelPicker(): HTMLElement {
598
+ return h("div", { class: "models", role: "radiogroup", "aria-label": "장면 쓰는 모델" },
599
+ ...MODELS.map((m) => h("label", { class: "model" },
600
+ h("input", { type: "radio", name: "model", value: m.id, checked: D.model === m.id, onchange: () => { D.model = m.id; D.modelPicked = true; save(); } }),
601
+ h("span", { class: "model-name" }, m.name), h("span", { class: "model-by" }, m.by))));
602
+ }
603
+
604
+ // 화면 자막 켜고 끄기 — 들릴 말(내레이션)은 꺼도 srt·vtt 트랙으로 나간다. 다시 만들지 않고 결과 단계에서 반대 판을 또 구울 수 있다
605
+ function captionPicker(): HTMLElement {
606
+ const base = capMode(tpl(D.purpose)?.captions);
607
+ const now = D.captions || (base === "on" ? "on" : "off");
608
+ const pick = (v: "on" | "off") => () => { D.captions = v === (base === "on" ? "on" : "off") ? "" : v; save(); render(); };
609
+ const hint = base === "none" && !D.captions ? "이 용도는 기본으로 자막이 없다 — 켜면 내레이션을 써서 화면에 얹는다" : now === "on" ? "들릴 말을 화면 아래 판에 얹는다(두 줄까지) · srt·vtt 도 같이" : "화면엔 없이 srt·vtt 트랙만";
610
+ return h("div", { class: "cap-pick" },
611
+ h("div", { class: "chips", role: "radiogroup", "aria-label": "화면 자막" },
612
+ h("button", { type: "button", class: "chip", role: "radio", "aria-pressed": String(now === "on"), "aria-checked": String(now === "on"), onclick: pick("on") }, icon("caption", 16), h("span", {}, "켜기")),
613
+ h("button", { type: "button", class: "chip", role: "radio", "aria-pressed": String(now === "off"), "aria-checked": String(now === "off"), onclick: pick("off") }, h("span", {}, "끄기"))),
614
+ h("p", { class: "dim cap-hint" }, hint));
615
+ }
616
+
617
+ function needsMusic(purpose: Choice | null | undefined): boolean {
618
+ return tpl(purpose)?.music === "required";
619
+ }
620
+
621
+ function renderInputFoot(): void {
622
+ const foot = document.getElementById("input-foot");
623
+ if (foot) foot.replaceWith(inputFoot());
624
+ }
625
+
626
+ function inputFoot(): HTMLElement {
627
+ const ok = !!D.request.trim();
628
+ return h("footer", { class: "foot", id: "input-foot" },
629
+ h("button", { type: "button", class: "ghost", onclick: () => go("theme") }, ...iconLabel("back", "테마")),
630
+ h("button", { type: "button", class: "primary", disabled: !ok, onclick: () => go("plan") }, h("span", {}, "계획 보기"), icon("arrow")),
631
+ );
632
+ }
633
+
634
+ function viewInput(): HTMLElement {
635
+ const req = h("textarea", {
636
+ id: "request", rows: 4, value: D.request, placeholder: EXAMPLES[D.purpose ?? "auto"] ?? "", "aria-label": "요청",
637
+ oninput: () => { D.request = req.value; save(); renderInputFoot(); renderRail("input"); },
638
+ onkeydown: (e: KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && D.request.trim()) go("plan"); },
639
+ });
640
+ queueMicrotask(() => { if (!D.request) req.focus(); });
641
+ const musicNeed = needsMusic(D.purpose) ? h("span", { class: "need" }, "필요") : null;
642
+ return pane("input",
643
+ head(3, "재료", chosen()),
644
+ h("div", { class: "input-grid" },
645
+ h("div", { class: "col" },
646
+ block("요청", [req]),
647
+ block("모델", [modelPicker()]),
648
+ block("화면 자막", [captionPicker()])),
649
+ h("div", { class: "col" },
650
+ block("곡", [trackList(), filePicker("곡 올리기", "audio/*", false, (fs) => void addTracks(fs))], musicNeed),
651
+ block("자산", [assetBox()], D.assets.length ? h("span", { class: "count" }, `${D.assets.length}`) : null)),
652
+ ),
653
+ inputFoot(),
654
+ );
655
+ }
656
+
657
+ // ---- 04 계획
658
+
659
+ const ROLE_TINT: Record<string, string> = { drop: "t-drop", hook: "t-edge", end: "t-edge", brand: "t-edge", seam: "t-seam" };
660
+
661
+ function sceneBar(scenes: Scene[], total: number, big = false): HTMLElement {
662
+ const bar = h("div", { class: `scene-bar${big ? " big" : ""}` }, ...scenes.map((s) => {
663
+ const cell = h("div", { class: `cell ${ROLE_TINT[s.role] ?? ""}`, title: `${s.id} · ${sec(s.from)}–${sec(s.from + s.dur)}` },
664
+ h("b", {}, s.id), big ? h("span", {}, sec(s.dur)) : null);
665
+ cell.style.flexGrow = String(s.dur / total);
666
+ return cell;
667
+ }));
668
+ if (!big) return bar;
669
+ const step = total > 90 ? 30 : total > 40 ? 15 : total > 20 ? 5 : 2;
670
+ const axis = h("div", { class: "axis", "aria-hidden": "true" });
671
+ for (let t = 0; t <= total + 1e-6; t += step) {
672
+ const tick = h("span", {}, `${t}`);
673
+ tick.style.left = `${(t / total) * 100}%`;
674
+ axis.append(tick);
675
+ }
676
+ return h("div", { class: "scene-chart" }, bar, axis);
677
+ }
678
+
679
+ function facts(p: Plan): HTMLElement {
680
+ const track = tracks.find((t) => t.path === D.music);
681
+ const model = MODELS.find((m) => m.id === D.model);
682
+ const rows: [string, string][] = [
683
+ ["화면", `${p.aspect} · ${p.width}×${p.height}`],
684
+ ["길이", sec(p.duration)],
685
+ ["스타일", p.style],
686
+ ["자막", CAPTIONS[D.captions || p.captions] ?? p.captions],
687
+ ["결과", p.deliverables.join(" · ")],
688
+ ["곡", track?.name ?? "없음"],
689
+ ["자산", D.assets.length ? `${D.assets.length}개` : "없음"],
690
+ ["테마", themeOf(themeId())?.name ?? "자동"],
691
+ ["모델", model?.name ?? D.model],
692
+ ];
693
+ return h("dl", { class: "facts" }, ...rows.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)]));
694
+ }
695
+
696
+ const themeId = () => (D.theme === "auto" ? (D.themePick ?? "auto") : (D.theme ?? "auto"));
697
+
698
+ async function loadSuggest(): Promise<void> {
699
+ const key = `${D.purpose}\u0000${D.request}`;
700
+ if (D.theme !== "auto" || suggest?.key === key) return;
701
+ suggest = { key, ids: [], loading: true };
702
+ try {
703
+ const r = await api<{ candidates: { id: string }[] }>("/api/themes/suggest", { method: "POST", body: JSON.stringify({ request: D.request, purpose: D.purpose ?? "auto" }) });
704
+ if (suggest.key === key) suggest.ids = r.candidates.map((c) => c.id).filter((id) => themeOf(id)).slice(0, 3);
705
+ } catch {
706
+ // 서버에 아직 후보 길이 없으면 테마는 "auto" 로 넘기고 서버가 정한다
707
+ }
708
+ if (suggest.key === key) suggest.loading = false;
709
+ if (D.themePick && !suggest.ids.includes(D.themePick)) D.themePick = null;
710
+ if (readUrl().step === "plan") render();
711
+ }
712
+
713
+ async function loadPlan(purpose: Choice): Promise<void> {
714
+ const key = `${purpose}\u0000${themeId()}\u0000${D.request}`;
715
+ if (plan && planKey === key) return;
716
+ planKey = key;
717
+ plan = null;
718
+ try {
719
+ const p = await api<Plan>("/api/plan", { method: "POST", body: JSON.stringify({ request: D.request, purpose, theme: themeId() }) });
720
+ if (planKey === key) plan = p;
721
+ } catch (e) {
722
+ error = (e as Error).message;
723
+ }
724
+ if (readUrl().step === "plan") render();
725
+ }
726
+
727
+ function viewPlan(): HTMLElement {
728
+ const purpose = D.purpose ?? "auto";
729
+ void loadPlan(purpose);
730
+ void loadSuggest();
731
+ if (!plan) {
732
+ return pane("plan", head(4, "계획"), h("div", { class: "plan-grid" }, h("div", { class: "skeleton block-skel" }), h("div", { class: "skeleton block-skel short" })));
733
+ }
734
+ const p = plan;
735
+ const t = tpl(p.purpose);
736
+ // 곡을 안 골랐어도 계획이 곡 모음에서 정했거나(answers.music) 곡 질문(생성·없음)이 떠 있으면 막지 않는다
737
+ const musicMissing = needsMusic(p.purpose) && !D.music && !p.answers?.music && !(p.asks ?? []).some((q) => q.id === "music");
738
+ const judged = purpose === "auto"
739
+ ? h("div", { class: "judged" }, ...[{ purpose: p.purpose, p: p.confidence }, ...p.alternatives].slice(0, 4).map((a, i) =>
740
+ h("button", { type: "button", class: "chip", "aria-pressed": String(i === 0), onclick: () => { D.purpose = a.purpose; save(); plan = null; render(); } },
741
+ tpl(a.purpose)?.label ?? a.purpose, h("span", { class: "p" }, `${Math.round(a.p * 100)}%`))))
742
+ : null;
743
+ const asks = (p.questions ?? []).filter((q) => q.id !== "music" || !D.music);
744
+ const unpicked = (p.asks ?? []).some((q) => !pick(q));
745
+ const auto = D.theme === "auto";
746
+ const cands = auto ? (suggest?.ids ?? []).map((id) => themeOf(id)).filter((t): t is Theme => !!t) : [];
747
+ const needPick = cands.length > 0 && !D.themePick;
748
+ const candBox = !auto
749
+ ? null
750
+ : suggest?.loading
751
+ ? h("section", { class: "cands" }, h("h2", {}, "테마 후보"), h("div", { class: "themes three" }, ...[0, 1, 2].map(() => h("div", { class: "theme skeleton", "aria-hidden": "true" }))))
752
+ : cands.length
753
+ ? h("section", { class: "cands" }, h("h2", {}, "테마 후보"),
754
+ h("div", { class: "themes three" }, ...cands.map((t) => themeCard(t, () => { D.themePick = t.id; save(); plan = null; render(); }, D.themePick === t.id))))
755
+ : null;
756
+ return pane("plan",
757
+ head(4, "계획", h("span", { class: "plan-title" }, p.title || D.request)),
758
+ judged,
759
+ candBox,
760
+ h("div", { class: "plan-grid" },
761
+ h("div", { class: "plan-main" },
762
+ h("div", { class: "plan-stage" }, frame(t, p.aspect)),
763
+ sceneBar(p.scenes, p.duration, true),
764
+ h("ol", { class: "scene-rows" }, ...p.scenes.map((s) => h("li", {},
765
+ h("span", { class: `dot ${ROLE_TINT[s.role] ?? ""}` }),
766
+ h("span", { class: "sid" }, s.id),
767
+ h("span", { class: "st" }, `${sec(s.from)}–${sec(s.from + s.dur)}`),
768
+ h("span", { class: "si" }, s.idea))))),
769
+ h("aside", { class: "plan-side" },
770
+ facts(p),
771
+ p.gen.length
772
+ ? h("section", { class: "gen" }, h("h2", {}, "만들 그림·영상"), h("ul", {}, ...p.gen.map((g) => h("li", {}, icon(g.kind === "vid" ? "video" : "image", 16), h("span", {}, g.id), g.scene ? h("span", { class: "dim" }, g.scene) : null))))
773
+ : null,
774
+ ...asks.map((q) => h("p", { class: "ask" }, icon("warn", 16), h("span", {}, q.ask))),
775
+ musicMissing ? h("p", { class: "ask" }, icon("music", 16), h("span", {}, "곡 필요"), h("button", { type: "button", class: "ghost small", onclick: () => go("input") }, "고르기")) : 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!)))),
782
+ ),
783
+ ),
784
+ h("footer", { class: "foot" },
785
+ h("button", { type: "button", class: "ghost", onclick: () => go("input") }, ...iconLabel("back", "재료")),
786
+ h("button", { type: "button", class: "primary", disabled: musicMissing || needPick || unpicked, onclick: start }, h("span", {}, musicMissing ? "곡이 필요하다" : needPick ? "테마를 고른다" : unpicked ? "없는 능력을 고른다" : "만들기"), icon("arrow"))),
787
+ );
788
+ }
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
+
795
+ async function start(e: Event): Promise<void> {
796
+ const btn = e.currentTarget as HTMLButtonElement;
797
+ btn.disabled = true;
798
+ try {
799
+ const job = await api<Job>("/api/jobs", {
800
+ method: "POST",
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)) }),
802
+ });
803
+ current = job;
804
+ go("job", { job: job.id });
805
+ } catch (err) {
806
+ error = (err as Error).message;
807
+ btn.disabled = false;
808
+ render();
809
+ }
810
+ }
811
+
812
+ // ---- 05 만들기
813
+
814
+ function follow(id: string): void {
815
+ stream?.close();
816
+ logLines = [];
817
+ current = null;
818
+ stream = new EventSource(`/api/jobs/${id}/events`);
819
+ stream.addEventListener("state", (e) => {
820
+ current = JSON.parse((e as MessageEvent).data) as Job;
821
+ if (readUrl().job === current.id) {
822
+ renderRail("job");
823
+ renderJob();
824
+ }
825
+ });
826
+ stream.addEventListener("log", (e) => {
827
+ logLines.push(JSON.parse((e as MessageEvent).data) as string);
828
+ if (logLines.length > 400) logLines.splice(0, logLines.length - 400);
829
+ const pre = document.getElementById("log");
830
+ if (pre) {
831
+ const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20;
832
+ pre.textContent = logLines.join("\n");
833
+ if (stick) pre.scrollTop = pre.scrollHeight;
834
+ }
835
+ });
836
+ stream.addEventListener("error", () => {
837
+ if (!current) {
838
+ error = "이 작업을 찾지 못했다";
839
+ renderJob();
840
+ }
841
+ });
842
+ }
843
+
844
+ let shownPreview = "";
845
+ let shownVideo = "";
846
+ // 고르고 있는 장면과 그 고치기 자리
847
+ // stale — 그 일이 돌았다: 끝나면 고른 패턴·내용이 바뀌었을 수 있어 다시 받는다
848
+ let picked: { job: string; id: string; detail: SceneDetail | null; error: string; stale?: boolean } | null = null;
849
+ // 결과 화면에서 자막을 반대로 구운 판(files.alt)을 틀고 있나
850
+ let showAlt = false;
851
+
852
+ // iframe 을 DOM 에서 떼었다 붙이면 다시 읽혀 보던 시각이 날아간다 — 틀은 일마다 한 번 짓고 칸만 갈아 끼운다
853
+ function jobFrame(id: string) {
854
+ const view = $<HTMLElement>("#view");
855
+ let root = view.querySelector<HTMLElement>(`.pane[data-step="job"]`);
856
+ if (!root || root.dataset.job !== id) {
857
+ shownPreview = shownVideo = "";
858
+ showAlt = false;
859
+ root = pane("job",
860
+ head(5, "만들기", h("span", { class: "plan-title job-title" })),
861
+ h("div", { class: "job-grid" },
862
+ h("div", { class: "job-stage" }, h("div", { class: "screen" })),
863
+ h("aside", { class: "job-side" }, h("ol", { class: "phases" }), h("div", { class: "job-status" }), h("div", { class: "job-acts" }))),
864
+ h("section", { class: "job-scenes" }),
865
+ h("details", { class: "job-log" }, h("summary", {}, "기록"), h("pre", { class: "log", id: "log" })),
866
+ );
867
+ root.dataset.job = id;
868
+ view.replaceChildren(root);
869
+ if (matchMedia("(min-width: 1024px)").matches) (root.querySelector(".job-log") as HTMLDetailsElement).open = true;
870
+ }
871
+ const q = <T extends HTMLElement>(s: string) => root!.querySelector(s) as T;
872
+ return { title: q(".job-title"), screen: q(".screen"), phases: q(".phases"), status: q(".job-status"), acts: q(".job-acts"), scenes: q(".job-scenes"), log: q(".job-log") as HTMLDetailsElement };
873
+ }
874
+
875
+ function renderJob(): void {
876
+ const id = readUrl().job;
877
+ if (!id) return;
878
+ if (!current || current.id !== id) {
879
+ const f = jobFrame(id);
880
+ f.status.replaceChildren(error ? h("p", { class: "err" }, error) : h("p", { class: "dim" }, "불러오는 중"));
881
+ return;
882
+ }
883
+ const j = current;
884
+ const f = jobFrame(j.id);
885
+ const w = j.kf?.width ?? j.plan?.width ?? 1920;
886
+ const ht = j.kf?.height ?? j.plan?.height ?? 1080;
887
+ const file = (p: string) => `/api/jobs/${j.id}/files/${p}`;
888
+ const done = j.phase === "done" && !!j.files.video;
889
+ const at = PHASES.findIndex(([k]) => k === j.phase);
890
+
891
+ f.title.textContent = j.input.request;
892
+ f.screen.style.aspectRatio = `${w} / ${ht}`;
893
+ f.screen.style.setProperty("--ar", String(w / ht));
894
+ const shown = showAlt && j.files.alt ? j.files.alt : j.files.video;
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;
900
+ shownPreview = "";
901
+ f.screen.classList.remove("studio");
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}`;
907
+ shownVideo = "";
908
+ f.screen.classList.add("studio");
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" }));
912
+ } else if (!done && !j.previewUrl && !shownPreview) {
913
+ const want = j.running ? "run" : j.files.cover ? "cover" : "idle";
914
+ if (f.screen.dataset.empty !== want) {
915
+ f.screen.dataset.empty = want;
916
+ f.screen.replaceChildren(want === "cover"
917
+ ? h("img", { src: file(j.files.cover!), alt: "" })
918
+ : h("div", { class: "screen-wait" }, want === "run" ? h("span", { class: "pulse" }) : icon(j.phase === "failed" ? "warn" : "video", 28)));
919
+ }
920
+ }
921
+
922
+ f.phases.replaceChildren(...PHASES.map(([k, label], i) => {
923
+ const state = j.phase === "done" || i < at ? "done" : k === j.phase ? (j.running ? "now" : "stop") : "todo";
924
+ const pct = k === "render" && state === "now" ? h("span", { class: "pct" }, `${Math.round(j.progress * 100)}%`) : null;
925
+ return h("li", { "data-state": state },
926
+ h("span", { class: "pm" }, state === "done" ? icon("check", 14) : null),
927
+ h("span", { class: "label" }, label), pct,
928
+ k === "render" && state === "now" ? h("span", { class: "progress" }, bar(j.progress)) : null);
929
+ }));
930
+
931
+ f.status.replaceChildren(
932
+ ...(j.phase === "done" ? [h("p", { class: `verdict ${j.checkPassed ? "ok" : "bad"}` }, icon(j.checkPassed ? "check" : "warn", 16), j.checkPassed ? "검사 통과" : "검사에 걸림")] : []),
933
+ ...(j.phase === "waiting" ? [h("p", { class: "verdict bad" }, icon("warn", 16), "정할 것이 있다 · 기록")] : []),
934
+ ...(j.phase === "failed" && j.error ? [h("p", { class: "err" }, j.error)] : []),
935
+ );
936
+
937
+ // 자막은 다시 만들지 않고 켜고 끈다 — 장면은 그대로, 반대 판을 옆 파일로 굽는다
938
+ const projOn = capMode(j.kf?.captions) === "on";
939
+ const altLabel = projOn ? "자막 없음" : "자막";
940
+ const doneActs: (HTMLElement | null)[] = done
941
+ ? [
942
+ j.files.alt
943
+ ? h("div", { class: "chips", role: "radiogroup", "aria-label": "볼 판" },
944
+ h("button", { type: "button", class: "chip", "aria-pressed": String(!showAlt), onclick: () => { showAlt = false; renderJob(); } }, "원본"),
945
+ h("button", { type: "button", class: "chip", "aria-pressed": String(showAlt), onclick: () => { showAlt = true; renderJob(); } }, icon("caption", 16), h("span", {}, altLabel)))
946
+ : null,
947
+ h("a", { class: "primary link", href: `${file(j.files.video!)}?download`, download: "" }, icon("download"), h("span", {}, "MP4")),
948
+ j.files.alt ? h("a", { class: "ghost link", href: `${file(j.files.alt)}?download`, download: "" }, icon("download"), h("span", {}, `MP4 · ${altLabel}`)) : null,
949
+ j.files.cover ? h("a", { class: "ghost link", href: `${file(j.files.cover)}?download`, download: "" }, icon("image"), h("span", {}, "표지")) : null,
950
+ j.files.check ? h("a", { class: "ghost link", href: file(j.files.check), target: "_blank", rel: "noopener" }, h("span", {}, "검사 JSON")) : null,
951
+ h("button", { type: "button", class: "ghost", disabled: j.running, onclick: () => api(`/api/jobs/${j.id}/render`, { method: "POST" }) }, ...iconLabel("redo", "다시 렌더")),
952
+ h("button", {
953
+ type: "button", class: "ghost", disabled: j.running || !j.files.captions,
954
+ title: j.files.captions ? undefined : "들릴 말(captions.json)이 없다",
955
+ onclick: () => { showAlt = true; void api(`/api/jobs/${j.id}/render`, { method: "POST", body: JSON.stringify({ captions: projOn ? "off" : "on" }) }); },
956
+ }, ...iconLabel("caption", projOn ? "자막 빼고 다시 굽기" : "자막 넣어 다시 굽기")),
957
+ ]
958
+ : j.running
959
+ ? [h("button", { type: "button", class: "ghost", onclick: () => api(`/api/jobs/${j.id}/stop`, { method: "POST" }) }, ...iconLabel("stop", "멈추기"))]
960
+ : [];
961
+ f.acts.replaceChildren(...doneActs.filter((x): x is HTMLElement => !!x));
962
+ if (done) f.log.open = false;
963
+ renderScenes(f.scenes, j);
964
+ }
965
+
966
+ function bar(p: number): HTMLElement {
967
+ const fill = h("span");
968
+ fill.style.transform = `scaleX(${Math.min(1, Math.max(0, p))})`;
969
+ return fill;
970
+ }
971
+
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
+ // 장면마다 상태가 동시에 흐르는 타임라인 — 칸을 누르면 그 장면을 고치는 자리가 열린다
978
+ function renderScenes(box: HTMLElement, j: Job): void {
979
+ const scenes = j.kf?.scenes;
980
+ if (!scenes?.length) return void box.replaceChildren();
981
+ if (!box.querySelector(".timeline")) box.replaceChildren(h("h2", {}, "장면"), h("div", { class: "timeline", role: "group", "aria-label": "장면 타임라인" }), h("div", { class: "scene-edit" }));
982
+ const total = scenes.at(-1)!.from + scenes.at(-1)!.dur;
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),
1146
+ );
1147
+ }
1148
+
1149
+ // ---- 지난 작업
1150
+
1151
+ const jobsBox = $<HTMLElement>("#jobs");
1152
+ const jobsBtn = $<HTMLButtonElement>("#jobs-toggle");
1153
+ jobsBtn.replaceChildren(...iconLabel("history", "지난 작업"));
1154
+ jobsBtn.addEventListener("click", async () => {
1155
+ const open = jobsBox.hidden;
1156
+ jobsBox.hidden = !open;
1157
+ jobsBtn.setAttribute("aria-expanded", String(open));
1158
+ if (!open) return;
1159
+ const jobs = await api<Job[]>("/api/jobs").catch(() => [] as Job[]);
1160
+ const label = (j: Job) => PHASES.find(([k]) => k === j.phase)?.[1] ?? (j.phase === "failed" ? "실패" : j.phase === "waiting" ? "정할 것" : j.phase);
1161
+ jobsBox.replaceChildren(...(jobs.length
1162
+ ? jobs.map((j) => {
1163
+ const shape = (j.plan?.height ?? 9) > (j.plan?.width ?? 16) ? "tall" : "wide";
1164
+ return h("a", {
1165
+ class: "job-row", href: `/?job=${j.id}`,
1166
+ onclick: (ev: Event) => { ev.preventDefault(); jobsBox.hidden = true; jobsBtn.setAttribute("aria-expanded", "false"); go("job", { job: j.id }); },
1167
+ },
1168
+ h("span", { class: "job-thumb", "data-shape": shape }, j.files.cover ? h("img", { src: `/api/jobs/${j.id}/files/${j.files.cover}`, alt: "", loading: "lazy", width: 96, height: 54 }) : null),
1169
+ h("span", { class: "job-text" }, h("span", { class: "r" }, j.input.request), h("span", { class: "s" }, `${tpl(j.plan?.purpose)?.label ?? ""} · ${label(j)} · ${new Date(j.createdAt).toLocaleString("ko-KR", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}`)));
1170
+ })
1171
+ : [h("p", { class: "dim" }, "없음")]));
1172
+ });
1173
+ document.addEventListener("keydown", (e) => {
1174
+ if (e.key === "Escape" && !jobsBox.hidden) jobsBtn.click();
1175
+ });
1176
+
1177
+ async function boot(): Promise<void> {
1178
+ render();
1179
+ const [t, m, a, th] = await Promise.all([
1180
+ api<Template[]>("/api/templates").catch((e: Error) => ((error = e.message), [] as Template[])),
1181
+ api<Track[]>("/api/music").catch(() => [] as Track[]),
1182
+ api<Asset[]>("/api/assets").catch(() => [] as Asset[]),
1183
+ // 기본은 승인된 것만 온다 — 검토 중 묶음까지 그리려고 all=1 로 받고 draft 는 아래에서 거른다
1184
+ api<Theme[]>("/api/themes?all=1").catch(() => null),
1185
+ ]);
1186
+ // draft 는 테마 폴더 안 견본에서만 쓴다 — 작업실에는 승인·검토 중만
1187
+ themes = th?.filter((x) => x.status !== "draft") ?? null;
1188
+ themesAll = th ?? [];
1189
+ templates = t;
1190
+ rangeMax = Math.max(60, ...t.map((x) => x.duration.max));
1191
+ tracks = m;
1192
+ assets = a;
1193
+ render();
1194
+ }
1195
+ void boot();