kasaframe 0.0.0-stage → 0.1.1

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