kasaframe 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +146 -0
- package/README.md +77 -2
- package/app/package.json +12 -0
- package/app/server/engine.ts +415 -0
- package/app/server/main.ts +205 -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 +1013 -0
- package/app/web/style.css +430 -0
- package/bin/hooks.mjs +51 -0
- package/bin/kf.mjs +14 -0
- package/docs/AGENTS.md +238 -0
- package/music/README.md +13 -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 +273 -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 +42 -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 +135 -0
- package/packages/check/src/rules/layout.ts +257 -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 +29 -0
- package/packages/cli/src/blur.ts +68 -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/blender.ts +270 -0
- package/packages/cli/src/commands/check.ts +25 -0
- package/packages/cli/src/commands/doctor.ts +22 -0
- package/packages/cli/src/commands/gen.ts +173 -0
- package/packages/cli/src/commands/look.ts +39 -0
- package/packages/cli/src/commands/lyrics.ts +111 -0
- package/packages/cli/src/commands/make.ts +542 -0
- package/packages/cli/src/commands/new.ts +176 -0
- package/packages/cli/src/commands/preview.ts +43 -0
- package/packages/cli/src/commands/render.ts +216 -0
- package/packages/cli/src/commands/setup.ts +63 -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 +85 -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 +53 -0
- package/packages/cli/src/lock.ts +72 -0
- package/packages/cli/src/out.ts +13 -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 +146 -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/gen/PROVIDERS.md +55 -0
- package/packages/gen/package.json +9 -0
- package/packages/gen/src/generate.ts +204 -0
- package/packages/gen/src/image/og.ts +133 -0
- package/packages/gen/src/index.ts +18 -0
- package/packages/gen/src/keys.ts +60 -0
- package/packages/gen/src/media.ts +150 -0
- package/packages/gen/src/plan.ts +106 -0
- package/packages/gen/src/registry.ts +51 -0
- package/packages/gen/src/seam.ts +103 -0
- package/packages/gen/src/types.ts +86 -0
- package/packages/gen/src/video/fal.ts +140 -0
- package/packages/gen/src/video/h3.ts +107 -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 +226 -0
- package/packages/jizura/src/layer.ts +282 -0
- package/packages/jizura/src/lyrics.ts +81 -0
- package/packages/jizura/src/pin.ts +5 -0
- package/packages/jizura/src/vite.ts +36 -0
- package/packages/motion/README.md +142 -0
- package/packages/motion/package.json +29 -0
- package/packages/motion/src/camera.ts +97 -0
- package/packages/motion/src/captions.ts +479 -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 +90 -0
- package/packages/motion/src/moves/cloneArray.ts +111 -0
- package/packages/motion/src/moves/curtains.ts +81 -0
- package/packages/motion/src/moves/echoStack.ts +70 -0
- package/packages/motion/src/moves/fit.ts +34 -0
- package/packages/motion/src/moves/flood.ts +61 -0
- package/packages/motion/src/moves/ghostStack.ts +79 -0
- package/packages/motion/src/moves/gridFloor.ts +100 -0
- package/packages/motion/src/moves/imageSwarm.ts +109 -0
- package/packages/motion/src/moves/index.ts +19 -0
- package/packages/motion/src/moves/irisBlades.ts +113 -0
- package/packages/motion/src/moves/karaokeLine.ts +94 -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/morphBox.ts +96 -0
- package/packages/motion/src/moves/neonSign.ts +77 -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 +89 -0
- package/packages/motion/src/moves/sparkTrail.ts +114 -0
- package/packages/motion/src/moves/speedLines.ts +91 -0
- package/packages/motion/src/moves/splitFlap.ts +147 -0
- package/packages/motion/src/moves/stepWords.ts +117 -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 +87 -0
- package/packages/motion/src/moves/tokenPicker.ts +114 -0
- package/packages/motion/src/moves/truchet.ts +85 -0
- package/packages/motion/src/moves/wordWall.ts +76 -0
- package/packages/motion/src/moves/worldRoll.ts +31 -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 +228 -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 +502 -0
- package/packages/motion/src/transition3d/shatter.ts +101 -0
- package/packages/motion/src/transition3d/whip.ts +65 -0
- package/packages/motion/src/transition3d/world.ts +683 -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/plan/package.json +15 -0
- package/packages/plan/src/brief.ts +254 -0
- package/packages/plan/src/captions.ts +75 -0
- package/packages/plan/src/detect.ts +93 -0
- package/packages/plan/src/index.ts +23 -0
- package/packages/plan/src/layout.ts +176 -0
- package/packages/plan/src/llm.ts +110 -0
- package/packages/plan/src/look.ts +220 -0
- package/packages/plan/src/plan.ts +125 -0
- package/packages/plan/src/request.ts +55 -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 +106 -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 +16 -0
- package/packages/themes/src/factory.ts +296 -0
- package/packages/themes/src/fx.ts +14 -0
- package/packages/themes/src/index.ts +105 -0
- package/packages/themes/src/stage3d.ts +177 -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/skills/kasaframe/SKILL.md +32 -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 +272 -0
- package/templates/_shared/project/main.ts +9 -0
- package/templates/_shared/scenes/default.ts +18 -0
- package/templates/_shared/scenes/drop.ts +30 -0
- package/templates/_shared/scenes/end.ts +23 -0
- package/templates/_shared/scenes/hook.ts +16 -0
- package/templates/_shared/scenes/seam.ts +41 -0
- package/templates/_shared/scenes/step.ts +40 -0
- package/templates/ad/preview.png +0 -0
- package/templates/ad/template.json +132 -0
- package/templates/demo/preview.png +0 -0
- package/templates/demo/template.json +130 -0
- package/templates/explainer/preview.png +0 -0
- package/templates/explainer/template.json +142 -0
- package/templates/launch/preview.png +0 -0
- package/templates/launch/template.json +130 -0
- package/templates/mv/preview.png +0 -0
- package/templates/mv/scenes/default.ts +95 -0
- package/templates/mv/template.json +176 -0
- package/templates/promo/preview.png +0 -0
- package/templates/promo/template.json +144 -0
- package/templates/shorts/preview.png +0 -0
- package/templates/shorts/scenes/hook.ts +34 -0
- package/templates/shorts/template.json +127 -0
- package/templates/square/preview.png +0 -0
- package/templates/square/template.json +113 -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/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/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/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,503 @@
|
|
|
1
|
+
import type { StudioConfig } from "../plugin.ts";
|
|
2
|
+
import { Timeline, type TimelineData } from "./timeline.ts";
|
|
3
|
+
|
|
4
|
+
// SPEC 1번 페이지 계약. 미리보기는 프로젝트와 이것으로만 말한다.
|
|
5
|
+
interface KfMarker {
|
|
6
|
+
t: number;
|
|
7
|
+
label: string;
|
|
8
|
+
kind: "scene" | "beat" | "drop" | "section";
|
|
9
|
+
}
|
|
10
|
+
interface KfPage {
|
|
11
|
+
meta: { fps: number; width: number; height: number; duration: number; music?: string };
|
|
12
|
+
mode: "seek" | "step";
|
|
13
|
+
ready(): Promise<void>;
|
|
14
|
+
seek(t: number): Promise<void>;
|
|
15
|
+
markers?: KfMarker[];
|
|
16
|
+
}
|
|
17
|
+
type KfWindow = Window & { __kf?: Partial<KfPage> };
|
|
18
|
+
|
|
19
|
+
const ICON = {
|
|
20
|
+
first: "M5 5h2.5v14H5zM19 5v14L8.5 12z",
|
|
21
|
+
prev: "M15.5 5v14L5 12zM16.5 5H19v14h-2.5z",
|
|
22
|
+
play: "M7 4.5v15L19.5 12z",
|
|
23
|
+
pause: "M6.5 5h4v14h-4zM13.5 5h4v14h-4z",
|
|
24
|
+
next: "M5 5h2.5v14H5zM8.5 5v14L19 12z",
|
|
25
|
+
last: "M5 5v14l10.5-7zM16.5 5H19v14h-2.5z",
|
|
26
|
+
loop: "M7 7h10v3l4-4-4-4v3H5v6h2zM17 17H7v-3l-4 4 4 4v-3h12v-6h-2z",
|
|
27
|
+
captions: "M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v10h16V7zm2 5h5v2H6zm7 0h5v2h-5zm-7-3h12v2H6z",
|
|
28
|
+
};
|
|
29
|
+
const svg = (d: string) => `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${d}"/></svg>`;
|
|
30
|
+
|
|
31
|
+
const app = document.getElementById("app")!;
|
|
32
|
+
app.innerHTML = `
|
|
33
|
+
<header class="top">
|
|
34
|
+
<span class="name"></span>
|
|
35
|
+
<span class="meta"></span>
|
|
36
|
+
<span class="grow"></span>
|
|
37
|
+
<span class="keys">Space 재생 · ←→ 프레임 · Shift 1초 · Alt 박 · ↑↓ 경계 · Shift 끌기 = 붙이기</span>
|
|
38
|
+
<span class="status" data-state="wait">불러오는 중</span>
|
|
39
|
+
</header>
|
|
40
|
+
<main class="stage"><div class="error"></div><div class="frame"><iframe src="/index.html" tabindex="-1" title="project"></iframe></div></main>
|
|
41
|
+
<nav class="bar">
|
|
42
|
+
<button data-act="first" title="처음 (Home)">${svg(ICON.first)}</button>
|
|
43
|
+
<button data-act="prev" title="한 프레임 뒤 (←)">${svg(ICON.prev)}</button>
|
|
44
|
+
<button data-act="play" class="play" title="재생 (Space)">${svg(ICON.play)}</button>
|
|
45
|
+
<button data-act="next" title="한 프레임 앞 (→)">${svg(ICON.next)}</button>
|
|
46
|
+
<button data-act="last" title="끝 (End)">${svg(ICON.last)}</button>
|
|
47
|
+
<button data-act="loop" title="반복 (L)" aria-pressed="false">${svg(ICON.loop)}</button>
|
|
48
|
+
<button data-act="captions" title="화면 자막 (C)" aria-pressed="false" hidden>${svg(ICON.captions)}</button>
|
|
49
|
+
<span class="time">0:00.000</span>
|
|
50
|
+
<span class="frameno"></span>
|
|
51
|
+
<span class="info"></span>
|
|
52
|
+
</nav>
|
|
53
|
+
<section class="timeline"></section>
|
|
54
|
+
`;
|
|
55
|
+
const $ = <T extends Element>(s: string) => app.querySelector(s) as T;
|
|
56
|
+
const iframe = $<HTMLIFrameElement>("iframe");
|
|
57
|
+
const frameBox = $<HTMLDivElement>(".frame");
|
|
58
|
+
const stageBox = $<HTMLElement>(".stage");
|
|
59
|
+
const errorBox = $<HTMLDivElement>(".error");
|
|
60
|
+
errorBox.addEventListener("click", (e) => e.stopPropagation());
|
|
61
|
+
const statusEl = $<HTMLSpanElement>(".status");
|
|
62
|
+
const timeEl = $<HTMLSpanElement>(".time");
|
|
63
|
+
const frameEl = $<HTMLSpanElement>(".frameno");
|
|
64
|
+
const infoEl = $<HTMLSpanElement>(".info");
|
|
65
|
+
const playBtn = $<HTMLButtonElement>('[data-act="play"]');
|
|
66
|
+
const loopBtn = $<HTMLButtonElement>('[data-act="loop"]');
|
|
67
|
+
const capBtn = $<HTMLButtonElement>('[data-act="captions"]');
|
|
68
|
+
// 화면 자막은 프로젝트 페이지 주소의 ?captions=on|off 로 켜고 끈다 — kf.json 은 그대로(kf render --captions 와 같은 길)
|
|
69
|
+
let captions = new URLSearchParams(location.search).get("captions");
|
|
70
|
+
if (captions === "on" || captions === "off") iframe.src = `/index.html?captions=${captions}`;
|
|
71
|
+
|
|
72
|
+
const S = {
|
|
73
|
+
kf: null as KfPage | null,
|
|
74
|
+
meta: { fps: 60, width: 1920, height: 1080, duration: 60 },
|
|
75
|
+
cfg: null as StudioConfig | null,
|
|
76
|
+
data: null as TimelineData | null,
|
|
77
|
+
t: Math.max(0, Number(new URLSearchParams(location.search).get("t")) || 0),
|
|
78
|
+
playing: false,
|
|
79
|
+
resumeAfterScrub: false,
|
|
80
|
+
loop: false,
|
|
81
|
+
wanted: null as number | null,
|
|
82
|
+
busy: false,
|
|
83
|
+
seekMs: 0,
|
|
84
|
+
generation: 0,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const audio = new Audio();
|
|
88
|
+
audio.preload = "auto";
|
|
89
|
+
let audioUrl: string | null = null;
|
|
90
|
+
let hasAudio = false;
|
|
91
|
+
let wallStart = 0;
|
|
92
|
+
|
|
93
|
+
const fps = () => S.meta.fps;
|
|
94
|
+
const dur = () => S.meta.duration;
|
|
95
|
+
const toFrame = (t: number) => Math.round(t * fps());
|
|
96
|
+
const frameTime = (t: number) => Math.min(dur(), Math.max(0, toFrame(t) / fps()));
|
|
97
|
+
|
|
98
|
+
function fmt(t: number) {
|
|
99
|
+
const m = Math.floor(t / 60);
|
|
100
|
+
const s = t - m * 60;
|
|
101
|
+
return `${m}:${s.toFixed(3).padStart(6, "0")}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setStatus(state: "ok" | "wait" | "err", text: string) {
|
|
105
|
+
statusEl.dataset.state = state;
|
|
106
|
+
statusEl.textContent = text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function showError(msg: string) {
|
|
110
|
+
errorBox.textContent = msg;
|
|
111
|
+
errorBox.classList.add("on");
|
|
112
|
+
setStatus("err", "오류");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clearError() {
|
|
116
|
+
errorBox.textContent = "";
|
|
117
|
+
errorBox.classList.remove("on");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- 시계: 곡이 있으면 곡이 기준, 없으면 벽시계 ----
|
|
121
|
+
|
|
122
|
+
function clockNow(): number {
|
|
123
|
+
if (!S.playing) return S.t;
|
|
124
|
+
if (hasAudio) return audio.currentTime - (S.cfg?.from ?? 0);
|
|
125
|
+
return (performance.now() - wallStart) / 1000;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function startClock() {
|
|
129
|
+
if (hasAudio) {
|
|
130
|
+
audio.currentTime = (S.cfg?.from ?? 0) + S.t;
|
|
131
|
+
audio.play().catch((e: unknown) => {
|
|
132
|
+
hasAudio = false;
|
|
133
|
+
wallStart = performance.now() - S.t * 1000;
|
|
134
|
+
showError(`곡을 재생하지 못했다 — 소리 없이 돈다 (${String(e)})`);
|
|
135
|
+
});
|
|
136
|
+
} else wallStart = performance.now() - S.t * 1000;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function play() {
|
|
140
|
+
if (S.playing) return;
|
|
141
|
+
if (S.t >= dur() - 1 / fps()) S.t = 0;
|
|
142
|
+
S.playing = true;
|
|
143
|
+
startClock();
|
|
144
|
+
playBtn.innerHTML = svg(ICON.pause);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pause() {
|
|
148
|
+
if (!S.playing) return;
|
|
149
|
+
S.t = frameTime(clockNow());
|
|
150
|
+
S.playing = false;
|
|
151
|
+
audio.pause();
|
|
152
|
+
playBtn.innerHTML = svg(ICON.play);
|
|
153
|
+
requestSeek(S.t);
|
|
154
|
+
syncUrl();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function setTime(t: number, exact = false) {
|
|
158
|
+
S.t = exact ? Math.min(dur(), Math.max(0, t)) : frameTime(t);
|
|
159
|
+
if (S.playing) startClock();
|
|
160
|
+
requestSeek(S.t);
|
|
161
|
+
syncUrl();
|
|
162
|
+
paint();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let urlTimer = 0;
|
|
166
|
+
function syncUrl() {
|
|
167
|
+
clearTimeout(urlTimer);
|
|
168
|
+
urlTimer = window.setTimeout(() => {
|
|
169
|
+
const q = new URLSearchParams(location.search);
|
|
170
|
+
q.set("t", S.t.toFixed(3));
|
|
171
|
+
history.replaceState(null, "", `?${q}`);
|
|
172
|
+
}, 120);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- 페이지에 seek: 하나만 돌리고, 밀린 요청은 마지막 것만 ----
|
|
176
|
+
|
|
177
|
+
function requestSeek(t: number) {
|
|
178
|
+
S.wanted = t;
|
|
179
|
+
void pump();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function pump() {
|
|
183
|
+
if (S.busy || S.wanted === null || !S.kf) return;
|
|
184
|
+
const t = S.wanted;
|
|
185
|
+
const gen = S.generation;
|
|
186
|
+
S.wanted = null;
|
|
187
|
+
S.busy = true;
|
|
188
|
+
const t0 = performance.now();
|
|
189
|
+
try {
|
|
190
|
+
await S.kf.seek(t);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
if (gen === S.generation) showError(`seek(${t.toFixed(3)}) 실패: ${e instanceof Error ? e.message : String(e)}`);
|
|
193
|
+
}
|
|
194
|
+
S.busy = false;
|
|
195
|
+
if (gen !== S.generation) return;
|
|
196
|
+
S.seekMs = performance.now() - t0;
|
|
197
|
+
setStatus("ok", `연결됨 · seek ${S.seekMs.toFixed(0)}ms`);
|
|
198
|
+
if (S.wanted !== null) void pump();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function tick() {
|
|
202
|
+
if (S.playing) {
|
|
203
|
+
let t = clockNow();
|
|
204
|
+
if (t >= dur()) {
|
|
205
|
+
if (S.loop) {
|
|
206
|
+
S.t = 0;
|
|
207
|
+
startClock();
|
|
208
|
+
t = 0;
|
|
209
|
+
} else {
|
|
210
|
+
S.t = dur();
|
|
211
|
+
pause();
|
|
212
|
+
t = dur();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
S.t = Math.max(0, t);
|
|
216
|
+
// 렌더와 같은 그림을 보이려고 프레임 경계로 내려서 찍는다
|
|
217
|
+
requestSeek(Math.floor(S.t * fps() + 1e-6) / fps());
|
|
218
|
+
}
|
|
219
|
+
paint();
|
|
220
|
+
requestAnimationFrame(tick);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- 화면 ----
|
|
224
|
+
|
|
225
|
+
const timeline = new Timeline($<HTMLElement>(".timeline"), {
|
|
226
|
+
seek: (t, snapped) => setTime(t, snapped),
|
|
227
|
+
scrubStart() {
|
|
228
|
+
S.resumeAfterScrub = S.playing;
|
|
229
|
+
pause();
|
|
230
|
+
},
|
|
231
|
+
scrubEnd() {
|
|
232
|
+
if (S.resumeAfterScrub) play();
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
function lastAtOrBefore(ts: number[], t: number): number {
|
|
237
|
+
let i = -1;
|
|
238
|
+
for (let k = 0; k < ts.length && ts[k] <= t + 1e-6; k++) i = k;
|
|
239
|
+
return i;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function paint() {
|
|
243
|
+
const t = S.t;
|
|
244
|
+
timeEl.textContent = fmt(t);
|
|
245
|
+
frameEl.textContent = `f ${toFrame(t)} / ${toFrame(dur())}`;
|
|
246
|
+
timeline.setTime(t, S.playing);
|
|
247
|
+
const d = S.data;
|
|
248
|
+
if (!d) return;
|
|
249
|
+
const parts: string[] = [];
|
|
250
|
+
const scene = d.scenes.find((s) => t >= s.from && t < s.end) ?? (t >= dur() ? d.scenes[d.scenes.length - 1] : undefined);
|
|
251
|
+
if (scene) parts.push(`장면 <b>${esc(scene.id)}</b> +${(t - scene.from).toFixed(2)}`);
|
|
252
|
+
const si = lastAtOrBefore(d.sections.map((s) => s.t), t);
|
|
253
|
+
if (si >= 0) parts.push(`구간 <b>${esc(d.sections[si].kind)}</b>`);
|
|
254
|
+
const bi = lastAtOrBefore(d.downbeats, t);
|
|
255
|
+
if (bi >= 0) {
|
|
256
|
+
const inBar = lastAtOrBefore(d.beats, t) - lastAtOrBefore(d.beats, d.downbeats[bi]);
|
|
257
|
+
parts.push(`마디 <b>${bi + 1}.${inBar + 1}</b>`);
|
|
258
|
+
}
|
|
259
|
+
const pi = lastAtOrBefore(d.phrases, t);
|
|
260
|
+
if (pi >= 0) parts.push(`프레이즈 ${pi + 1}`);
|
|
261
|
+
const next = d.drops.find((m) => m.t > t + 1e-6);
|
|
262
|
+
const last = [...d.drops].reverse().find((m) => m.t <= t + 1e-6);
|
|
263
|
+
if (last && t - last.t < 1) parts.push(`<span class="drop">드롭 +${(t - last.t).toFixed(2)}</span>`);
|
|
264
|
+
else if (next) parts.push(`드롭까지 ${(next.t - t).toFixed(2)}`);
|
|
265
|
+
infoEl.innerHTML = parts.join(" · ");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function esc(s: string) {
|
|
269
|
+
return s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function fit() {
|
|
273
|
+
const pad = 20;
|
|
274
|
+
const w = stageBox.clientWidth - pad * 2;
|
|
275
|
+
const h = stageBox.clientHeight - pad * 2;
|
|
276
|
+
const { width, height } = S.meta;
|
|
277
|
+
const k = Math.max(0.02, Math.min(w / width, h / height));
|
|
278
|
+
frameBox.style.width = `${Math.floor(width * k)}px`;
|
|
279
|
+
frameBox.style.height = `${Math.floor(height * k)}px`;
|
|
280
|
+
iframe.style.width = `${width}px`;
|
|
281
|
+
iframe.style.height = `${height}px`;
|
|
282
|
+
iframe.style.transform = `scale(${k})`;
|
|
283
|
+
const metaEl = $<HTMLSpanElement>(".meta");
|
|
284
|
+
const ratio = aspect(width, height);
|
|
285
|
+
metaEl.textContent = `${width}×${height} · ${ratio} · ${S.meta.fps}fps · ${+S.meta.duration.toFixed(3)}초 · ${Math.round(k * 100)}%${S.cfg?.beats?.bpm ? ` · ${S.cfg.beats.bpm} BPM` : ""}${hasAudio ? "" : " · 소리 없음"}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function aspect(w: number, h: number) {
|
|
289
|
+
const g = (a: number, b: number): number => (b ? g(b, a % b) : a);
|
|
290
|
+
const d = g(w, h);
|
|
291
|
+
return `${w / d}:${h / d}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
new ResizeObserver(fit).observe(stageBox);
|
|
295
|
+
|
|
296
|
+
function buildData(kf: KfPage, cfg: StudioConfig | null): TimelineData {
|
|
297
|
+
const D = kf.meta.duration;
|
|
298
|
+
const markers = (kf.markers ?? []).slice().sort((a, b) => a.t - b.t);
|
|
299
|
+
const of = (kind: KfMarker["kind"]) => markers.filter((m) => m.kind === kind);
|
|
300
|
+
let scenes = of("scene").map((m, i, all) => ({ id: m.label, from: m.t, end: i + 1 < all.length ? all[i + 1].t : D }));
|
|
301
|
+
if (!scenes.length && cfg?.kf?.scenes) scenes = cfg.kf.scenes.map((s) => ({ id: s.id, from: s.from, end: s.from + s.dur }));
|
|
302
|
+
const within = (t: number) => t >= -1e-6 && t <= D + 1e-6;
|
|
303
|
+
const sections = (cfg?.beats?.sections ?? of("section").map((m) => ({ t: m.t, kind: m.label }))).filter((s) => within(s.t));
|
|
304
|
+
return {
|
|
305
|
+
duration: D,
|
|
306
|
+
fps: kf.meta.fps,
|
|
307
|
+
scenes,
|
|
308
|
+
sections,
|
|
309
|
+
beats: of("beat").map((m) => m.t),
|
|
310
|
+
downbeats: (cfg?.beats?.downbeats ?? []).filter(within),
|
|
311
|
+
phrases: (cfg?.beats?.phrases ?? []).filter(within),
|
|
312
|
+
drops: of("drop").map((m) => ({ t: m.t, label: m.label })),
|
|
313
|
+
energy: cfg?.beats?.energy,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function setupAudio(cfg: StudioConfig | null, kf: KfPage) {
|
|
318
|
+
// 서버가 kf.json 기준으로 푼 주소가 먼저 — 프로젝트 밖 곡도 /@fs 로 열린다
|
|
319
|
+
let url = cfg?.music ?? null;
|
|
320
|
+
if (!url && kf.meta.music) url = new URL(kf.meta.music, iframe.contentWindow!.location.href).pathname;
|
|
321
|
+
if (url === audioUrl) return;
|
|
322
|
+
audioUrl = url;
|
|
323
|
+
hasAudio = !!url;
|
|
324
|
+
if (url) {
|
|
325
|
+
audio.src = url;
|
|
326
|
+
audio.onerror = () => {
|
|
327
|
+
hasAudio = false;
|
|
328
|
+
showError(`곡을 못 읽었다: ${decodeURI(url!)} — 소리 없이 돈다`);
|
|
329
|
+
fit();
|
|
330
|
+
};
|
|
331
|
+
} else audio.removeAttribute("src");
|
|
332
|
+
if (S.playing) startClock();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function syncCaptions(cfg: StudioConfig | null) {
|
|
336
|
+
const c = cfg?.captions;
|
|
337
|
+
capBtn.hidden = !c?.lines;
|
|
338
|
+
if (!c) return;
|
|
339
|
+
const on = (captions ?? (c.mode === "on" || c.mode === "fit" || c.mode === "burned" ? "on" : "off")) === "on";
|
|
340
|
+
capBtn.setAttribute("aria-pressed", String(on));
|
|
341
|
+
capBtn.title = `화면 자막 ${on ? "끄기" : "켜기"} (C) — kf.json 은 ${c.mode}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 장면 파일이 깨지면 main.ts 가 못 떠서 __kf 가 영영 안 생긴다 — Vite 가 iframe 에 띄운 오류를 바로 옮겨 온다
|
|
345
|
+
function viteError(win: Window): string | null {
|
|
346
|
+
const el = win.document.querySelector("vite-error-overlay");
|
|
347
|
+
const root = el?.shadowRoot;
|
|
348
|
+
if (!root) return null;
|
|
349
|
+
const text = (sel: string) => root.querySelector(sel)?.textContent?.trim() ?? "";
|
|
350
|
+
return [text(".message"), text(".file")].filter(Boolean).join("\n") || "Vite 오류";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function waitFor<T>(get: () => T | null, ms: number): Promise<T | null> {
|
|
354
|
+
const end = performance.now() + ms;
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
const poll = () => {
|
|
357
|
+
const v = get();
|
|
358
|
+
if (v) return resolve(v);
|
|
359
|
+
if (performance.now() > end) return resolve(null);
|
|
360
|
+
setTimeout(poll, 30);
|
|
361
|
+
};
|
|
362
|
+
poll();
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 장면을 저장하면 Vite 가 iframe 만 새로 고친다. 그때마다 다시 붙고 보던 시각으로 돌아간다.
|
|
367
|
+
async function attach() {
|
|
368
|
+
const gen = ++S.generation;
|
|
369
|
+
S.kf = null;
|
|
370
|
+
S.busy = false;
|
|
371
|
+
setStatus("wait", "불러오는 중");
|
|
372
|
+
const win = iframe.contentWindow as KfWindow | null;
|
|
373
|
+
if (!win) return;
|
|
374
|
+
win.addEventListener("pagehide", () => (lastUnload = performance.now()));
|
|
375
|
+
win.addEventListener("error", (e) => showError(`페이지 오류: ${e.message}\n${e.filename}:${e.lineno}`));
|
|
376
|
+
win.addEventListener("unhandledrejection", (e) => showError(`페이지 오류: ${e.reason instanceof Error ? (e.reason.stack ?? e.reason.message) : String(e.reason)}`));
|
|
377
|
+
const found = await waitFor<KfPage | { overlay: string }>(() => {
|
|
378
|
+
if (win.__kf?.seek && win.__kf.meta) return win.__kf as KfPage;
|
|
379
|
+
const msg = viteError(win);
|
|
380
|
+
return msg ? { overlay: msg } : null;
|
|
381
|
+
}, 15000);
|
|
382
|
+
if (gen !== S.generation) return;
|
|
383
|
+
if (found && "overlay" in found) {
|
|
384
|
+
showError(`프로젝트 페이지 오류(저장하면 다시 붙는다)\n${found.overlay}`);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const kf = found;
|
|
388
|
+
if (!kf && errorBox.classList.contains("on")) return;
|
|
389
|
+
if (!kf) {
|
|
390
|
+
showError("window.__kf 가 없다 — main.ts 가 mountProject(...) 를 부르는지, 장면 파일에 오류가 없는지 보라(프로젝트 화면에 Vite 오류가 떠 있을 수 있다)");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
await kf.ready();
|
|
395
|
+
} catch (e) {
|
|
396
|
+
if (gen === S.generation) showError(`ready() 실패: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (gen !== S.generation) return;
|
|
400
|
+
const cfg = await fetch("/__kf/studio.json", { cache: "no-store" })
|
|
401
|
+
.then((r) => (r.ok ? (r.json() as Promise<StudioConfig>) : null))
|
|
402
|
+
.catch(() => null);
|
|
403
|
+
if (gen !== S.generation) return;
|
|
404
|
+
S.kf = kf;
|
|
405
|
+
S.cfg = cfg;
|
|
406
|
+
S.meta = { fps: kf.meta.fps, width: kf.meta.width, height: kf.meta.height, duration: kf.meta.duration };
|
|
407
|
+
S.t = Math.min(S.t, dur());
|
|
408
|
+
S.data = buildData(kf, cfg);
|
|
409
|
+
document.title = `${cfg?.name ?? "kf"} · kf preview`;
|
|
410
|
+
$<HTMLSpanElement>(".name").textContent = cfg?.name ?? "";
|
|
411
|
+
setupAudio(cfg, kf);
|
|
412
|
+
syncCaptions(cfg);
|
|
413
|
+
fit();
|
|
414
|
+
timeline.setData(S.data);
|
|
415
|
+
clearError();
|
|
416
|
+
setStatus("ok", "연결됨");
|
|
417
|
+
requestSeek(S.playing ? clockNow() : S.t);
|
|
418
|
+
paint();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
iframe.addEventListener("load", () => void attach());
|
|
422
|
+
|
|
423
|
+
// 페이지가 떠나기 시작한 시각 — Vite 가 이미 새로 고치는 중이면 다시 고치지 않는다
|
|
424
|
+
let lastUnload = 0;
|
|
425
|
+
let changedAt: number | null = null;
|
|
426
|
+
let fallback = 0;
|
|
427
|
+
new EventSource("/__kf/events").onmessage = (e) => {
|
|
428
|
+
const m = JSON.parse(e.data) as { ev: string; message?: string; where?: string; frame?: string };
|
|
429
|
+
if (m.ev === "error") {
|
|
430
|
+
showError(`프로젝트 코드 오류(고쳐 저장하면 다시 붙는다)\n${m.message}\n${m.where}\n${m.frame}`.trim());
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
changedAt ??= performance.now();
|
|
434
|
+
clearTimeout(fallback);
|
|
435
|
+
fallback = window.setTimeout(() => {
|
|
436
|
+
// SSE 가 Vite 의 새로 고침보다 조금 늦게 닿기도 해서 바로 앞의 새로 고침도 이번 변경을 반영한 것으로 본다
|
|
437
|
+
if (changedAt !== null && lastUnload < changedAt - 400) iframe.contentWindow?.location.reload();
|
|
438
|
+
changedAt = null;
|
|
439
|
+
}, 700);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// ---- 조작 ----
|
|
443
|
+
|
|
444
|
+
function boundaries(): number[] {
|
|
445
|
+
const d = S.data;
|
|
446
|
+
if (!d) return [0, dur()];
|
|
447
|
+
return [0, dur(), ...d.scenes.map((s) => s.from), ...d.sections.map((s) => s.t), ...d.drops.map((m) => m.t)].sort((a, b) => a - b);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function jump(list: number[], dir: 1 | -1) {
|
|
451
|
+
const t = S.t;
|
|
452
|
+
const eps = 0.5 / fps();
|
|
453
|
+
const target = dir > 0 ? list.find((x) => x > t + eps) : [...list].reverse().find((x) => x < t - eps);
|
|
454
|
+
if (target !== undefined) setTime(target, true);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function act(name: string) {
|
|
458
|
+
const f = 1 / fps();
|
|
459
|
+
if (name === "play") S.playing ? pause() : play();
|
|
460
|
+
else if (name === "first") setTime(0);
|
|
461
|
+
else if (name === "last") setTime(dur());
|
|
462
|
+
else if (name === "prev") {
|
|
463
|
+
pause();
|
|
464
|
+
setTime(S.t - f);
|
|
465
|
+
} else if (name === "next") {
|
|
466
|
+
pause();
|
|
467
|
+
setTime(S.t + f);
|
|
468
|
+
} else if (name === "captions") {
|
|
469
|
+
// iframe 만 다시 연다 — attach 가 보던 시각으로 돌아간다
|
|
470
|
+
captions = capBtn.getAttribute("aria-pressed") === "true" ? "off" : "on";
|
|
471
|
+
const u = new URL(location.href);
|
|
472
|
+
u.searchParams.set("captions", captions);
|
|
473
|
+
history.replaceState(null, "", u);
|
|
474
|
+
iframe.src = `/index.html?captions=${captions}`;
|
|
475
|
+
} else if (name === "loop") {
|
|
476
|
+
S.loop = !S.loop;
|
|
477
|
+
loopBtn.setAttribute("aria-pressed", String(S.loop));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
app.querySelectorAll<HTMLButtonElement>("[data-act]").forEach((b) => b.addEventListener("click", () => act(b.dataset.act!)));
|
|
482
|
+
stageBox.addEventListener("click", () => act("play"));
|
|
483
|
+
|
|
484
|
+
window.addEventListener("keydown", (e) => {
|
|
485
|
+
if (e.metaKey || e.ctrlKey) return;
|
|
486
|
+
const f = 1 / fps();
|
|
487
|
+
const k = e.key;
|
|
488
|
+
if (k === " ") act("play");
|
|
489
|
+
else if (k === "ArrowLeft" || k === "ArrowRight") {
|
|
490
|
+
const dir = k === "ArrowRight" ? 1 : -1;
|
|
491
|
+
pause();
|
|
492
|
+
if (e.altKey) jump(S.data?.beats ?? [], dir);
|
|
493
|
+
else setTime(S.t + dir * (e.shiftKey ? 1 : f));
|
|
494
|
+
} else if (k === "ArrowUp" || k === "ArrowDown") jump(boundaries(), k === "ArrowDown" ? 1 : -1);
|
|
495
|
+
else if (k === "Home") setTime(0);
|
|
496
|
+
else if (k === "End") setTime(dur());
|
|
497
|
+
else if (k === "l" || k === "L") act("loop");
|
|
498
|
+
else if ((k === "c" || k === "C") && !capBtn.hidden) act("captions");
|
|
499
|
+
else return;
|
|
500
|
+
e.preventDefault();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
requestAnimationFrame(tick);
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0c0d10;
|
|
3
|
+
--panel: #14161b;
|
|
4
|
+
--raised: #1b1e25;
|
|
5
|
+
--line: #262a33;
|
|
6
|
+
--ink: #e9ebf0;
|
|
7
|
+
--dim: #8b91a0;
|
|
8
|
+
--faint: #5a606d;
|
|
9
|
+
--accent: #ff6b9a;
|
|
10
|
+
--sky: #7cc4ff;
|
|
11
|
+
--danger: #ff6b6b;
|
|
12
|
+
--mono: "JetBrains Mono", "SF Mono", ui-monospace, Menlo, "Apple SD Gothic Neo", monospace;
|
|
13
|
+
--sans: "Pretendard", "Apple SD Gothic Neo", system-ui, sans-serif;
|
|
14
|
+
color-scheme: dark;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
|
|
19
|
+
html, body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
height: 100%;
|
|
22
|
+
background: var(--bg);
|
|
23
|
+
color: var(--ink);
|
|
24
|
+
font: 13px/1.4 var(--sans);
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
-webkit-font-smoothing: antialiased;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#app {
|
|
30
|
+
display: grid;
|
|
31
|
+
grid-template-rows: auto 1fr auto auto;
|
|
32
|
+
height: 100%;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.top {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: baseline;
|
|
38
|
+
gap: 16px;
|
|
39
|
+
padding: 10px 16px;
|
|
40
|
+
border-bottom: 1px solid var(--line);
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
}
|
|
44
|
+
.top .name { font-weight: 600; letter-spacing: -0.01em; }
|
|
45
|
+
.top .meta { color: var(--dim); font-family: var(--mono); font-size: 12px; }
|
|
46
|
+
.top .grow { flex: 1; }
|
|
47
|
+
.top .keys { color: var(--faint); font-family: var(--mono); font-size: 11px; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
48
|
+
.top .status { font-family: var(--mono); font-size: 12px; color: var(--dim); }
|
|
49
|
+
.top .status[data-state="ok"]::before,
|
|
50
|
+
.top .status[data-state="wait"]::before,
|
|
51
|
+
.top .status[data-state="err"]::before {
|
|
52
|
+
content: "";
|
|
53
|
+
display: inline-block;
|
|
54
|
+
width: 7px;
|
|
55
|
+
height: 7px;
|
|
56
|
+
margin-right: 7px;
|
|
57
|
+
border-radius: 50%;
|
|
58
|
+
vertical-align: 1px;
|
|
59
|
+
background: var(--sky);
|
|
60
|
+
}
|
|
61
|
+
.top .status[data-state="wait"]::before { background: var(--faint); }
|
|
62
|
+
.top .status[data-state="err"]::before { background: var(--danger); }
|
|
63
|
+
|
|
64
|
+
.error {
|
|
65
|
+
position: absolute;
|
|
66
|
+
z-index: 2;
|
|
67
|
+
top: 0;
|
|
68
|
+
left: 0;
|
|
69
|
+
right: 0;
|
|
70
|
+
display: none;
|
|
71
|
+
padding: 8px 16px;
|
|
72
|
+
background: rgba(42, 20, 24, 0.94);
|
|
73
|
+
color: #ffc9cf;
|
|
74
|
+
border-bottom: 1px solid #4a1f27;
|
|
75
|
+
font-family: var(--mono);
|
|
76
|
+
font-size: 12px;
|
|
77
|
+
white-space: pre-wrap;
|
|
78
|
+
max-height: 40%;
|
|
79
|
+
overflow: auto;
|
|
80
|
+
}
|
|
81
|
+
.error.on { display: block; }
|
|
82
|
+
|
|
83
|
+
.stage {
|
|
84
|
+
position: relative;
|
|
85
|
+
min-height: 0;
|
|
86
|
+
display: grid;
|
|
87
|
+
place-items: center;
|
|
88
|
+
background:
|
|
89
|
+
linear-gradient(45deg, #101216 25%, transparent 25%, transparent 75%, #101216 75%) 0 0 / 24px 24px,
|
|
90
|
+
linear-gradient(45deg, #101216 25%, transparent 25%, transparent 75%, #101216 75%) 12px 12px / 24px 24px,
|
|
91
|
+
var(--bg);
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
}
|
|
94
|
+
.frame {
|
|
95
|
+
position: relative;
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
background: #000;
|
|
98
|
+
box-shadow: 0 0 0 1px var(--line);
|
|
99
|
+
}
|
|
100
|
+
.frame iframe {
|
|
101
|
+
position: absolute;
|
|
102
|
+
left: 0;
|
|
103
|
+
top: 0;
|
|
104
|
+
border: 0;
|
|
105
|
+
transform-origin: 0 0;
|
|
106
|
+
background: #000;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.bar {
|
|
110
|
+
display: flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
gap: 4px;
|
|
113
|
+
padding: 8px 12px;
|
|
114
|
+
border-top: 1px solid var(--line);
|
|
115
|
+
background: var(--panel);
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
overflow: hidden;
|
|
118
|
+
}
|
|
119
|
+
.bar button {
|
|
120
|
+
display: inline-grid;
|
|
121
|
+
place-items: center;
|
|
122
|
+
width: 30px;
|
|
123
|
+
height: 30px;
|
|
124
|
+
padding: 0;
|
|
125
|
+
border: 0;
|
|
126
|
+
border-radius: 6px;
|
|
127
|
+
background: transparent;
|
|
128
|
+
color: var(--ink);
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
}
|
|
131
|
+
.bar button:hover { background: var(--raised); }
|
|
132
|
+
.bar button.play { width: 38px; background: var(--raised); }
|
|
133
|
+
.bar button[aria-pressed="true"] { color: var(--sky); }
|
|
134
|
+
.bar button svg { width: 16px; height: 16px; fill: currentColor; }
|
|
135
|
+
.bar .time {
|
|
136
|
+
margin-left: 10px;
|
|
137
|
+
font-family: var(--mono);
|
|
138
|
+
font-size: 15px;
|
|
139
|
+
font-variant-numeric: tabular-nums;
|
|
140
|
+
letter-spacing: -0.02em;
|
|
141
|
+
}
|
|
142
|
+
.bar .frameno, .bar .info {
|
|
143
|
+
font-family: var(--mono);
|
|
144
|
+
font-size: 12px;
|
|
145
|
+
color: var(--dim);
|
|
146
|
+
font-variant-numeric: tabular-nums;
|
|
147
|
+
}
|
|
148
|
+
.bar .frameno { margin-left: 8px; }
|
|
149
|
+
.bar .info { margin-left: 16px; overflow: hidden; text-overflow: ellipsis; }
|
|
150
|
+
.bar .info b { color: var(--ink); font-weight: 500; }
|
|
151
|
+
.bar .info .drop { color: var(--accent); }
|
|
152
|
+
|
|
153
|
+
.timeline {
|
|
154
|
+
position: relative;
|
|
155
|
+
height: 156px;
|
|
156
|
+
border-top: 1px solid var(--line);
|
|
157
|
+
background: var(--panel);
|
|
158
|
+
cursor: crosshair;
|
|
159
|
+
touch-action: none;
|
|
160
|
+
}
|
|
161
|
+
.timeline canvas { display: block; width: 100%; height: 100%; }
|
|
162
|
+
|
|
163
|
+
@media (max-width: 1100px) {
|
|
164
|
+
.top .keys { display: none; }
|
|
165
|
+
}
|