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,328 @@
|
|
|
1
|
+
// 페이지 안에서 도는 탐침. addInitScript 로 페이지 스크립트보다 먼저 들어가야
|
|
2
|
+
// 장면이 처음 부르는 fillText 부터 잡힌다. 함수 본문만 직렬화되므로 바깥 이름을 쓰지 않는다.
|
|
3
|
+
export function probe(): void {
|
|
4
|
+
const w = window as any;
|
|
5
|
+
if (w.__kfCheck) return;
|
|
6
|
+
|
|
7
|
+
const C2D: any = CanvasRenderingContext2D.prototype;
|
|
8
|
+
const O2D: any = typeof OffscreenCanvasRenderingContext2D !== "undefined" ? OffscreenCanvasRenderingContext2D.prototype : null;
|
|
9
|
+
// 판정용 작은 판은 OffscreenCanvas 라 그쪽 원본 fillText 를 따로 쥔다(다른 원형으로 부르면 Illegal invocation).
|
|
10
|
+
const origFill = (O2D || C2D).fillText;
|
|
11
|
+
const hits: any[] = [];
|
|
12
|
+
const rects = new Map<any, any>();
|
|
13
|
+
const SUSPECT = /undefined|NaN|\[object |null|Infinity/;
|
|
14
|
+
|
|
15
|
+
function canvasRect(cv: HTMLCanvasElement) {
|
|
16
|
+
let r = rects.get(cv);
|
|
17
|
+
if (!r) {
|
|
18
|
+
const b = cv.getBoundingClientRect();
|
|
19
|
+
r = { x: b.left, y: b.top, sx: b.width / (cv.width || 1), sy: b.height / (cv.height || 1) };
|
|
20
|
+
rects.set(cv, r);
|
|
21
|
+
}
|
|
22
|
+
return r;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 나쁜 글자가 나온 순간만 스택을 떠서 어느 장면 파일 몇째 줄인지 알려 준다.
|
|
26
|
+
function caller(): string | undefined {
|
|
27
|
+
const limit = (Error as any).stackTraceLimit;
|
|
28
|
+
(Error as any).stackTraceLimit = 30;
|
|
29
|
+
const stack = new Error().stack || "";
|
|
30
|
+
(Error as any).stackTraceLimit = limit;
|
|
31
|
+
// 탐침 자신은 주소 없는 <anonymous> 로 찍힌다. 주소가 붙은 첫 줄들이 장면 코드다.
|
|
32
|
+
const lines = stack.split("\n").slice(1).map((s) => s.trim()).filter((s) => /(https?|file):\/\//.test(s));
|
|
33
|
+
return lines.slice(0, 3).join(" <- ") || undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function record(ctx: any, raw: unknown, x: number, y: number) {
|
|
37
|
+
const text = String(raw);
|
|
38
|
+
if (!text.trim()) return;
|
|
39
|
+
const m = /(\d+(?:\.\d+)?)px/.exec(ctx.font);
|
|
40
|
+
const hit: any = {
|
|
41
|
+
text,
|
|
42
|
+
via: "canvas",
|
|
43
|
+
font: ctx.font,
|
|
44
|
+
sizePx: m ? parseFloat(m[1]) : 0,
|
|
45
|
+
alpha: ctx.globalAlpha,
|
|
46
|
+
};
|
|
47
|
+
if (typeof raw !== "string") hit.argType = raw === null ? "null" : typeof raw;
|
|
48
|
+
if (SUSPECT.test(text) || hit.argType) hit.where = caller();
|
|
49
|
+
const cv = ctx.canvas;
|
|
50
|
+
if (typeof HTMLCanvasElement !== "undefined" && cv instanceof HTMLCanvasElement && cv.closest("[data-kf-lyrics]")) hit.lyrics = true;
|
|
51
|
+
if (typeof HTMLCanvasElement !== "undefined" && cv instanceof HTMLCanvasElement && cv.isConnected) {
|
|
52
|
+
const mt = ctx.measureText(text);
|
|
53
|
+
const l = x - mt.actualBoundingBoxLeft;
|
|
54
|
+
const r = x + mt.actualBoundingBoxRight;
|
|
55
|
+
const t = y - mt.actualBoundingBoxAscent;
|
|
56
|
+
const b = y + mt.actualBoundingBoxDescent;
|
|
57
|
+
const M = ctx.getTransform();
|
|
58
|
+
const cr = canvasRect(cv);
|
|
59
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
60
|
+
for (const [px, py] of [[l, t], [r, t], [l, b], [r, b]]) {
|
|
61
|
+
const qx = (M.a * px + M.c * py + M.e) * cr.sx + cr.x;
|
|
62
|
+
const qy = (M.b * px + M.d * py + M.f) * cr.sy + cr.y;
|
|
63
|
+
x0 = Math.min(x0, qx); y0 = Math.min(y0, qy); x1 = Math.max(x1, qx); y1 = Math.max(y1, qy);
|
|
64
|
+
}
|
|
65
|
+
hit.box = { x0, y0, x1, y1 };
|
|
66
|
+
hit.sizePx *= Math.hypot(M.b, M.d) * cr.sy;
|
|
67
|
+
}
|
|
68
|
+
hits.push(hit);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const proto of [C2D, O2D]) {
|
|
72
|
+
if (!proto) continue;
|
|
73
|
+
const fill = proto.fillText;
|
|
74
|
+
const stroke = proto.strokeText;
|
|
75
|
+
proto.fillText = function (this: any, text: unknown, x: number, y: number, maxWidth?: number) {
|
|
76
|
+
try { record(this, text, x, y); } catch {}
|
|
77
|
+
return maxWidth === undefined ? fill.call(this, text, x, y) : fill.call(this, text, x, y, maxWidth);
|
|
78
|
+
};
|
|
79
|
+
proto.strokeText = function (this: any, text: unknown, x: number, y: number, maxWidth?: number) {
|
|
80
|
+
try { record(this, text, x, y); } catch {}
|
|
81
|
+
return maxWidth === undefined ? stroke.call(this, text, x, y) : stroke.call(this, text, x, y, maxWidth);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function selector(el: Element | null): string {
|
|
86
|
+
const parts: string[] = [];
|
|
87
|
+
for (let e = el, i = 0; e && e !== document.body && i < 3; e = e.parentElement, i++) {
|
|
88
|
+
let s = e.tagName.toLowerCase();
|
|
89
|
+
if (e.id) s += "#" + e.id;
|
|
90
|
+
else if (typeof e.className === "string" && e.className.trim()) s += "." + e.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
91
|
+
parts.unshift(s);
|
|
92
|
+
}
|
|
93
|
+
// mountProject 가 장면 뿌리에 data-kf-scene 을 단다 — 고칠 파일 이름이 곧 그것이다.
|
|
94
|
+
const scene = el?.closest("[data-kf-scene]")?.getAttribute("data-kf-scene");
|
|
95
|
+
return scene ? `scenes/${scene}.ts · ${parts.join(" > ")}` : parts.join(" > ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 스크린리더 전용 글(1px 상자·clip·clip-path 로 숨김)은 화면에 없다. checkVisibility 는 이걸 못 거른다.
|
|
99
|
+
const hiddenBox = new Map<Element, boolean>();
|
|
100
|
+
function srOnly(el: Element | null, depth = 0): boolean {
|
|
101
|
+
if (!el || el === document.body || depth > 3) return false;
|
|
102
|
+
const c = hiddenBox.get(el);
|
|
103
|
+
if (c !== undefined) return c;
|
|
104
|
+
const cs = getComputedStyle(el);
|
|
105
|
+
const r = el.getBoundingClientRect();
|
|
106
|
+
const v =
|
|
107
|
+
((r.width <= 1 || r.height <= 1) && cs.overflow !== "visible") ||
|
|
108
|
+
((cs.position === "absolute" || cs.position === "fixed") && !!cs.clip && cs.clip !== "auto") ||
|
|
109
|
+
/inset\(\s*(50|100)%|circle\(\s*0/.test(cs.clipPath || "") ||
|
|
110
|
+
srOnly(el.parentElement, depth + 1);
|
|
111
|
+
hiddenBox.set(el, v);
|
|
112
|
+
return v;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function domHits(): any[] {
|
|
116
|
+
const root = document.getElementById("stage") || document.body;
|
|
117
|
+
if (!root) return [];
|
|
118
|
+
hiddenBox.clear();
|
|
119
|
+
const out: any[] = [];
|
|
120
|
+
const opacity = new Map<Element, number>();
|
|
121
|
+
const effOpacity = (el: Element | null): number => {
|
|
122
|
+
if (!el) return 1;
|
|
123
|
+
const c = opacity.get(el);
|
|
124
|
+
if (c !== undefined) return c;
|
|
125
|
+
const v = parseFloat(getComputedStyle(el).opacity || "1") * effOpacity(el.parentElement);
|
|
126
|
+
opacity.set(el, v);
|
|
127
|
+
return v;
|
|
128
|
+
};
|
|
129
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
130
|
+
const range = document.createRange();
|
|
131
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
132
|
+
const text = n.nodeValue || "";
|
|
133
|
+
if (!text.trim()) continue;
|
|
134
|
+
const el = n.parentElement;
|
|
135
|
+
if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(el.tagName)) continue;
|
|
136
|
+
if ((el as any).checkVisibility && !(el as any).checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue;
|
|
137
|
+
range.selectNodeContents(n);
|
|
138
|
+
const b = range.getBoundingClientRect();
|
|
139
|
+
if (b.width < 1 || b.height < 1) continue;
|
|
140
|
+
const cs = getComputedStyle(el);
|
|
141
|
+
const scale = (el as HTMLElement).offsetWidth ? el.getBoundingClientRect().width / (el as HTMLElement).offsetWidth : 1;
|
|
142
|
+
const hit: any = {
|
|
143
|
+
text: text.trim(),
|
|
144
|
+
via: el.closest("[data-kf-captions]") ? "caption" : "dom",
|
|
145
|
+
lyrics: !!el.closest("[data-kf-lyrics]") || undefined,
|
|
146
|
+
font: `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`,
|
|
147
|
+
sizePx: parseFloat(cs.fontSize) * (scale || 1),
|
|
148
|
+
alpha: effOpacity(el),
|
|
149
|
+
box: { x0: b.left, y0: b.top, x1: b.right, y1: b.bottom },
|
|
150
|
+
};
|
|
151
|
+
// 캔버스 자식(HTML-in-Canvas)은 DOM 으로 안 보이고 텍스처가 되어 3D 화면 어딘가에 그려진다. 스크린리더 전용 글은 화면에 없다.
|
|
152
|
+
// 둘 다 화면 자리를 빼 대비·화면 밖·글 크기 판정에서만 빠지게 한다 — undefined·두부 같은 글 내용 판정에는 남는다
|
|
153
|
+
if (el.closest("canvas") || srOnly(el)) delete hit.box;
|
|
154
|
+
if (SUSPECT.test(hit.text)) hit.where = selector(el);
|
|
155
|
+
out.push(hit);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 글리프 판정용 작은 판. 가로챈 fillText 를 거치지 않게 원본을 쓴다.
|
|
161
|
+
const pad = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(64, 64) : null;
|
|
162
|
+
const pctx: any = pad ? pad.getContext("2d", { willReadFrequently: true }) : null;
|
|
163
|
+
const GENERIC = /^(serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-\w+|emoji|math|fangsong)$/i;
|
|
164
|
+
|
|
165
|
+
function ink(font: string, ch: string): Uint8ClampedArray {
|
|
166
|
+
pctx.clearRect(0, 0, 64, 64);
|
|
167
|
+
pctx.font = font;
|
|
168
|
+
pctx.fillStyle = "#000";
|
|
169
|
+
// middle·center 는 첫 글꼴의 높이·폭으로 자리를 잡아, 같은 대체 글리프도 스택마다 다른 자리에 찍힌다.
|
|
170
|
+
// 글자 시작점과 기본 기준선은 어느 글꼴이든 같은 자리다.
|
|
171
|
+
pctx.textBaseline = "alphabetic";
|
|
172
|
+
pctx.textAlign = "left";
|
|
173
|
+
origFill.call(pctx, ch, 8, 46);
|
|
174
|
+
return pctx.getImageData(0, 0, 64, 64).data;
|
|
175
|
+
}
|
|
176
|
+
function same(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
|
|
177
|
+
for (let i = 3; i < a.length; i += 4) if (a[i] !== b[i]) return false;
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
function empty(a: Uint8ClampedArray): boolean {
|
|
181
|
+
for (let i = 3; i < a.length; i += 4) if (a[i] > 16) return false;
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
function splitFont(font: string) {
|
|
185
|
+
const m = /^(.*?)(\d+(?:\.\d+)?)px(?:\/\S+)?\s+(.+)$/.exec(font.trim());
|
|
186
|
+
if (!m) return null;
|
|
187
|
+
const families = m[3];
|
|
188
|
+
const primary = (families.match(/^\s*("[^"]+"|'[^']+'|[^,]+)/)?.[1] || "").trim();
|
|
189
|
+
return { pre: m[1], families, primary };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 글꼴마다 글자마다: ok · stack(스택에 적힌 다음 글꼴이 그림) · fallback(시스템 글꼴로 넘어감) · missing-font(첫 글꼴이 안 불러와짐) · tofu(어떤 글꼴에도 없음) · blank(빈 글리프)
|
|
193
|
+
async function glyphs(pairs: [string, string][]): Promise<Record<string, string>> {
|
|
194
|
+
const out: Record<string, string> = {};
|
|
195
|
+
if (!pctx) return out;
|
|
196
|
+
const fonts = [...new Set(pairs.map((p) => p[0]))];
|
|
197
|
+
for (const f of fonts) {
|
|
198
|
+
const chars = pairs.filter((p) => p[0] === f).map((p) => p[1]).join("");
|
|
199
|
+
try { await document.fonts.load(f, chars); } catch {}
|
|
200
|
+
}
|
|
201
|
+
const lr = ink("40px LastResort", "A");
|
|
202
|
+
const lastResortUsable = !same(lr, ink("40px serif", "A")) && !same(lr, ink("40px sans-serif", "A"));
|
|
203
|
+
for (const [f, ch] of pairs) {
|
|
204
|
+
const key = f + "\u0000" + ch;
|
|
205
|
+
const sp = splitFont(f);
|
|
206
|
+
if (!sp) { out[key] = "ok"; continue; }
|
|
207
|
+
const full = `${sp.pre}40px ${sp.families}`;
|
|
208
|
+
const got = ink(full, ch);
|
|
209
|
+
if (empty(got)) { out[key] = "blank"; continue; }
|
|
210
|
+
if (same(got, ink(full, "\u{10FFFD}")) || (lastResortUsable && same(got, ink("40px LastResort", ch)))) {
|
|
211
|
+
out[key] = "tofu";
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const primary = sp.primary.replace(/^["']|["']$/g, "");
|
|
215
|
+
if (GENERIC.test(primary)) { out[key] = "ok"; continue; }
|
|
216
|
+
if (covers(sp.pre, sp.primary, ch)) { out[key] = "ok"; continue; }
|
|
217
|
+
if (!loaded(sp.pre, sp.primary)) { out[key] = "missing-font"; continue; }
|
|
218
|
+
// 모노 글꼴 뒤에 한글 글꼴을 일부러 적어 두는 스택이 흔하다 — 그 글꼴이 실제로 그렸으면 괜찮다.
|
|
219
|
+
out[key] = "fallback";
|
|
220
|
+
for (const fam of splitFamilies(sp.families).slice(1)) {
|
|
221
|
+
// ui-monospace 처럼 스택 가운데 낀 일반 이름은 건너뛰고 뒤에 적힌 글꼴을 본다.
|
|
222
|
+
if (GENERIC.test(fam.replace(/^["']|["']$/g, ""))) continue;
|
|
223
|
+
if (!covers(sp.pre, fam, ch)) continue;
|
|
224
|
+
if (same(got, ink(`${sp.pre}40px ${fam}, serif`, ch))) out[key] = "stack";
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
function covers(pre: string, family: string, ch: string): boolean {
|
|
231
|
+
return same(ink(`${pre}40px ${family}, serif`, ch), ink(`${pre}40px ${family}, monospace`, ch));
|
|
232
|
+
}
|
|
233
|
+
const loadedCache = new Map<string, boolean>();
|
|
234
|
+
function loaded(pre: string, family: string): boolean {
|
|
235
|
+
const k = pre + family;
|
|
236
|
+
let v = loadedCache.get(k);
|
|
237
|
+
if (v === undefined) loadedCache.set(k, (v = covers(pre, family, "a") || covers(pre, family, "0")));
|
|
238
|
+
return v;
|
|
239
|
+
}
|
|
240
|
+
function splitFamilies(families: string): string[] {
|
|
241
|
+
return (families.match(/"[^"]+"|'[^']+'|[^,]+/g) || []).map((x) => x.trim()).filter(Boolean);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 카메라 층 = 화면의 30% 넘게 덮고 글을 품은 층 중 transform 이 걸린 것. 글 없는 큰 층(바탕 흐름·모눈)은 카메라로 치지 않는다.
|
|
245
|
+
// 열쇠는 장면 뿌리에서 자식 번호로 내려간 길 — 탭이 달라도 같은 층이 같은 열쇠가 된다.
|
|
246
|
+
const textInside = new WeakMap<Element, boolean>();
|
|
247
|
+
function hasText(el: Element): boolean {
|
|
248
|
+
if (textInside.get(el)) return true;
|
|
249
|
+
const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
250
|
+
for (let n = tw.nextNode(); n; n = tw.nextNode()) {
|
|
251
|
+
if ((n.nodeValue || "").trim()) {
|
|
252
|
+
textInside.set(el, true);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
function camera() {
|
|
259
|
+
const root = document.getElementById("stage") || document.body;
|
|
260
|
+
const area = innerWidth * innerHeight;
|
|
261
|
+
const layers: any[] = [];
|
|
262
|
+
const walk = (el: Element, path: string, scene: string | null, depth: number) => {
|
|
263
|
+
Array.from(el.children).forEach((c, i) => {
|
|
264
|
+
if (!(c instanceof HTMLElement) || c.offsetWidth * c.offsetHeight < area * 0.3) return;
|
|
265
|
+
const cs = getComputedStyle(c);
|
|
266
|
+
if (cs.visibility === "hidden" || parseFloat(cs.opacity || "1") < 0.05) return;
|
|
267
|
+
const sc = c.getAttribute("data-kf-scene") ?? scene;
|
|
268
|
+
const key = `${path}/${i}`;
|
|
269
|
+
if (cs.transform && cs.transform !== "none" && hasText(c)) {
|
|
270
|
+
const m = new DOMMatrixReadOnly(cs.transform);
|
|
271
|
+
layers.push({ key, scene: sc, sel: selector(c), a: m.a, b: m.b, e: m.e, f: m.f, flat: m.is2D });
|
|
272
|
+
}
|
|
273
|
+
if (depth < 8) walk(c, key, sc, depth + 1);
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
walk(root, "", null, 0);
|
|
277
|
+
// 주인공 — 장면이 data-kf-hero 로 짚은 요소(무대의 bigWord 가 단다). 없으면 검사가 가장 큰 글을 주인공으로 본다
|
|
278
|
+
const heroes: any[] = [];
|
|
279
|
+
for (const el of Array.from(root.querySelectorAll("[data-kf-hero]"))) {
|
|
280
|
+
if ((el as any).checkVisibility && !(el as any).checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue;
|
|
281
|
+
const b = el.getBoundingClientRect();
|
|
282
|
+
if (b.width < 1 || b.height < 1) continue;
|
|
283
|
+
heroes.push({ scene: el.closest("[data-kf-scene]")?.getAttribute("data-kf-scene") ?? null, sel: selector(el), box: { x0: b.left, y0: b.top, x1: b.right, y1: b.bottom } });
|
|
284
|
+
}
|
|
285
|
+
return { layers, heroes };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
w.__kfCheck = {
|
|
289
|
+
take(): any[] {
|
|
290
|
+
const all = hits.splice(0, hits.length).concat(domHits());
|
|
291
|
+
rects.clear();
|
|
292
|
+
return all;
|
|
293
|
+
},
|
|
294
|
+
camera,
|
|
295
|
+
clear() {
|
|
296
|
+
hits.length = 0;
|
|
297
|
+
rects.clear();
|
|
298
|
+
},
|
|
299
|
+
glyphs,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 옛 방식(window.seek(t) 하나만 있는 페이지)을 페이지 계약 꼴로 감싼다.
|
|
304
|
+
// 계약을 따르는 페이지면 아무것도 하지 않는다.
|
|
305
|
+
export function legacyShim(o: { fps: number; duration: number }): string {
|
|
306
|
+
const w = window as any;
|
|
307
|
+
if (w.__kf) return "kf";
|
|
308
|
+
const seek = typeof w.seek === "function" ? w.seek : typeof w.__setTime === "function" ? w.__setTime : null;
|
|
309
|
+
if (!seek) return "none";
|
|
310
|
+
const stage = document.getElementById("stage") || document.querySelector("canvas") || document.body;
|
|
311
|
+
const r = stage.getBoundingClientRect();
|
|
312
|
+
const dur = o.duration || w.T?.dur || w.DURATION || 0;
|
|
313
|
+
w.__kf = {
|
|
314
|
+
meta: { fps: o.fps, width: Math.round(r.width) || innerWidth, height: Math.round(r.height) || innerHeight, duration: dur },
|
|
315
|
+
mode: "seek",
|
|
316
|
+
legacy: true,
|
|
317
|
+
async ready() {
|
|
318
|
+
const t0 = performance.now();
|
|
319
|
+
while ("__ready" in w && w.__ready !== true && performance.now() - t0 < 30000) await new Promise((res) => setTimeout(res, 50));
|
|
320
|
+
await document.fonts.ready;
|
|
321
|
+
},
|
|
322
|
+
// 옛 seek 는 동기로 다 그린다. 스크린샷은 어차피 새 프레임을 기다린다.
|
|
323
|
+
async seek(t: number) {
|
|
324
|
+
seek(t);
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
return "legacy";
|
|
328
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Finding, Report } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const sec = (t: number) => t.toFixed(2);
|
|
4
|
+
|
|
5
|
+
function span(f: Finding): string {
|
|
6
|
+
return Math.abs(f.to - f.from) < 0.0005 ? `${sec(f.from)}초` : `${sec(f.from)}–${sec(f.to)}초`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 그림을 못 보는 모델도 이것만 읽고 고칠 수 있게: 무엇이·언제·어디서·어떻게 고치나를 한 덩어리로.
|
|
10
|
+
export function formatReport(r: Report, o: { maxPerRule?: number } = {}): string {
|
|
11
|
+
const max = o.maxPerRule ?? 8;
|
|
12
|
+
const kind = r.kind === "page" ? "페이지" : "영상 파일";
|
|
13
|
+
const lines: string[] = [];
|
|
14
|
+
lines.push(`kf check — ${r.target}`);
|
|
15
|
+
lines.push(`${kind} · ${sec(r.meta.duration)}초 · ${r.meta.width}×${r.meta.height} · ${Math.round(r.meta.fps * 100) / 100}fps · 프레임 ${r.sampled.frames}장 훑음 · ${r.elapsedSec.toFixed(1)}초 걸림`);
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push(`오류 ${r.counts.error} · 경고 ${r.counts.warn}`);
|
|
18
|
+
|
|
19
|
+
const byRule = new Map<string, Finding[]>();
|
|
20
|
+
for (const f of r.findings) {
|
|
21
|
+
const key = `${f.severity}\u0000${f.rule}`;
|
|
22
|
+
if (!byRule.has(key)) byRule.set(key, []);
|
|
23
|
+
byRule.get(key)!.push(f);
|
|
24
|
+
}
|
|
25
|
+
for (const [key, list] of byRule) {
|
|
26
|
+
const [severity, rule] = key.split("\u0000");
|
|
27
|
+
lines.push("");
|
|
28
|
+
lines.push(`[${severity === "error" ? "오류" : "경고"}] ${rule} — ${list.length}건`);
|
|
29
|
+
for (const f of list.slice(0, max)) {
|
|
30
|
+
lines.push(` ${span(f)} ${f.message}`);
|
|
31
|
+
const where = f.detail?.where;
|
|
32
|
+
if (typeof where === "string" && where) lines.push(` 자리: ${where}`);
|
|
33
|
+
}
|
|
34
|
+
if (list.length > max) lines.push(` … 외 ${list.length - max}건 (JSON 에 다 있다)`);
|
|
35
|
+
}
|
|
36
|
+
if (r.skipped.length) {
|
|
37
|
+
lines.push("");
|
|
38
|
+
lines.push("못 본 것:");
|
|
39
|
+
for (const s of r.skipped) lines.push(` - ${s}`);
|
|
40
|
+
}
|
|
41
|
+
lines.push("");
|
|
42
|
+
lines.push(r.ok ? `결과: 통과${r.counts.warn ? ` (경고 ${r.counts.warn})` : ""}` : `결과: 실패 — 오류 ${r.counts.error}건을 고치고 다시 kf check`);
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Finding } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
const NOTE: Record<string, string> = {
|
|
6
|
+
"alpha-failed": "투명 배경을 못 받아 단색 바탕을 키잉했다 — 가장자리 번짐을 본다",
|
|
7
|
+
"refs-ignored": "참고 그림(refs)을 모델이 안 받았다 — 모양이 브리프와 다를 수 있다",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// 생성 담당이 assets/gen/<id>.json 에 남긴 출처 기록. 밖에 내는 결과물이면 지역·라이선스 제한을 먼저 본다.
|
|
11
|
+
export function genAssets(projectDir: string): Finding[] {
|
|
12
|
+
const dir = join(projectDir, "assets", "gen");
|
|
13
|
+
if (!existsSync(dir)) return [];
|
|
14
|
+
const out: Finding[] = [];
|
|
15
|
+
for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
|
|
16
|
+
let meta: any;
|
|
17
|
+
try {
|
|
18
|
+
meta = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const id = meta.id ?? f.replace(/\.json$/, "");
|
|
23
|
+
const license = String(meta.license ?? "");
|
|
24
|
+
const region = String(meta.region ?? "");
|
|
25
|
+
if (region || /제한|비상업|non-?commercial|restrict/i.test(license)) {
|
|
26
|
+
out.push({
|
|
27
|
+
rule: "asset.license",
|
|
28
|
+
severity: "warn",
|
|
29
|
+
from: 0,
|
|
30
|
+
to: 0,
|
|
31
|
+
message: `생성 자산 ${id} — 라이선스 「${license || "?"}」${region ? ` · 지역 「${region}」` : ""} — 밖에 내기 전에 확인한다`,
|
|
32
|
+
detail: { file: join("assets", "gen", f), provider: meta.provider, model: meta.model },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
for (const n of (meta.notes ?? []) as string[]) {
|
|
36
|
+
const key = Object.keys(NOTE).find((k) => n.startsWith(k));
|
|
37
|
+
if (!key) continue;
|
|
38
|
+
out.push({ rule: "asset.note", severity: "warn", from: 0, to: 0, message: `생성 자산 ${id} — ${NOTE[key]}`, detail: { file: join("assets", "gen", f), note: n } });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { Beats, Finding, MotionEvent, MotionRules } from "../types.ts";
|
|
2
|
+
import { runs, type Run } from "./runs.ts";
|
|
3
|
+
|
|
4
|
+
function nearest(list: number[], t: number): number | undefined {
|
|
5
|
+
let best: number | undefined;
|
|
6
|
+
for (const x of list) if (best === undefined || Math.abs(x - t) < Math.abs(best - t)) best = x;
|
|
7
|
+
return best;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ms = (s: number) => `${Math.round(s * 1000)}ms`;
|
|
11
|
+
|
|
12
|
+
// 장면 경계는 박 위에, 드롭 근처면 드롭 위에. 한 프레임까지는 반올림 차로 본다.
|
|
13
|
+
export function boundaries(times: number[], beats: Beats, o: { fps: number; duration: number; source: "scene" | "cut" }): Finding[] {
|
|
14
|
+
const tol = 1 / o.fps + 0.004;
|
|
15
|
+
const out: Finding[] = [];
|
|
16
|
+
const drops = beats.drops ?? [];
|
|
17
|
+
for (const t of times) {
|
|
18
|
+
if (t <= tol || t >= o.duration - tol) continue;
|
|
19
|
+
const drop = nearest(drops, t);
|
|
20
|
+
if (drop !== undefined && Math.abs(drop - t) > tol && Math.abs(drop - t) < 0.6) {
|
|
21
|
+
out.push({
|
|
22
|
+
rule: "beat.boundary-drop",
|
|
23
|
+
severity: "warn",
|
|
24
|
+
from: t,
|
|
25
|
+
to: t,
|
|
26
|
+
message: `${o.source === "scene" ? "장면 경계" : "컷"} ${t.toFixed(3)}초가 드롭 ${drop.toFixed(3)}초에서 ${ms(t - drop)} 어긋났다 — 드롭에 붙인다`,
|
|
27
|
+
detail: { at: t, drop, offsetMs: Math.round((t - drop) * 1000) },
|
|
28
|
+
});
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const beat = nearest(beats.beats, t);
|
|
32
|
+
if (beat === undefined || Math.abs(beat - t) <= tol) continue;
|
|
33
|
+
// 컷은 장면 안 연출(섬광·글자 교체)도 잡히니 박 사이 한가운데쯤 떨어진 것만 말한다.
|
|
34
|
+
if (o.source === "cut" && Math.abs(beat - t) < 0.06) continue;
|
|
35
|
+
out.push({
|
|
36
|
+
rule: "beat.boundary",
|
|
37
|
+
severity: "warn",
|
|
38
|
+
from: t,
|
|
39
|
+
to: t,
|
|
40
|
+
message: `${o.source === "scene" ? "장면 경계" : "컷"} ${t.toFixed(3)}초가 박 ${beat.toFixed(3)}초에서 ${ms(t - beat)} 어긋났다`,
|
|
41
|
+
detail: { at: t, beat, offsetMs: Math.round((t - beat) * 1000) },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Window {
|
|
48
|
+
from: number;
|
|
49
|
+
to: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function dropWindows(beats: Beats, duration: number): Window[] {
|
|
53
|
+
const sections = [...(beats.sections ?? [])].sort((a, b) => a.t - b.t);
|
|
54
|
+
const out: Window[] = [];
|
|
55
|
+
sections.forEach((s, i) => {
|
|
56
|
+
if (s.kind === "drop") out.push({ from: s.t, to: sections[i + 1]?.t ?? duration });
|
|
57
|
+
});
|
|
58
|
+
const phrases = beats.phrases ?? [];
|
|
59
|
+
for (const d of beats.drops ?? []) {
|
|
60
|
+
if (out.some((w) => d >= w.from - 0.05 && d < w.to)) continue;
|
|
61
|
+
const next = phrases.find((p) => p > d + 0.05);
|
|
62
|
+
out.push({ from: d, to: next ?? Math.min(duration, d + 4) });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function phraseOf(phrases: number[], t: number): number {
|
|
68
|
+
let k = -1;
|
|
69
|
+
for (let i = 0; i < phrases.length; i++) if (phrases[i] <= t + 0.001) k = i;
|
|
70
|
+
return k;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function inside(ws: Window[], t: number, slack = 0.05): boolean {
|
|
74
|
+
return ws.some((w) => t >= w.from - slack && t < w.to);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function occurrences(events: MotionEvent[], kind: MotionEvent["kind"], fps: number): (Run & { max: number; sources: string[] })[] {
|
|
78
|
+
const list = events.filter((e) => e.kind === kind);
|
|
79
|
+
return runs(
|
|
80
|
+
list.map((e) => e.t),
|
|
81
|
+
1.5 / fps,
|
|
82
|
+
).map((r) => {
|
|
83
|
+
const inRun = list.filter((e) => e.t >= r.from - 1e-6 && e.t <= r.to + 1e-6);
|
|
84
|
+
return { ...r, max: Math.max(...inRun.map((e) => Math.abs(e.value))), sources: [...new Set(inRun.map((e) => e.source))] };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// style.json motion 규칙을 페이지가 남긴 window.__kf.events 로 센다. 연속 프레임 묶음 = 한 번.
|
|
89
|
+
export function motionRules(events: MotionEvent[], beats: Beats | null, rules: MotionRules, o: { fps: number; duration: number }): Finding[] {
|
|
90
|
+
const out: Finding[] = [];
|
|
91
|
+
const drops = beats ? dropWindows(beats, o.duration) : [];
|
|
92
|
+
const phrases = beats?.phrases?.length ? beats.phrases : [];
|
|
93
|
+
const end = (r: Run) => r.to + 1 / o.fps;
|
|
94
|
+
|
|
95
|
+
const pulse = rules.beatPulse;
|
|
96
|
+
if (pulse === "off" || pulse === "drops-only" || pulse === "downbeats") {
|
|
97
|
+
for (const r of occurrences(events, "pulse", o.fps)) {
|
|
98
|
+
let bad = pulse === "off";
|
|
99
|
+
if (pulse === "drops-only") bad = !inside(drops, r.from);
|
|
100
|
+
if (pulse === "downbeats") {
|
|
101
|
+
const db = nearest(beats?.downbeats ?? beats?.bars ?? [], r.from);
|
|
102
|
+
bad = db === undefined || Math.abs(db - r.from) > 2 / o.fps;
|
|
103
|
+
}
|
|
104
|
+
if (!bad) continue;
|
|
105
|
+
out.push({
|
|
106
|
+
rule: "motion.beat-pulse",
|
|
107
|
+
severity: "error",
|
|
108
|
+
from: r.from,
|
|
109
|
+
to: end(r),
|
|
110
|
+
message: `박 펄스가 규칙(beatPulse: ${pulse}) 밖에서 뛴다 — ${r.sources.join(", ")}`,
|
|
111
|
+
detail: { sources: r.sources, max: r.max },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const shake = rules.shake;
|
|
117
|
+
if (shake) {
|
|
118
|
+
const occ = occurrences(events, "shake", o.fps);
|
|
119
|
+
const perPhrase = new Map<number, typeof occ>();
|
|
120
|
+
for (const r of occ) {
|
|
121
|
+
if (shake.maxPx !== undefined && r.max > shake.maxPx + 0.5) {
|
|
122
|
+
out.push({
|
|
123
|
+
rule: "motion.shake-px",
|
|
124
|
+
severity: "error",
|
|
125
|
+
from: r.from,
|
|
126
|
+
to: end(r),
|
|
127
|
+
message: `흔들림 ${r.max.toFixed(1)}px — 상한 ${shake.maxPx}px 넘음 (${r.sources.join(", ")})`,
|
|
128
|
+
detail: { max: r.max, sources: r.sources },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const on = shake.on ?? [];
|
|
132
|
+
if (on.length && !on.includes("impact" as never) && on.includes("drop" as never) && beats && !inside(drops, r.from)) {
|
|
133
|
+
out.push({
|
|
134
|
+
rule: "motion.shake-where",
|
|
135
|
+
severity: "error",
|
|
136
|
+
from: r.from,
|
|
137
|
+
to: end(r),
|
|
138
|
+
message: `드롭이 아닌 곳에서 흔들린다 (shake.on: ${on.join(",")}) — ${r.sources.join(", ")}`,
|
|
139
|
+
detail: { sources: r.sources },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const k = phraseOf(phrases, r.from);
|
|
143
|
+
if (!perPhrase.has(k)) perPhrase.set(k, []);
|
|
144
|
+
perPhrase.get(k)!.push(r);
|
|
145
|
+
}
|
|
146
|
+
if (shake.maxPerPhrase !== undefined && phrases.length) {
|
|
147
|
+
for (const [k, list] of perPhrase) {
|
|
148
|
+
if (list.length <= shake.maxPerPhrase) continue;
|
|
149
|
+
out.push({
|
|
150
|
+
rule: "motion.shake-count",
|
|
151
|
+
severity: "error",
|
|
152
|
+
from: list[0].from,
|
|
153
|
+
to: end(list[list.length - 1]),
|
|
154
|
+
message: `한 프레이즈(${(phrases[k] ?? 0).toFixed(2)}초~)에서 ${list.length}번 흔들린다 — 최대 ${shake.maxPerPhrase}번`,
|
|
155
|
+
detail: { phrase: phrases[k], times: list.map((r) => Math.round(r.from * 1000) / 1000) },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const cam = rules.cameraMove;
|
|
162
|
+
if (cam === "off" || cam === "once-per-phrase") {
|
|
163
|
+
const occ = occurrences(events, "camera", o.fps);
|
|
164
|
+
if (cam === "off") {
|
|
165
|
+
for (const r of occ)
|
|
166
|
+
out.push({ rule: "motion.camera", severity: "error", from: r.from, to: end(r), message: `카메라가 움직인다 (cameraMove: off) — ${r.sources.join(", ")}` });
|
|
167
|
+
} else if (phrases.length) {
|
|
168
|
+
const per = new Map<number, typeof occ>();
|
|
169
|
+
for (const r of occ) {
|
|
170
|
+
const k = phraseOf(phrases, r.from);
|
|
171
|
+
if (!per.has(k)) per.set(k, []);
|
|
172
|
+
per.get(k)!.push(r);
|
|
173
|
+
}
|
|
174
|
+
for (const [k, list] of per) {
|
|
175
|
+
if (list.length <= 1) continue;
|
|
176
|
+
out.push({
|
|
177
|
+
rule: "motion.camera",
|
|
178
|
+
severity: "error",
|
|
179
|
+
from: list[0].from,
|
|
180
|
+
to: end(list[list.length - 1]),
|
|
181
|
+
message: `한 프레이즈(${(phrases[k] ?? 0).toFixed(2)}초~)에서 카메라가 ${list.length}번 움직인다 — 프레이즈당 한 번`,
|
|
182
|
+
detail: { phrase: phrases[k], moves: list.map((r) => ({ t: Math.round(r.from * 1000) / 1000, sources: r.sources })) },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (rules.repeatSameMove === false) {
|
|
189
|
+
// 장면마다 처음 나온 enter: 움직임이 그 장면의 등장이다.
|
|
190
|
+
const first = new Map<string, { t: number; name: string }>();
|
|
191
|
+
for (const e of events) {
|
|
192
|
+
if (e.kind !== "move" || !e.scene || !e.name?.startsWith("enter:")) continue;
|
|
193
|
+
const cur = first.get(e.scene);
|
|
194
|
+
if (!cur || e.t < cur.t) first.set(e.scene, { t: e.t, name: e.name });
|
|
195
|
+
}
|
|
196
|
+
const order = [...first.entries()].sort((a, b) => a[1].t - b[1].t);
|
|
197
|
+
for (let i = 1; i < order.length; i++) {
|
|
198
|
+
const [pa, a] = order[i - 1];
|
|
199
|
+
const [pb, b] = order[i];
|
|
200
|
+
if (a.name !== b.name) continue;
|
|
201
|
+
out.push({
|
|
202
|
+
rule: "motion.repeat",
|
|
203
|
+
severity: "warn",
|
|
204
|
+
from: b.t,
|
|
205
|
+
to: b.t,
|
|
206
|
+
message: `이어지는 두 장면(${pa} → ${pb})이 같은 등장 움직임 ${b.name} 을 쓴다 (repeatSameMove: false)`,
|
|
207
|
+
detail: { scenes: [pa, pb], name: b.name },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|