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
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import type { CaptionLook, KfStyle, SafeArea } from "@kasaframe/core";
|
|
2
|
+
|
|
3
|
+
// 자막 — 들릴 말(captions.json)을 장면 시각에 나눠 화면 아래 판에 얹는다.
|
|
4
|
+
// srt·vtt(plan 의 writeCaptions)와 같은 toCues 를 써서 트랙과 화면 자막이 한 마디도 어긋나지 않는다.
|
|
5
|
+
// 이 파일은 Node(트랙 쓰기)와 페이지 양쪽에서 불리므로 DOM 은 mountCaptions 안에서만 만진다.
|
|
6
|
+
|
|
7
|
+
/** kf.json captions — off: 화면엔 없이 srt·vtt 만 · on: 화면에 얹고 srt·vtt 도 · none: 아무것도 */
|
|
8
|
+
export type CaptionMode = "on" | "off" | "none";
|
|
9
|
+
|
|
10
|
+
/** 옛 이름(track·burned)도 받는다. fit 은 on 과 같고 장면을 띠 위로 줄여 올리는 방식만 다르다(captionsPlan) */
|
|
11
|
+
export function captionMode(v: unknown): CaptionMode {
|
|
12
|
+
if (v === "on" || v === "fit" || v === "burned" || v === true) return "on";
|
|
13
|
+
if (v === "off" || v === "track") return "off";
|
|
14
|
+
return "none";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 낱말 하나와 그 시각(영상 전체 기준 초). lyrics.json 꼴({ w, start, end })도 받는다 */
|
|
18
|
+
export interface CaptionWord {
|
|
19
|
+
text?: string;
|
|
20
|
+
from?: number;
|
|
21
|
+
to?: number;
|
|
22
|
+
w?: string;
|
|
23
|
+
start?: number;
|
|
24
|
+
end?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CaptionLine {
|
|
28
|
+
scene?: string;
|
|
29
|
+
from?: number;
|
|
30
|
+
to?: number;
|
|
31
|
+
text: string;
|
|
32
|
+
/** 낱말마다 잰 시각이 있으면 마디를 그 시각 그대로 끊는다 */
|
|
33
|
+
words?: CaptionWord[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Cue {
|
|
37
|
+
from: number;
|
|
38
|
+
to: number;
|
|
39
|
+
text: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Span {
|
|
43
|
+
id: string;
|
|
44
|
+
from: number;
|
|
45
|
+
dur: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 화면 자막 판이 놓일 자리와 크기. 페이지에서 재기 전에(Node 의 srt 쓰기에서도) 같은 값을 내야 해서 글꼴 없이 화면 크기만으로 정한다
|
|
49
|
+
export interface CaptionMetrics {
|
|
50
|
+
fontPx: number;
|
|
51
|
+
lineHeight: number;
|
|
52
|
+
padX: number;
|
|
53
|
+
padY: number;
|
|
54
|
+
/** 판 안 글 한 줄의 최대 폭(px) */
|
|
55
|
+
textMaxPx: number;
|
|
56
|
+
/** 한 줄에 들어가는 폭을 글자 크기 단위(em)로 */
|
|
57
|
+
maxEm: number;
|
|
58
|
+
/** 판 아래 끝의 y(px) — 안전 영역 아래 가장자리에서 gap 만큼 띄운다 */
|
|
59
|
+
bottom: number;
|
|
60
|
+
/** 판 가운데의 x(px) — 안전 영역 가운데 */
|
|
61
|
+
centerX: number;
|
|
62
|
+
/** 자막 띠 위아래 여백(px) */
|
|
63
|
+
gap: number;
|
|
64
|
+
/** 한 마디 줄 수 */
|
|
65
|
+
maxLines: number;
|
|
66
|
+
/** style.json captions.position — 판 가운데 y(px). 있으면 bottom 대신 이 자리 */
|
|
67
|
+
centerY?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 판 높이(px) — 줄 수만큼 */
|
|
71
|
+
export function plateHeight(m: CaptionMetrics, lines: number): number {
|
|
72
|
+
return Math.round(m.fontPx * m.lineHeight * lines + m.padY * 2);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 자막 띠 — 안전 영역 아래쪽에서 장면이 비워 줄 높이(px). 판 + 위아래 여백 */
|
|
76
|
+
export function captionBand(m: CaptionMetrics, lines = m.maxLines): number {
|
|
77
|
+
if (m.centerY === undefined) return plateHeight(m, lines) + m.gap * 2;
|
|
78
|
+
// 판을 정한 자리에 두면 띠는 판 윗변부터 안전 영역 아래 끝까지 — 판이 그 아래(플랫폼 UI 자리)에 있으면 장면은 비켜 줄 것이 없다
|
|
79
|
+
return Math.max(0, Math.round(m.bottom + m.gap - (m.centerY - plateHeight(m, lines) / 2) + m.gap));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function captionMetrics(o: { width: number; height: number; safe?: SafeArea | null; minPx?: number; look?: CaptionLook | null }): CaptionMetrics {
|
|
83
|
+
const safe = o.safe ?? { top: 0, bottom: 0, left: 0, right: 0 };
|
|
84
|
+
const short = Math.min(o.width, o.height);
|
|
85
|
+
const vertical = o.height > o.width;
|
|
86
|
+
// 세로 쇼츠는 폰 한 손 거리에서 읽혀야 한다 — 폭의 8%(1080 폭이면 86px). 가로는 높이의 5%
|
|
87
|
+
const fontPx = Math.max(o.minPx ?? 0, Math.round(o.look?.size ?? (vertical ? o.width * 0.08 : short * 0.05)));
|
|
88
|
+
const padX = Math.round(fontPx * 0.5);
|
|
89
|
+
const padY = Math.round(fontPx * 0.2);
|
|
90
|
+
const gap = Math.round(short * 0.02);
|
|
91
|
+
const room = o.width - safe.left - safe.right - Math.round(short * 0.04);
|
|
92
|
+
// 가로 화면은 한 줄이 너무 길면 눈이 따라가지 못한다 — 화면 폭 2/3 까지
|
|
93
|
+
const plateMax = vertical ? room : Math.min(room, Math.round(o.width * 0.66));
|
|
94
|
+
const textMaxPx = plateMax - padX * 2;
|
|
95
|
+
return {
|
|
96
|
+
fontPx,
|
|
97
|
+
lineHeight: 1.28,
|
|
98
|
+
padX,
|
|
99
|
+
padY,
|
|
100
|
+
textMaxPx,
|
|
101
|
+
maxEm: textMaxPx / fontPx,
|
|
102
|
+
bottom: o.height - safe.bottom - gap,
|
|
103
|
+
centerX: safe.left + (o.width - safe.left - safe.right) / 2,
|
|
104
|
+
gap,
|
|
105
|
+
maxLines: Math.max(1, Math.round(o.look?.maxLines ?? 2)),
|
|
106
|
+
...(o.look?.position !== undefined ? { centerY: o.look.position } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 글꼴 없이 어림하는 폭(em). 한글·한자는 정사각에 가깝고 라틴은 좁다. 어림이 실제보다 넓게 나와야 페이지에서 잰 줄바꿈이 두 줄을 넘지 않는다
|
|
111
|
+
export function emWidth(s: string): number {
|
|
112
|
+
let w = 0;
|
|
113
|
+
for (const ch of s) {
|
|
114
|
+
const c = ch.codePointAt(0)!;
|
|
115
|
+
if (ch === " ") w += 0.3;
|
|
116
|
+
else if ((c >= 0x1100 && c <= 0x11ff) || (c >= 0x3000 && c <= 0x9fff) || (c >= 0xac00 && c <= 0xd7af) || (c >= 0xff00 && c <= 0xffef)) w += 1;
|
|
117
|
+
else if (/[0-9]/.test(ch)) w += 0.62;
|
|
118
|
+
else if (/[A-Z]/.test(ch)) w += 0.72;
|
|
119
|
+
else if (/[a-z]/.test(ch)) w += 0.58;
|
|
120
|
+
else if (/[.,:;'"!?·]/.test(ch)) w += 0.34;
|
|
121
|
+
else w += 0.62;
|
|
122
|
+
}
|
|
123
|
+
return w;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 한글로 끝나는데 조사·어미가 아닌 어절(「잠재」「512차원」) — 그 뒤에서 끊으면 말이 갈린다
|
|
127
|
+
const ENDINGS = new Set([..."은는이가을를에서로와과도만의께랑엔고며면니데해게지요다까죠라여야듯록나서"]);
|
|
128
|
+
function looseEnd(word: string): boolean {
|
|
129
|
+
const w = word.replace(/[^\p{L}\p{N}]+$/u, "");
|
|
130
|
+
const last = [...w].pop() ?? "";
|
|
131
|
+
// 「3만 2768」「57분의 1」 — 수를 세는 말 뒤는 조사처럼 보여도 수가 이어진다
|
|
132
|
+
if (/\d[만천억조]$|\d분의$/.test(w)) return true;
|
|
133
|
+
return /[가-힣]/.test(last) && !ENDINGS.has(last);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 어절(띄어쓰기) 단위로 두 줄까지 나눈다. 한 줄에 들면 한 줄, 아니면 두 줄 폭이 가장 고른 자리에서 끊는다.
|
|
138
|
+
* 두 줄로도 안 들면 null — 마디를 더 잘게 나눠야 한다. measure 는 글 폭(px 든 em 이든 maxWidth 와 같은 단위).
|
|
139
|
+
*/
|
|
140
|
+
export function wrapCaption(text: string, maxWidth: number, measure: (s: string) => number = emWidth, maxLines = 2): string[] | null {
|
|
141
|
+
const t = text.trim().replace(/\s+/g, " ");
|
|
142
|
+
if (measure(t) <= maxWidth) return [t];
|
|
143
|
+
const words = t.split(" ");
|
|
144
|
+
if (maxLines < 2 || words.length < 2) return null;
|
|
145
|
+
let best: string[] | null = null;
|
|
146
|
+
let bestW = Infinity;
|
|
147
|
+
for (let i = 1; i < words.length; i++) {
|
|
148
|
+
const a = words.slice(0, i).join(" ");
|
|
149
|
+
const b = words.slice(i).join(" ");
|
|
150
|
+
const wa = measure(a);
|
|
151
|
+
const wb = measure(b);
|
|
152
|
+
if (wa > maxWidth) break;
|
|
153
|
+
if (wb > maxWidth) continue;
|
|
154
|
+
// 위 줄이 조금 짧은 쪽(역삼각형)을 같은 폭보다 살짝 낫게 본다 — 한국 방송 자막 관례.
|
|
155
|
+
// 조사·어미로 끝난 어절 뒤에서 끊는다 — 「잠재 / 공간」처럼 한 덩어리 말을 가르면 폭이 고르더라도 읽다가 걸린다
|
|
156
|
+
const score = Math.max(wa, wb) + (wa > wb ? 0.01 * maxWidth : 0) + (looseEnd(words[i - 1]) ? 0.12 * maxWidth : 0);
|
|
157
|
+
if (score < bestW) {
|
|
158
|
+
bestW = score;
|
|
159
|
+
best = [a, b];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return best;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 쉼표·마침표 뒤에서 끊는다. 숫자 사이 쉼표·점(1,000 · 3.5)은 끊지 않는다. 쉼표 뒤 띄어쓰기가 없어도 끊는다
|
|
166
|
+
export function splitPhrases(text: string): string[] {
|
|
167
|
+
const out: string[] = [];
|
|
168
|
+
let cur = "";
|
|
169
|
+
const chars = [...text];
|
|
170
|
+
for (let i = 0; i < chars.length; i++) {
|
|
171
|
+
const ch = chars[i];
|
|
172
|
+
cur += ch;
|
|
173
|
+
if (!/[,.?!。、]/.test(ch)) continue;
|
|
174
|
+
if ((ch === "," || ch === ".") && /\d/.test(chars[i - 1] ?? "") && /\d/.test(chars[i + 1] ?? "")) continue;
|
|
175
|
+
if (/[.?!。]/.test(chars[i + 1] ?? "")) continue;
|
|
176
|
+
out.push(cur);
|
|
177
|
+
cur = "";
|
|
178
|
+
}
|
|
179
|
+
if (cur.trim()) out.push(cur);
|
|
180
|
+
return out.map((p) => p.trim().replace(/[,、]$/, "").replace(/(?<!\.)\.$/, "").trim()).filter(Boolean);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 두 줄에 안 드는 마디는 어절 경계에서 몇 토막으로 나눈다 — 토막 길이는 고르게, 끝은 조사·어미 뒤에서(「흡수해 잠재 / 공간에서」처럼 덩어리를 가르지 않게)
|
|
184
|
+
function chunk(text: string, maxEm: number, lines = 2): string[] {
|
|
185
|
+
if (wrapCaption(text, maxEm, emWidth, lines)) return [text];
|
|
186
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
187
|
+
const n = words.length;
|
|
188
|
+
const part = (i: number, j: number) => words.slice(i, j).join(" ");
|
|
189
|
+
for (let k = 2; k <= n; k++) {
|
|
190
|
+
const ideal = emWidth(text) / k;
|
|
191
|
+
// best[j][c] = 앞 j 어절을 c 토막으로 나눈 가장 싼 값
|
|
192
|
+
const best = Array.from({ length: n + 1 }, () => new Array<number>(k + 1).fill(Infinity));
|
|
193
|
+
const from = Array.from({ length: n + 1 }, () => new Array<number>(k + 1).fill(-1));
|
|
194
|
+
best[0][0] = 0;
|
|
195
|
+
for (let c = 1; c <= k; c++)
|
|
196
|
+
for (let j = 1; j <= n; j++)
|
|
197
|
+
for (let i = c - 1; i < j; i++) {
|
|
198
|
+
if (!Number.isFinite(best[i][c - 1])) continue;
|
|
199
|
+
const p = part(i, j);
|
|
200
|
+
if (!wrapCaption(p, maxEm, emWidth, lines)) continue;
|
|
201
|
+
const cost = best[i][c - 1] + Math.abs(emWidth(p) - ideal) / ideal + (j < n && looseEnd(words[j - 1]) ? 0.6 : 0);
|
|
202
|
+
if (cost < best[j][c]) {
|
|
203
|
+
best[j][c] = cost;
|
|
204
|
+
from[j][c] = i;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!Number.isFinite(best[n][k])) continue;
|
|
208
|
+
const parts: string[] = [];
|
|
209
|
+
for (let j = n, c = k; c > 0; c--) {
|
|
210
|
+
const i = from[j][c];
|
|
211
|
+
parts.unshift(part(i, j));
|
|
212
|
+
j = i;
|
|
213
|
+
}
|
|
214
|
+
return parts;
|
|
215
|
+
}
|
|
216
|
+
return words;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 한 줄 반에 못 미치는 짧은 마디(「MLA,」)는 다음 마디에 붙인다 — 0.5초짜리 자막은 읽기 전에 사라진다
|
|
220
|
+
function merge(parts: string[], maxEm: number, lines = 2): string[] {
|
|
221
|
+
const out: string[] = [];
|
|
222
|
+
for (const p of parts) {
|
|
223
|
+
const prev = out[out.length - 1];
|
|
224
|
+
if (prev !== undefined && emWidth(prev) < 4 && wrapCaption(`${prev}, ${p}`, maxEm, emWidth, lines)) out[out.length - 1] = `${prev}, ${p}`;
|
|
225
|
+
else out.push(p);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 소리 길이는 띄어쓰기 뺀 글자 수에 가깝다
|
|
231
|
+
const spoken = (s: string) => Math.max(1, s.replace(/\s/g, "").length);
|
|
232
|
+
|
|
233
|
+
function spread(texts: string[], from: number, to: number, cues: Cue[]): void {
|
|
234
|
+
const total = texts.reduce((a, p) => a + spoken(p), 0);
|
|
235
|
+
let t = from;
|
|
236
|
+
for (const p of texts) {
|
|
237
|
+
const d = ((to - from) * spoken(p)) / total;
|
|
238
|
+
cues.push({ from: t, to: t + d, text: p });
|
|
239
|
+
t += d;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function fromWords(raw: CaptionWord[], maxEm: number, cues: Cue[], lines = 2): number {
|
|
244
|
+
const before = cues.length;
|
|
245
|
+
const words = raw
|
|
246
|
+
.map((w) => ({ text: (w.text ?? w.w ?? "").trim(), from: w.from ?? w.start ?? NaN, to: w.to ?? w.end ?? NaN }))
|
|
247
|
+
.filter((w) => w.text && Number.isFinite(w.from) && Number.isFinite(w.to));
|
|
248
|
+
let cur: typeof words = [];
|
|
249
|
+
const flush = () => {
|
|
250
|
+
if (!cur.length) return;
|
|
251
|
+
cues.push({ from: cur[0].from, to: cur[cur.length - 1].to, text: splitPhrases(cur.map((w) => w.text).join(" ")).join(", ") });
|
|
252
|
+
cur = [];
|
|
253
|
+
};
|
|
254
|
+
for (const w of words) {
|
|
255
|
+
if (cur.length && !wrapCaption([...cur, w].map((x) => x.text).join(" "), maxEm, emWidth, lines)) flush();
|
|
256
|
+
cur.push(w);
|
|
257
|
+
if (/[.?!。]$/.test(w.text)) flush();
|
|
258
|
+
}
|
|
259
|
+
flush();
|
|
260
|
+
return cues.length - before;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* captions.json 줄 → 시각 붙은 자막 마디. 장면에만 묶인 줄은 쉼표·마침표 마디로 나눠 글자 수 비율로 장면 시간에 펴고,
|
|
265
|
+
* from·to 가 있으면 그 안에, words 가 있으면 낱말 시각 그대로. maxEm(captionMetrics) 을 주면 마디마다 두 줄 안에 들게 더 나눈다.
|
|
266
|
+
*/
|
|
267
|
+
export function toCues(lines: CaptionLine[], scenes: Span[], o: { maxEm?: number; duration?: number; maxLines?: number } = {}): Cue[] {
|
|
268
|
+
const maxEm = o.maxEm ?? Infinity;
|
|
269
|
+
const rows = o.maxLines ?? 2;
|
|
270
|
+
const cues: Cue[] = [];
|
|
271
|
+
for (const l of lines) {
|
|
272
|
+
const text = (l.text ?? "").trim();
|
|
273
|
+
if (l.words?.length && fromWords(l.words, maxEm, cues, rows) > 0) continue;
|
|
274
|
+
if (!text) continue;
|
|
275
|
+
let from: number;
|
|
276
|
+
let to: number;
|
|
277
|
+
if (l.from !== undefined && l.to !== undefined) {
|
|
278
|
+
from = l.from;
|
|
279
|
+
to = l.to;
|
|
280
|
+
} else {
|
|
281
|
+
const s = scenes.find((x) => x.id === l.scene);
|
|
282
|
+
if (!s) continue;
|
|
283
|
+
const pad = Math.min(0.25, s.dur * 0.05);
|
|
284
|
+
from = s.from + pad;
|
|
285
|
+
to = s.from + s.dur - pad;
|
|
286
|
+
}
|
|
287
|
+
const parts = merge(splitPhrases(text).flatMap((p) => chunk(p, maxEm, rows)), maxEm, rows);
|
|
288
|
+
spread(parts, from, to, cues);
|
|
289
|
+
}
|
|
290
|
+
cues.sort((a, b) => a.from - b.from);
|
|
291
|
+
for (let i = 0; i < cues.length; i++) {
|
|
292
|
+
const c = cues[i];
|
|
293
|
+
const next = cues[i + 1];
|
|
294
|
+
// 말 사이 짧은 숨에 자막이 깜빡이지 않게 다음 자막까지 붙여 남긴다 — 틈이 한 프레임이라도 있으면 판이 꺼졌다 켜진다
|
|
295
|
+
if (next && next.from - c.to < 0.6) c.to = next.from;
|
|
296
|
+
// 한두 프레임짜리 자막은 읽히지도 않고 검사에 깜빡임(frame.spike)으로 잡힌다
|
|
297
|
+
if (c.to - c.from < 0.3) c.to = next ? Math.min(next.from, c.from + 0.3) : c.from + 0.3;
|
|
298
|
+
if (o.duration !== undefined) c.to = Math.min(c.to, o.duration);
|
|
299
|
+
}
|
|
300
|
+
return cues.filter((c) => c.to > c.from);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface CaptionLayerOptions {
|
|
304
|
+
cues: Cue[];
|
|
305
|
+
width: number;
|
|
306
|
+
height: number;
|
|
307
|
+
safe?: SafeArea | null;
|
|
308
|
+
style?: KfStyle | null;
|
|
309
|
+
minPx?: number;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface Laid {
|
|
313
|
+
cue: Cue;
|
|
314
|
+
lines: string[];
|
|
315
|
+
fontPx: number;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const FADE = 0.12;
|
|
319
|
+
|
|
320
|
+
// 판 색은 테마의 어두운 쪽, 글은 밝은 쪽 — 어느 장면 위에서도 대비 3:1 을 넘게 판을 거의 불투명하게
|
|
321
|
+
function palette(style: KfStyle | null | undefined): { plate: string; ink: string; edge: string } {
|
|
322
|
+
const c = style?.colors;
|
|
323
|
+
if (!c) return { plate: "rgba(8, 10, 14, 0.86)", ink: "#ffffff", edge: "transparent" };
|
|
324
|
+
const [dark, light] = style!.scheme === "light" ? [c.ink, c.bg] : [c.bg, c.ink];
|
|
325
|
+
return { plate: `color-mix(in srgb, ${dark} 86%, transparent)`, ink: light, edge: c.accent };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 화면 자막 판을 짓고 t 마다 칠하는 함수(같은 t 면 같은 화면)와 판이 차지할 띠 높이를 돌려준다. 글꼴이 다 받아진 뒤에 불러야 줄바꿈 폭이 맞는다.
|
|
330
|
+
* 판은 안전 영역 아래쪽 가운데, 한 마디는 두 줄까지 — 넘치면 글자를 줄인다.
|
|
331
|
+
*/
|
|
332
|
+
export async function captionLayer(host: HTMLElement, o: CaptionLayerOptions): Promise<{ draw: (t: number) => void; band: number }> {
|
|
333
|
+
const m = captionMetrics({ ...o, look: o.style?.captions });
|
|
334
|
+
const fam = o.style?.fonts?.sans ?? '"Pretendard Variable", Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", sans-serif';
|
|
335
|
+
const weight = 700;
|
|
336
|
+
const font = (px: number) => `${weight} ${px}px ${fam}`;
|
|
337
|
+
// unicode-range 로 쪼갠 글꼴이면 처음 나오는 글자 조각을 그 프레임에 받느라 투명하게 찍힌다 — 쓸 글자를 미리 다 받는다
|
|
338
|
+
const all = [...new Set(o.cues.map((c) => c.text).join(""))].join("");
|
|
339
|
+
if (all) await document.fonts.load(font(m.fontPx), all).catch(() => {});
|
|
340
|
+
const ctx2d = document.createElement("canvas").getContext("2d")!;
|
|
341
|
+
const measure = (px: number) => (s: string) => {
|
|
342
|
+
ctx2d.font = font(px);
|
|
343
|
+
return ctx2d.measureText(s).width;
|
|
344
|
+
};
|
|
345
|
+
const laid: Laid[] = o.cues.map((cue) => {
|
|
346
|
+
for (let px = m.fontPx; px >= Math.round(m.fontPx * 0.8); px--) {
|
|
347
|
+
const lines = wrapCaption(cue.text, m.textMaxPx, measure(px), m.maxLines);
|
|
348
|
+
if (lines) return { cue, lines, fontPx: px };
|
|
349
|
+
}
|
|
350
|
+
const px = Math.round(m.fontPx * 0.8);
|
|
351
|
+
return { cue, lines: wrapCaption(cue.text, m.textMaxPx, measure(px), m.maxLines + 1) ?? [cue.text], fontPx: px };
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const { plate: plateBg, ink, edge } = palette(o.style);
|
|
355
|
+
const layer = document.createElement("div");
|
|
356
|
+
layer.dataset.kfCaptions = "on";
|
|
357
|
+
layer.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:50;";
|
|
358
|
+
const plate = document.createElement("div");
|
|
359
|
+
const radius = Math.min(o.style?.radius?.md ?? 10, Math.round(m.fontPx * 0.35));
|
|
360
|
+
plate.style.cssText = [
|
|
361
|
+
"position:absolute",
|
|
362
|
+
`left:${m.centerX}px`,
|
|
363
|
+
...(m.centerY !== undefined ? [`top:${m.centerY}px`, "transform:translate(-50%, -50%)"] : [`bottom:${o.height - m.bottom}px`, "transform:translateX(-50%)"]),
|
|
364
|
+
`max-width:${m.textMaxPx + m.padX * 2}px`,
|
|
365
|
+
"box-sizing:border-box",
|
|
366
|
+
`padding:${m.padY}px ${m.padX}px`,
|
|
367
|
+
`background:${plateBg}`,
|
|
368
|
+
`color:${ink}`,
|
|
369
|
+
`border-radius:${radius}px`,
|
|
370
|
+
`box-shadow:inset 0 -${Math.max(2, Math.round(m.fontPx * 0.06))}px 0 ${edge}`,
|
|
371
|
+
`font-family:${fam}`,
|
|
372
|
+
`font-weight:${weight}`,
|
|
373
|
+
`line-height:${m.lineHeight}`,
|
|
374
|
+
"letter-spacing:-0.01em",
|
|
375
|
+
"text-align:center",
|
|
376
|
+
"white-space:nowrap",
|
|
377
|
+
"word-break:keep-all",
|
|
378
|
+
].join(";");
|
|
379
|
+
plate.hidden = true;
|
|
380
|
+
layer.append(plate);
|
|
381
|
+
host.append(layer);
|
|
382
|
+
|
|
383
|
+
let shown = -1;
|
|
384
|
+
const band = captionBand(m, Math.max(1, ...laid.map((l) => l.lines.length)));
|
|
385
|
+
const draw = (t: number) => {
|
|
386
|
+
let i = -1;
|
|
387
|
+
for (let k = 0; k < laid.length; k++) {
|
|
388
|
+
if (laid[k].cue.from > t) break;
|
|
389
|
+
if (t < laid[k].cue.to) i = k;
|
|
390
|
+
}
|
|
391
|
+
if (i < 0) {
|
|
392
|
+
plate.hidden = true;
|
|
393
|
+
shown = -1;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const { cue, lines, fontPx } = laid[i];
|
|
397
|
+
if (i !== shown) {
|
|
398
|
+
shown = i;
|
|
399
|
+
plate.style.fontSize = `${fontPx}px`;
|
|
400
|
+
plate.replaceChildren(
|
|
401
|
+
...lines.map((l) => {
|
|
402
|
+
const s = document.createElement("div");
|
|
403
|
+
s.textContent = l;
|
|
404
|
+
return s;
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
plate.hidden = false;
|
|
409
|
+
// 바로 이어지는 마디끼리는 글만 바꾼다. 앞뒤가 빈 자리에서만 서서히 켜고 끈다
|
|
410
|
+
const joinedIn = i > 0 && laid[i - 1].cue.to >= cue.from - 1e-6;
|
|
411
|
+
const joinedOut = i < laid.length - 1 && laid[i + 1].cue.from <= cue.to + 1e-6;
|
|
412
|
+
const a = Math.min(joinedIn ? 1 : (t - cue.from) / FADE, joinedOut ? 1 : (cue.to - t) / FADE, 1);
|
|
413
|
+
plate.style.opacity = String(Math.max(0, a));
|
|
414
|
+
};
|
|
415
|
+
return { draw, band };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 자막 띠 자리를 비운다 — 장면을 하나도 고치지 않고, 안전 영역(위 끝 ~ 아래 끝)에 있던 내용을 (위 끝 ~ 띠 위)로 한 배율로 줄여 올린다.
|
|
420
|
+
* 장면이 ctx.safe 를 쓰든 절대 좌표로 놓든, React·캔버스든 똑같이 올라간다. 줄어 남는 가장자리는 테마 바탕색.
|
|
421
|
+
*/
|
|
422
|
+
export function liftScenes(stage: HTMLElement, o: { width: number; height: number; safe?: SafeArea | null; band: number }): number {
|
|
423
|
+
const safe = o.safe ?? { top: 0, bottom: 0, left: 0, right: 0 };
|
|
424
|
+
const top = safe.top;
|
|
425
|
+
const room = o.height - safe.bottom - top;
|
|
426
|
+
const k = Math.max(0.5, (room - o.band) / room);
|
|
427
|
+
stage.style.background = "var(--kf-bg, #000)";
|
|
428
|
+
// 줄어든 장면의 가장자리(모눈·바탕 무늬)가 칼로 자른 듯 끊기지 않게 바탕색으로 녹인다 — 녹는 폭은 안전 영역 밖이라 글은 안 닿는다
|
|
429
|
+
const fade = Math.round(Math.min(o.width, o.height) * 0.06 / k);
|
|
430
|
+
const edge = (dir: string) => `linear-gradient(${dir}, transparent, #000 ${fade}px, #000 calc(100% - ${fade}px), transparent)`;
|
|
431
|
+
for (const r of stage.querySelectorAll<HTMLElement>(":scope > [data-kf-scene]")) {
|
|
432
|
+
r.style.transformOrigin = `${o.width / 2}px ${top}px`;
|
|
433
|
+
r.style.transform = `scale(${k})`;
|
|
434
|
+
r.style.maskImage = `${edge("to right")}, ${edge("to bottom")}`;
|
|
435
|
+
r.style.maskComposite = "intersect";
|
|
436
|
+
}
|
|
437
|
+
return k;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
interface ProjectLike {
|
|
441
|
+
width: number;
|
|
442
|
+
height: number;
|
|
443
|
+
duration?: number;
|
|
444
|
+
captions?: unknown;
|
|
445
|
+
safeArea?: SafeArea;
|
|
446
|
+
text?: { minPx?: number };
|
|
447
|
+
scenes: Span[];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export interface CaptionsPlan {
|
|
451
|
+
/** 장면에 줄 안전 영역 — on 이면 아래쪽에 자막 띠를 더 뺀다(무대가 그 위로 배치한다). fit 이면 그대로 */
|
|
452
|
+
safe: SafeArea;
|
|
453
|
+
/** 장면을 다 지은 뒤 부른다 — 판을 얹고(fit 이면 장면을 띠 위로 줄여 올리고) t 마다 칠하는 함수를 돌려준다 */
|
|
454
|
+
mount(stage: HTMLElement, style?: KfStyle | null): Promise<(t: number) => void>;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* mountProject 가 장면을 짓기 전에 부른다 — 주소 ?captions=on|off|fit 가 kf.json 의 captions 를 그 판만 덮는다.
|
|
459
|
+
* 자막이 켜져 있고 captions.json 에 들릴 말이 있으면 띠를 뺀 안전 영역과 판 짓기를, 아니면 null.
|
|
460
|
+
* on: 무대(ctx.safe 로 배치하는 장면)가 띠 위로 제 크기 그대로 올라간다. 픽셀을 박아 둔 장면은 안 움직여 kf check 가 caption.overlap 으로 잡는다.
|
|
461
|
+
* fit: 장면 코드와 안전 영역은 그대로 두고 장면 뿌리를 띠 위로 줄여 올린다 — 어떤 장면이든 안 겹치지만 내용이 작아지고 움직이는 면적도 준다.
|
|
462
|
+
*/
|
|
463
|
+
export async function captionsPlan(o: { kf: ProjectLike; mode?: string | null; style?: KfStyle | null }): Promise<CaptionsPlan | null> {
|
|
464
|
+
const raw = o.mode ?? o.kf.captions;
|
|
465
|
+
if (captionMode(raw) !== "on") return null;
|
|
466
|
+
const res = await fetch(new URL("captions.json", document.baseURI)).catch(() => null);
|
|
467
|
+
if (!res?.ok) return null;
|
|
468
|
+
const lines = (await res.json().catch(() => null)) as CaptionLine[] | null;
|
|
469
|
+
if (!Array.isArray(lines)) return null;
|
|
470
|
+
const { kf } = o;
|
|
471
|
+
const minPx = kf.text?.minPx;
|
|
472
|
+
const base = kf.safeArea ?? { top: 0, bottom: 0, left: 0, right: 0 };
|
|
473
|
+
const m = captionMetrics({ width: kf.width, height: kf.height, safe: base, minPx, look: o.style?.captions });
|
|
474
|
+
const cues = toCues(lines, kf.scenes, { maxEm: m.maxEm, duration: kf.duration, maxLines: m.maxLines });
|
|
475
|
+
if (!cues.length) return null;
|
|
476
|
+
const fit = raw === "fit";
|
|
477
|
+
// 띠는 판이 실제로 쓸 줄 수만큼(어림) — 장면을 짓기 전이라 아직 글꼴로 잴 수 없다
|
|
478
|
+
const band = captionBand(m, cues.some((c) => !wrapCaption(c.text, m.maxEm, emWidth, 1)) ? m.maxLines : 1);
|
|
479
|
+
return {
|
|
480
|
+
safe: fit ? base : { ...base, bottom: base.bottom + band },
|
|
481
|
+
async mount(stage, style) {
|
|
482
|
+
const { draw, band: used } = await captionLayer(stage, { cues, width: kf.width, height: kf.height, safe: base, style, minPx });
|
|
483
|
+
if (fit) liftScenes(stage, { width: kf.width, height: kf.height, safe: base, band: used });
|
|
484
|
+
return draw;
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { clamp, emit, progress, rand, spring, toEase, type Ease, type EaseName, type Style, safeTime } from "@kasaframe/core";
|
|
2
|
+
|
|
3
|
+
export type MoveName = "fade" | "rise" | "drop" | "pop" | "blurIn" | "scaleIn" | "maskWipe" | "slide";
|
|
4
|
+
export type Side = "left" | "right" | "top" | "bottom";
|
|
5
|
+
|
|
6
|
+
export interface MoveOpts {
|
|
7
|
+
/** 시작 시각(초, 장면 안) */
|
|
8
|
+
at?: number;
|
|
9
|
+
/** 길이(초) */
|
|
10
|
+
dur?: number;
|
|
11
|
+
ease?: EaseName | Ease;
|
|
12
|
+
/** 움직이는 거리(px) — rise·drop·slide */
|
|
13
|
+
distance?: number;
|
|
14
|
+
/** slide·maskWipe 가 들어오는 쪽 */
|
|
15
|
+
from?: Side;
|
|
16
|
+
/** 이벤트 기록 — 같은 등장 움직임 되풀이를 검사가 센다 */
|
|
17
|
+
source?: string;
|
|
18
|
+
scene?: string;
|
|
19
|
+
emit?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const MOVES: readonly MoveName[] = ["fade", "rise", "drop", "pop", "blurIn", "scaleIn", "maskWipe", "slide"];
|
|
23
|
+
|
|
24
|
+
const DEFAULT_DUR: Record<MoveName, number> = {
|
|
25
|
+
fade: 0.4,
|
|
26
|
+
rise: 0.5,
|
|
27
|
+
drop: 0.5,
|
|
28
|
+
pop: 0.55,
|
|
29
|
+
blurIn: 0.6,
|
|
30
|
+
scaleIn: 0.5,
|
|
31
|
+
maskWipe: 0.6,
|
|
32
|
+
slide: 0.5,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** p 는 0(안 보임) → 1(제자리). 등장과 퇴장이 같은 모양을 반대로 쓴다 */
|
|
36
|
+
function shape(name: MoveName, p: number, e: number, local: number, o: MoveOpts): Style {
|
|
37
|
+
const d = o.distance;
|
|
38
|
+
switch (name) {
|
|
39
|
+
case "fade":
|
|
40
|
+
return { opacity: e };
|
|
41
|
+
case "rise":
|
|
42
|
+
return { opacity: clamp(p * 1.6), y: (1 - e) * (d ?? 28) };
|
|
43
|
+
case "drop":
|
|
44
|
+
return { opacity: clamp(p * 1.6), y: -(1 - e) * (d ?? 28) };
|
|
45
|
+
case "pop": {
|
|
46
|
+
// 스프링이 닫힌 식이라 퇴장(시간을 거꾸로)에도 같은 값이 나온다
|
|
47
|
+
const s = local <= 0 ? 0.6 : spring(local, { from: 0.6, to: 1, stiffness: 320, damping: 17 });
|
|
48
|
+
return { opacity: clamp(p * 3), scale: p >= 1 ? 1 : s };
|
|
49
|
+
}
|
|
50
|
+
case "blurIn":
|
|
51
|
+
return { opacity: e, blur: (1 - e) * 18, scale: 1.04 - 0.04 * e };
|
|
52
|
+
case "scaleIn":
|
|
53
|
+
return { opacity: e, scale: 0.86 + 0.14 * e };
|
|
54
|
+
case "maskWipe": {
|
|
55
|
+
const side = o.from ?? "left";
|
|
56
|
+
const hide = 1 - e;
|
|
57
|
+
const opposite = { left: "right", right: "left", top: "bottom", bottom: "top" } as const;
|
|
58
|
+
return { clip: { [opposite[side]]: hide } };
|
|
59
|
+
}
|
|
60
|
+
case "slide": {
|
|
61
|
+
const side = o.from ?? "left";
|
|
62
|
+
const dist = d ?? 120;
|
|
63
|
+
const dx = side === "left" ? -dist : side === "right" ? dist : 0;
|
|
64
|
+
const dy = side === "top" ? -dist : side === "bottom" ? dist : 0;
|
|
65
|
+
return { opacity: clamp(p * 2), x: (1 - e) * dx, y: (1 - e) * dy };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function check(name: string): MoveName {
|
|
71
|
+
if (!(MOVES as readonly string[]).includes(name)) throw new Error(`모르는 움직임: ${name} (있는 것: ${MOVES.join(" · ")})`);
|
|
72
|
+
return name as MoveName;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function enter(name: MoveName, t: number, opts: MoveOpts = {}): Style {
|
|
76
|
+
t = safeTime(t);
|
|
77
|
+
const n = check(name);
|
|
78
|
+
const at = opts.at ?? 0;
|
|
79
|
+
const dur = opts.dur ?? DEFAULT_DUR[n];
|
|
80
|
+
const p = progress(t, at, dur);
|
|
81
|
+
const e = toEase(opts.ease, n === "pop" ? (x) => x : undefined)(p);
|
|
82
|
+
if (opts.emit !== false && p > 0 && p < 1) emit({ kind: "move", source: opts.source ?? n, scene: opts.scene, name: `enter:${n}`, value: 1 });
|
|
83
|
+
return shape(n, p, e, t - at, opts);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 퇴장 — at 에서 시작해 dur 뒤에 사라져 있다 */
|
|
87
|
+
export function exit(name: MoveName, t: number, opts: MoveOpts = {}): Style {
|
|
88
|
+
t = safeTime(t);
|
|
89
|
+
const n = check(name);
|
|
90
|
+
const at = opts.at ?? 0;
|
|
91
|
+
const dur = opts.dur ?? DEFAULT_DUR[n] * 0.7;
|
|
92
|
+
const p = 1 - progress(t, at, dur);
|
|
93
|
+
const e = toEase(opts.ease, (x) => x * x)(p);
|
|
94
|
+
// 스프링 튐은 들어올 때만 — 나갈 때 튀면 떨림으로 보인다
|
|
95
|
+
return shape(n === "pop" ? "scaleIn" : n, p, e, 0, opts);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 들어와 머물다 나간다. out 이 없으면 나가지 않는다 */
|
|
99
|
+
export function inOut(name: MoveName, t: number, opts: MoveOpts & { out?: number; exitAs?: MoveName; outDur?: number } = {}): Style {
|
|
100
|
+
t = safeTime(t);
|
|
101
|
+
const a = enter(name, t, opts);
|
|
102
|
+
if (opts.out === undefined || t < opts.out) return a;
|
|
103
|
+
return exit(opts.exitAs ?? name, t, { ...opts, at: opts.out, dur: opts.outDur, emit: false });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface StaggerOpts {
|
|
107
|
+
each?: number;
|
|
108
|
+
/** 어디서부터 번지나 — MoveOpts.from(들어오는 쪽)과 이름이 겹치지 않게 origin */
|
|
109
|
+
origin?: "start" | "end" | "center" | "random";
|
|
110
|
+
count?: number;
|
|
111
|
+
seed?: number | string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** i 번째 항목의 늦춤(초) */
|
|
115
|
+
export function stagger(i: number, opts: StaggerOpts = {}): number {
|
|
116
|
+
const each = opts.each ?? 0.06;
|
|
117
|
+
const n = opts.count ?? i + 1;
|
|
118
|
+
switch (opts.origin ?? "start") {
|
|
119
|
+
case "start":
|
|
120
|
+
return i * each;
|
|
121
|
+
case "end":
|
|
122
|
+
return (n - 1 - i) * each;
|
|
123
|
+
case "center":
|
|
124
|
+
return Math.abs(i - (n - 1) / 2) * each;
|
|
125
|
+
case "random":
|
|
126
|
+
return rand(opts.seed ?? "stagger", i) * (n - 1) * each;
|
|
127
|
+
}
|
|
128
|
+
}
|