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.
- package/LICENSE +21 -0
- package/NOTICE +148 -0
- package/README.md +92 -2
- package/app/package.json +12 -0
- package/app/server/engine.ts +533 -0
- package/app/server/main.ts +209 -0
- package/app/web/env.d.ts +2 -0
- package/app/web/icons.ts +31 -0
- package/app/web/index.html +21 -0
- package/app/web/main.ts +1195 -0
- package/app/web/style.css +463 -0
- package/bin/hooks.mjs +64 -0
- package/bin/kf.mjs +32 -0
- package/docs/AGENTS.md +286 -0
- package/music/README.md +37 -0
- package/package.json +44 -4
- package/packages/adapters/package.json +36 -0
- package/packages/adapters/src/css.ts +11 -0
- package/packages/adapters/src/gsap.ts +26 -0
- package/packages/adapters/src/index.ts +9 -0
- package/packages/adapters/src/lottie.ts +22 -0
- package/packages/adapters/src/media.ts +59 -0
- package/packages/adapters/src/page.ts +229 -0
- package/packages/adapters/src/project.ts +214 -0
- package/packages/adapters/src/react.ts +36 -0
- package/packages/adapters/src/svelte.ts +22 -0
- package/packages/adapters/src/three.ts +18 -0
- package/packages/adapters/src/vue.ts +27 -0
- package/packages/check/package.json +19 -0
- package/packages/check/src/check.ts +280 -0
- package/packages/check/src/cli.ts +70 -0
- package/packages/check/src/image.ts +146 -0
- package/packages/check/src/index.ts +9 -0
- package/packages/check/src/ocr.swift +37 -0
- package/packages/check/src/probe.ts +328 -0
- package/packages/check/src/report.ts +44 -0
- package/packages/check/src/rules/assets.ts +45 -0
- package/packages/check/src/rules/beats.ts +212 -0
- package/packages/check/src/rules/camera.ts +217 -0
- package/packages/check/src/rules/captions.ts +49 -0
- package/packages/check/src/rules/flash.ts +235 -0
- package/packages/check/src/rules/frames.ts +136 -0
- package/packages/check/src/rules/layout.ts +281 -0
- package/packages/check/src/rules/page.ts +87 -0
- package/packages/check/src/rules/runs.ts +20 -0
- package/packages/check/src/rules/text.ts +326 -0
- package/packages/check/src/scan.ts +240 -0
- package/packages/check/src/serve.ts +67 -0
- package/packages/check/src/types.ts +80 -0
- package/packages/check/src/video.ts +137 -0
- package/packages/cli/package.json +30 -0
- package/packages/cli/src/blur.ts +80 -0
- package/packages/cli/src/browser.ts +203 -0
- package/packages/cli/src/command.ts +44 -0
- package/packages/cli/src/commands/app.ts +25 -0
- package/packages/cli/src/commands/beats.ts +36 -0
- package/packages/cli/src/commands/bench.ts +123 -0
- package/packages/cli/src/commands/blender.ts +293 -0
- package/packages/cli/src/commands/check.ts +25 -0
- package/packages/cli/src/commands/direct.ts +210 -0
- package/packages/cli/src/commands/doctor.ts +30 -0
- package/packages/cli/src/commands/gen.ts +173 -0
- package/packages/cli/src/commands/look.ts +46 -0
- package/packages/cli/src/commands/lyrics.ts +157 -0
- package/packages/cli/src/commands/make.ts +1121 -0
- package/packages/cli/src/commands/music.ts +78 -0
- package/packages/cli/src/commands/narrate.ts +238 -0
- package/packages/cli/src/commands/new.ts +185 -0
- package/packages/cli/src/commands/preview.ts +43 -0
- package/packages/cli/src/commands/render.ts +250 -0
- package/packages/cli/src/commands/series.ts +110 -0
- package/packages/cli/src/commands/setup.ts +102 -0
- package/packages/cli/src/commands/sheet.ts +114 -0
- package/packages/cli/src/commands/skill.ts +43 -0
- package/packages/cli/src/commands/still.ts +40 -0
- package/packages/cli/src/commands/theme.ts +172 -0
- package/packages/cli/src/commands/transitions.ts +40 -0
- package/packages/cli/src/deps.ts +122 -0
- package/packages/cli/src/ffmpeg.ts +81 -0
- package/packages/cli/src/index.ts +8 -0
- package/packages/cli/src/kf.ts +64 -0
- package/packages/cli/src/lock.ts +72 -0
- package/packages/cli/src/mix.ts +131 -0
- package/packages/cli/src/out.ts +13 -0
- package/packages/cli/src/pool.ts +34 -0
- package/packages/cli/src/segments.ts +402 -0
- package/packages/cli/src/serve.ts +124 -0
- package/packages/clock/package.json +14 -0
- package/packages/clock/src/index.ts +25 -0
- package/packages/clock/src/install.ts +351 -0
- package/packages/clock/src/types.ts +45 -0
- package/packages/core/package.json +9 -0
- package/packages/core/src/beats.ts +283 -0
- package/packages/core/src/ease.ts +87 -0
- package/packages/core/src/frame.ts +99 -0
- package/packages/core/src/index.ts +27 -0
- package/packages/core/src/project.ts +157 -0
- package/packages/core/src/rand.ts +56 -0
- package/packages/core/src/spring.ts +52 -0
- package/packages/core/src/style.ts +100 -0
- package/packages/core/src/time.ts +88 -0
- package/packages/director/package.json +23 -0
- package/packages/director/src/assemble.ts +361 -0
- package/packages/director/src/brief.ts +122 -0
- package/packages/director/src/catalog.ts +49 -0
- package/packages/director/src/content.ts +343 -0
- package/packages/director/src/fallback.ts +58 -0
- package/packages/director/src/index.ts +13 -0
- package/packages/director/src/pattern.ts +90 -0
- package/packages/director/src/patterns/fill/cells.ts +115 -0
- package/packages/director/src/patterns/formula/stack.ts +93 -0
- package/packages/director/src/patterns/util.ts +58 -0
- package/packages/director/src/scene.ts +62 -0
- package/packages/director/src/select.ts +163 -0
- package/packages/director/src/transitions.ts +115 -0
- package/packages/gen/PROVIDERS.md +90 -0
- package/packages/gen/package.json +9 -0
- package/packages/gen/src/ask.ts +132 -0
- package/packages/gen/src/audio.ts +91 -0
- package/packages/gen/src/blender.ts +54 -0
- package/packages/gen/src/capabilities.ts +151 -0
- package/packages/gen/src/generate.ts +206 -0
- package/packages/gen/src/image/codex.ts +117 -0
- package/packages/gen/src/image/og.ts +133 -0
- package/packages/gen/src/index.ts +26 -0
- package/packages/gen/src/keys.ts +86 -0
- package/packages/gen/src/media.ts +150 -0
- package/packages/gen/src/music/elevenlabs.ts +41 -0
- package/packages/gen/src/music/index.ts +120 -0
- package/packages/gen/src/music/library.ts +73 -0
- package/packages/gen/src/music/lyria.ts +52 -0
- package/packages/gen/src/music/types.ts +28 -0
- package/packages/gen/src/plan.ts +106 -0
- package/packages/gen/src/registry.ts +54 -0
- package/packages/gen/src/seam.ts +103 -0
- package/packages/gen/src/tts/dashscope.ts +64 -0
- package/packages/gen/src/tts/elevenlabs.ts +84 -0
- package/packages/gen/src/tts/gemini.ts +73 -0
- package/packages/gen/src/tts/index.ts +241 -0
- package/packages/gen/src/tts/local.ts +42 -0
- package/packages/gen/src/tts/types.ts +44 -0
- package/packages/gen/src/types.ts +90 -0
- package/packages/gen/src/video/fal.ts +140 -0
- package/packages/gen/src/video/h3.ts +112 -0
- package/packages/gen/src/video/http.ts +44 -0
- package/packages/gen/src/video/ltx.ts +60 -0
- package/packages/gen/src/video/minimax.ts +55 -0
- package/packages/gen/src/video/veo.ts +71 -0
- package/packages/jizura/package.json +16 -0
- package/packages/jizura/src/deny.ts +73 -0
- package/packages/jizura/src/index.ts +7 -0
- package/packages/jizura/src/install.ts +269 -0
- package/packages/jizura/src/layer.ts +325 -0
- package/packages/jizura/src/lyrics.ts +81 -0
- package/packages/jizura/src/pin.ts +8 -0
- package/packages/jizura/src/vendor.ts +140 -0
- package/packages/jizura/src/vite.ts +63 -0
- package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
- package/packages/jizura/vendor/NOTICE +43 -0
- package/packages/jizura/vendor/engine.json +47 -0
- package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
- package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
- package/packages/jizura/vendor/fonts/fonts.json +52 -0
- package/packages/jizura/vendor/jizura_engine.js +35024 -0
- package/packages/motion/README.md +153 -0
- package/packages/motion/package.json +30 -0
- package/packages/motion/src/camera.ts +97 -0
- package/packages/motion/src/captions.ts +487 -0
- package/packages/motion/src/enter.ts +128 -0
- package/packages/motion/src/hangul.ts +129 -0
- package/packages/motion/src/impact.ts +100 -0
- package/packages/motion/src/index.ts +17 -0
- package/packages/motion/src/moves/brushWipe.ts +103 -0
- package/packages/motion/src/moves/cloneArray.ts +111 -0
- package/packages/motion/src/moves/curtains.ts +81 -0
- package/packages/motion/src/moves/dress.ts +191 -0
- package/packages/motion/src/moves/echoStack.ts +70 -0
- package/packages/motion/src/moves/fit.ts +70 -0
- package/packages/motion/src/moves/flood.ts +61 -0
- package/packages/motion/src/moves/ghostStack.ts +88 -0
- package/packages/motion/src/moves/gridFloor.ts +100 -0
- package/packages/motion/src/moves/imageSwarm.ts +113 -0
- package/packages/motion/src/moves/index.ts +38 -0
- package/packages/motion/src/moves/irisBlades.ts +113 -0
- package/packages/motion/src/moves/karaokeLine.ts +108 -0
- package/packages/motion/src/moves/ledBoard.ts +94 -0
- package/packages/motion/src/moves/liquidTab.ts +72 -0
- package/packages/motion/src/moves/markerWrite.ts +63 -0
- package/packages/motion/src/moves/menu.ts +138 -0
- package/packages/motion/src/moves/morphBox.ts +96 -0
- package/packages/motion/src/moves/neonSign.ts +78 -0
- package/packages/motion/src/moves/panelPop.ts +78 -0
- package/packages/motion/src/moves/paperTear.ts +75 -0
- package/packages/motion/src/moves/path.ts +64 -0
- package/packages/motion/src/moves/pathText.ts +107 -0
- package/packages/motion/src/moves/pointSphere.ts +109 -0
- package/packages/motion/src/moves/ransomLetters.ts +73 -0
- package/packages/motion/src/moves/shapeMorph.ts +143 -0
- package/packages/motion/src/moves/slamWords.ts +96 -0
- package/packages/motion/src/moves/sparkTrail.ts +130 -0
- package/packages/motion/src/moves/speedLines.ts +91 -0
- package/packages/motion/src/moves/splitFlap.ts +149 -0
- package/packages/motion/src/moves/stepWords.ts +128 -0
- package/packages/motion/src/moves/strikeOut.ts +98 -0
- package/packages/motion/src/moves/sunburst.ts +58 -0
- package/packages/motion/src/moves/tickerRows.ts +91 -0
- package/packages/motion/src/moves/tokenPicker.ts +115 -0
- package/packages/motion/src/moves/truchet.ts +85 -0
- package/packages/motion/src/moves/wordWall.ts +78 -0
- package/packages/motion/src/moves/worldRoll.ts +32 -0
- package/packages/motion/src/particles.ts +102 -0
- package/packages/motion/src/react/index.tsx +21 -0
- package/packages/motion/src/react/parts.tsx +431 -0
- package/packages/motion/src/react/time.tsx +97 -0
- package/packages/motion/src/text.ts +103 -0
- package/packages/motion/src/transition.ts +82 -0
- package/packages/motion/src/transition3d/cards.ts +65 -0
- package/packages/motion/src/transition3d/chain.ts +84 -0
- package/packages/motion/src/transition3d/dive.ts +181 -0
- package/packages/motion/src/transition3d/fold.ts +110 -0
- package/packages/motion/src/transition3d/gl.ts +219 -0
- package/packages/motion/src/transition3d/index.ts +235 -0
- package/packages/motion/src/transition3d/live.ts +70 -0
- package/packages/motion/src/transition3d/portal.ts +115 -0
- package/packages/motion/src/transition3d/react.tsx +29 -0
- package/packages/motion/src/transition3d/rig.ts +600 -0
- package/packages/motion/src/transition3d/shatter.ts +104 -0
- package/packages/motion/src/transition3d/space.ts +173 -0
- package/packages/motion/src/transition3d/whip.ts +78 -0
- package/packages/motion/src/transition3d/world.ts +737 -0
- package/packages/motion/src/transition3d/zoomOut.ts +114 -0
- package/packages/motion/src/transitions2d/catalog.ts +127 -0
- package/packages/motion/src/transitions2d/css.d.ts +2 -0
- package/packages/motion/src/transitions2d/cult-ui/LICENSE.md +9 -0
- package/packages/motion/src/transitions2d/cult-ui/RollingNumber.tsx +43 -0
- package/packages/motion/src/transitions2d/gl-transitions/LICENSE +25 -0
- package/packages/motion/src/transitions2d/gl-transitions/shaders.ts +1931 -0
- package/packages/motion/src/transitions2d/index.ts +2 -0
- package/packages/motion/src/transitions2d/kokonutui/LICENSE +21 -0
- package/packages/motion/src/transitions2d/kokonutui/MatrixText.css +16 -0
- package/packages/motion/src/transitions2d/kokonutui/MatrixText.tsx +94 -0
- package/packages/motion/src/transitions2d/magicui/HyperText.css +15 -0
- package/packages/motion/src/transitions2d/magicui/HyperText.tsx +115 -0
- package/packages/motion/src/transitions2d/magicui/LICENSE.md +21 -0
- package/packages/motion/src/transitions2d/magicui/MorphingText.css +25 -0
- package/packages/motion/src/transitions2d/magicui/MorphingText.tsx +130 -0
- package/packages/motion/src/transitions2d/magicui/WordRotate.tsx +42 -0
- package/packages/motion/src/transitions2d/react.ts +13 -0
- package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.css +39 -0
- package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.tsx +32 -0
- package/packages/motion/src/transitions2d/smoothui/LICENSE +21 -0
- package/packages/motion/src/transitions2d/smoothui/SharedAxisY.tsx +50 -0
- package/packages/motion/src/transitions2d/smoothui/TextMorph.tsx +90 -0
- package/packages/motion/src/transitions2d/smoothui/shaders.ts +223 -0
- package/packages/motion/src/transitions2d/unlumen/LICENSE +21 -0
- package/packages/motion/src/transitions2d/unlumen/ScrambleText.tsx +114 -0
- package/packages/motion/src/transitions2d/vengeance-ui/LICENSE +21 -0
- package/packages/motion/src/transitions2d/vengeance-ui/MorphText.css +69 -0
- package/packages/motion/src/transitions2d/vengeance-ui/MorphText.tsx +45 -0
- package/packages/motion/src/type.ts +131 -0
- package/packages/motion/src/ui.ts +172 -0
- package/packages/patterns/package.json +15 -0
- package/packages/patterns/src/bars/rank.ts +119 -0
- package/packages/patterns/src/before-after/shrink.ts +127 -0
- package/packages/patterns/src/before-after/strike.ts +125 -0
- package/packages/patterns/src/code/terminal.ts +133 -0
- package/packages/patterns/src/compare/bars.ts +142 -0
- package/packages/patterns/src/compare/duel.ts +103 -0
- package/packages/patterns/src/end/lockup.ts +85 -0
- package/packages/patterns/src/end/slam.ts +91 -0
- package/packages/patterns/src/flow/graph.ts +250 -0
- package/packages/patterns/src/hook-number/counter.ts +101 -0
- package/packages/patterns/src/hook-number/roll.ts +179 -0
- package/packages/patterns/src/hook-number/slam.ts +91 -0
- package/packages/patterns/src/index.ts +38 -0
- package/packages/patterns/src/kit.ts +449 -0
- package/packages/patterns/src/limit/gauge.ts +117 -0
- package/packages/patterns/src/list/cards.ts +131 -0
- package/packages/patterns/src/list/cycle.ts +22 -0
- package/packages/patterns/src/media/panels.ts +100 -0
- package/packages/patterns/src/objects.ts +234 -0
- package/packages/patterns/src/quote/karaoke.ts +126 -0
- package/packages/patterns/src/quote/slam.ts +22 -0
- package/packages/patterns/src/screen/focus.ts +85 -0
- package/packages/patterns/src/steps/path.ts +137 -0
- package/packages/patterns/src/steps/stack.ts +111 -0
- package/packages/patterns/src/term-reveal/chips.ts +92 -0
- package/packages/patterns/src/term-reveal/expand.ts +127 -0
- package/packages/patterns/src/timeline/axis.ts +144 -0
- package/packages/patterns/src/title/mask.ts +85 -0
- package/packages/patterns/src/title/slam.ts +23 -0
- package/packages/patterns/src/toggle-sim/switch.ts +92 -0
- package/packages/patterns/src/trend/columns.ts +108 -0
- package/packages/patterns/src/trend/draw.ts +140 -0
- package/packages/patterns/src/types.ts +67 -0
- package/packages/patterns/src/words/moves.ts +42 -0
- package/packages/patterns/src/words/slam.ts +4 -0
- package/packages/plan/package.json +15 -0
- package/packages/plan/src/asks.ts +256 -0
- package/packages/plan/src/brief.ts +383 -0
- package/packages/plan/src/captions.ts +78 -0
- package/packages/plan/src/detect.ts +93 -0
- package/packages/plan/src/index.ts +27 -0
- package/packages/plan/src/layout.ts +176 -0
- package/packages/plan/src/llm.ts +172 -0
- package/packages/plan/src/look.ts +222 -0
- package/packages/plan/src/plan.ts +179 -0
- package/packages/plan/src/request.ts +55 -0
- package/packages/plan/src/series.ts +76 -0
- package/packages/plan/src/studio.ts +53 -0
- package/packages/plan/src/templates.ts +42 -0
- package/packages/plan/src/themes.ts +48 -0
- package/packages/plan/src/types.ts +157 -0
- package/packages/studio/package.json +12 -0
- package/packages/studio/src/index.ts +1 -0
- package/packages/studio/src/plugin.ts +236 -0
- package/packages/studio/src/ui/main.ts +503 -0
- package/packages/studio/src/ui/studio.css +165 -0
- package/packages/studio/src/ui/timeline.ts +317 -0
- package/packages/themes/kit/THEME-MAKE.md +68 -0
- package/packages/themes/kit/THEME.md +30 -0
- package/packages/themes/kit/briefs/og-intro-15s/brief.json +16 -0
- package/packages/themes/kit/briefs/og-intro-15s/facts.ts +29 -0
- package/packages/themes/kit/briefs/og-intro-15s/logo.svg +12 -0
- package/packages/themes/kit/effects.ts +5 -0
- package/packages/themes/kit/sample/index.html +11 -0
- package/packages/themes/kit/sample/main.ts +9 -0
- package/packages/themes/kit/sample/scenes/scene.ts +15 -0
- package/packages/themes/package.json +19 -0
- package/packages/themes/src/factory.ts +296 -0
- package/packages/themes/src/fx.ts +14 -0
- package/packages/themes/src/index.ts +114 -0
- package/packages/themes/src/slotkit.ts +79 -0
- package/packages/themes/src/slots.ts +46 -0
- package/packages/themes/src/stage3d.ts +197 -0
- package/packages/themes/src/types.ts +58 -0
- package/presets/kawaii-pastel.json +94 -0
- package/presets/neutral-dark.json +82 -0
- package/presets/og.json +98 -0
- package/series/og-shorts.json +14 -0
- package/skills/kasaframe/SKILL.md +33 -0
- package/templates/_shared/project/index.html +11 -0
- package/templates/_shared/project/lib/react.ts +22 -0
- package/templates/_shared/project/lib/stage.ts +292 -0
- package/templates/_shared/project/main.ts +9 -0
- package/templates/_shared/scenes/default.ts +21 -0
- package/templates/_shared/scenes/drop.ts +33 -0
- package/templates/_shared/scenes/end.ts +26 -0
- package/templates/_shared/scenes/hook.ts +18 -0
- package/templates/_shared/scenes/seam.ts +41 -0
- package/templates/_shared/scenes/step.ts +49 -0
- package/templates/ad/preview.png +0 -0
- package/templates/ad/template.json +151 -0
- package/templates/demo/preview.png +0 -0
- package/templates/demo/template.json +141 -0
- package/templates/explainer/preview.png +0 -0
- package/templates/explainer/template.json +193 -0
- package/templates/launch/preview.png +0 -0
- package/templates/launch/template.json +163 -0
- package/templates/mv/preview.png +0 -0
- package/templates/mv/scenes/default.ts +98 -0
- package/templates/mv/template.json +178 -0
- package/templates/promo/preview.png +0 -0
- package/templates/promo/template.json +185 -0
- package/templates/shorts/preview.png +0 -0
- package/templates/shorts/scenes/hook.ts +37 -0
- package/templates/shorts/template.json +196 -0
- package/templates/square/preview.png +0 -0
- package/templates/square/template.json +133 -0
- package/themes/debug-hud/THEME.md +39 -0
- package/themes/debug-hud/effects.ts +389 -0
- package/themes/debug-hud/sample/assets/og/logo.svg +12 -0
- package/themes/debug-hud/sample/beats.json +1 -0
- package/themes/debug-hud/sample/index.html +11 -0
- package/themes/debug-hud/sample/kf.json +59 -0
- package/themes/debug-hud/sample/lib/hudkit.ts +49 -0
- package/themes/debug-hud/sample/lib/og.ts +29 -0
- package/themes/debug-hud/sample/main.ts +9 -0
- package/themes/debug-hud/sample/scenes/end.ts +62 -0
- package/themes/debug-hud/sample/scenes/gather.ts +67 -0
- package/themes/debug-hud/sample/scenes/models.ts +70 -0
- package/themes/debug-hud/sample/scenes/price.ts +79 -0
- package/themes/debug-hud/sample/style.json +91 -0
- package/themes/debug-hud/slots.ts +99 -0
- package/themes/debug-hud/still.png +0 -0
- package/themes/debug-hud/style.json +91 -0
- package/themes/debug-hud/theme.json +68 -0
- package/themes/og-measure/THEME.md +47 -0
- package/themes/og-measure/effects.ts +432 -0
- package/themes/og-measure/sample/assets/brand/og-logo-full.svg +12 -0
- package/themes/og-measure/sample/assets/brand/og-logo.svg +12 -0
- package/themes/og-measure/sample/assets/data/measure.json +353 -0
- package/themes/og-measure/sample/beats.json +1 -0
- package/themes/og-measure/sample/index.html +11 -0
- package/themes/og-measure/sample/kf.json +66 -0
- package/themes/og-measure/sample/lib/data.ts +62 -0
- package/themes/og-measure/sample/main.ts +9 -0
- package/themes/og-measure/sample/measure.ts +78 -0
- package/themes/og-measure/sample/scenes/end.ts +47 -0
- package/themes/og-measure/sample/scenes/gather.ts +51 -0
- package/themes/og-measure/sample/scenes/models.ts +34 -0
- package/themes/og-measure/sample/scenes/one.ts +39 -0
- package/themes/og-measure/sample/scenes/price.ts +56 -0
- package/themes/og-measure/sample/style.json +97 -0
- package/themes/og-measure/slots.ts +88 -0
- package/themes/og-measure/still.png +0 -0
- package/themes/og-measure/style.json +97 -0
- package/themes/og-measure/theme.json +79 -0
- package/themes/riso-zine/THEME.md +43 -0
- package/themes/riso-zine/effects.ts +321 -0
- package/themes/riso-zine/sample/assets/og/logo.svg +12 -0
- package/themes/riso-zine/sample/beats.json +1 -0
- package/themes/riso-zine/sample/index.html +11 -0
- package/themes/riso-zine/sample/kf.json +59 -0
- package/themes/riso-zine/sample/lib/og.ts +29 -0
- package/themes/riso-zine/sample/lib/shot.ts +86 -0
- package/themes/riso-zine/sample/lib/zine.ts +42 -0
- package/themes/riso-zine/sample/main.ts +9 -0
- package/themes/riso-zine/sample/scenes/end.ts +61 -0
- package/themes/riso-zine/sample/scenes/gather.ts +62 -0
- package/themes/riso-zine/sample/scenes/models.ts +52 -0
- package/themes/riso-zine/sample/scenes/price.ts +82 -0
- package/themes/riso-zine/sample/style.json +99 -0
- package/themes/riso-zine/slots.ts +81 -0
- package/themes/riso-zine/still.png +0 -0
- package/themes/riso-zine/style.json +99 -0
- package/themes/riso-zine/theme.json +75 -0
- package/tools/beats/beats.py +372 -0
- package/tools/beats/requirements.txt +3 -0
- package/tools/lyrics/align.py +164 -0
- package/tools/lyrics/requirements.txt +9 -0
package/app/web/main.ts
ADDED
|
@@ -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();
|