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,217 @@
|
|
|
1
|
+
import type { Box, Finding, FrameText, MotionEvent, SafeArea } from "../types.ts";
|
|
2
|
+
import type { SceneSpan } from "./layout.ts";
|
|
3
|
+
|
|
4
|
+
// 화면 전체를 이유 없이 흔들면(사인 드리프트·박마다 자세·늘 다가가는 카메라) 주인공이 기울고 가장자리로 밀린다.
|
|
5
|
+
// 픽셀만 봐서는 「화면 안 움직임」과 「카메라가 움직여 생긴 변화」가 안 갈린다 — 그래서 페이지에서 카메라 층의 transform 을 직접 잰다.
|
|
6
|
+
|
|
7
|
+
export interface CamLayer {
|
|
8
|
+
key: string;
|
|
9
|
+
scene: string | null;
|
|
10
|
+
sel: string;
|
|
11
|
+
a: number;
|
|
12
|
+
b: number;
|
|
13
|
+
e: number;
|
|
14
|
+
f: number;
|
|
15
|
+
flat: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CamSample {
|
|
19
|
+
t: number;
|
|
20
|
+
layers: CamLayer[];
|
|
21
|
+
heroes: { scene: string | null; sel: string; box: Box }[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const r1 = (x: number) => Math.round(x * 10) / 10;
|
|
25
|
+
|
|
26
|
+
function sceneOf(t: number, scenes: SceneSpan[]): string | undefined {
|
|
27
|
+
let hit: string | undefined;
|
|
28
|
+
for (const s of scenes) if (s.from <= t + 1e-3) hit = s.id;
|
|
29
|
+
return hit;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WanderOptions {
|
|
33
|
+
fps: number;
|
|
34
|
+
// 이음매처럼 화면 전체가 움직이는 게 일인 장면
|
|
35
|
+
skipScenes?: string[];
|
|
36
|
+
// 이보다 짧게 이어진 이동은 등장 움직임으로 본다
|
|
37
|
+
minRunSec?: number;
|
|
38
|
+
// 장면 안에서 이유 없이 움직인 시간을 다 합쳐 이만큼 넘으면 경고
|
|
39
|
+
warnSec?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 이웃 표본 사이에 카메라 층이 움직였는데 그 시각에 카메라 이벤트(__kf.events 의 camera·shake)가 없으면 「이유 없는 이동」이다
|
|
43
|
+
export function cameraWander(samples: CamSample[], events: MotionEvent[], o: WanderOptions): Finding[] {
|
|
44
|
+
const minRun = o.minRunSec ?? 0.3;
|
|
45
|
+
const warnSec = o.warnSec ?? 0.8;
|
|
46
|
+
const skip = new Set(o.skipScenes ?? []);
|
|
47
|
+
const reasons = events.filter((e) => e.kind === "camera" || e.kind === "shake");
|
|
48
|
+
const pad = 1.5 / o.fps;
|
|
49
|
+
const excused = (scene: string | null, a: number, b: number) => reasons.some((e) => (!e.scene || !scene || e.scene === scene) && e.t >= a - pad && e.t <= b + pad);
|
|
50
|
+
|
|
51
|
+
const tracks = new Map<string, { t: number; l: CamLayer }[]>();
|
|
52
|
+
for (const s of samples) for (const l of s.layers) (tracks.get(l.key) ?? tracks.set(l.key, []).get(l.key)!).push({ t: s.t, l });
|
|
53
|
+
const step = samples.length > 1 ? Math.min(...samples.slice(1).map((s, i) => s.t - samples[i].t).filter((d) => d > 0)) : 1 / 30;
|
|
54
|
+
|
|
55
|
+
interface Acc {
|
|
56
|
+
runs: { from: number; to: number }[];
|
|
57
|
+
shift: number;
|
|
58
|
+
rot: number;
|
|
59
|
+
sel: Set<string>;
|
|
60
|
+
tilt: boolean;
|
|
61
|
+
}
|
|
62
|
+
const byScene = new Map<string, Acc>();
|
|
63
|
+
for (const track of tracks.values()) {
|
|
64
|
+
track.sort((x, y) => x.t - y.t);
|
|
65
|
+
let run: { from: number; to: number } | null = null;
|
|
66
|
+
const scene = track[0].l.scene ?? "";
|
|
67
|
+
if (skip.has(scene)) continue;
|
|
68
|
+
const acc = byScene.get(scene) ?? { runs: [], shift: 0, rot: 0, sel: new Set<string>(), tilt: false };
|
|
69
|
+
const close = () => {
|
|
70
|
+
if (run && run.to - run.from >= minRun - 1e-6) acc.runs.push(run);
|
|
71
|
+
run = null;
|
|
72
|
+
};
|
|
73
|
+
for (let i = 1; i < track.length; i++) {
|
|
74
|
+
const p = track[i - 1];
|
|
75
|
+
const q = track[i];
|
|
76
|
+
if (q.t - p.t > step * 2.5) {
|
|
77
|
+
close();
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const sp = Math.hypot(p.l.a, p.l.b);
|
|
81
|
+
const sq = Math.hypot(q.l.a, q.l.b);
|
|
82
|
+
const rp = (Math.atan2(p.l.b, p.l.a) * 180) / Math.PI;
|
|
83
|
+
const rq = (Math.atan2(q.l.b, q.l.a) * 180) / Math.PI;
|
|
84
|
+
const moved = Math.abs(q.l.e - p.l.e) > 0.5 || Math.abs(q.l.f - p.l.f) > 0.5 || Math.abs(sq - sp) / (sp || 1) > 0.001 || Math.abs(rq - rp) > 0.02 || p.l.flat !== q.l.flat;
|
|
85
|
+
if (!moved || excused(q.l.scene, p.t, q.t)) {
|
|
86
|
+
close();
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (run) run.to = q.t;
|
|
90
|
+
else run = { from: p.t, to: q.t };
|
|
91
|
+
acc.shift = Math.max(acc.shift, Math.hypot(q.l.e - track[0].l.e, q.l.f - track[0].l.f));
|
|
92
|
+
acc.rot = Math.max(acc.rot, Math.abs(rq));
|
|
93
|
+
acc.tilt ||= !q.l.flat;
|
|
94
|
+
acc.sel.add(q.l.sel);
|
|
95
|
+
}
|
|
96
|
+
close();
|
|
97
|
+
byScene.set(scene, acc);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const out: Finding[] = [];
|
|
101
|
+
for (const [scene, acc] of byScene) {
|
|
102
|
+
const total = acc.runs.reduce((a, r) => a + (r.to - r.from), 0);
|
|
103
|
+
if (total < warnSec - 1e-6) continue;
|
|
104
|
+
const longest = Math.max(...acc.runs.map((r) => r.to - r.from));
|
|
105
|
+
const turn = acc.rot > 0.05 || acc.tilt ? ` · 기울기 ${acc.tilt ? "원근(3D)" : `${r1(acc.rot)}°`}` : "";
|
|
106
|
+
out.push({
|
|
107
|
+
rule: "camera.wander",
|
|
108
|
+
severity: "warn",
|
|
109
|
+
from: Math.min(...acc.runs.map((r) => r.from)),
|
|
110
|
+
to: Math.max(...acc.runs.map((r) => r.to)),
|
|
111
|
+
message: `장면 ${scene || "?"} 의 카메라 층이 이유 없이 ${total.toFixed(1)}초 움직인다(가장 길게 ${longest.toFixed(1)}초, 최대 ${Math.round(acc.shift)}px${turn}) — 카메라는 가만히 두고, 드러냄·비교·따라가기·충격 때만 stage.camera(pushIn·pullOut·follow·impact)로. 쉬지 않는 몫은 화면 안 움직임에`,
|
|
112
|
+
detail: { scene, seconds: r1(total), longest: r1(longest), maxShiftPx: Math.round(acc.shift), maxRotateDeg: r1(acc.rot), tilt3d: acc.tilt, layers: [...acc.sel].slice(0, 3), runs: acc.runs.slice(0, 8).map((r) => [r1(r.from), r1(r.to)]) },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return out.sort((a, b) => a.from - b.from);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface EdgeOptions {
|
|
119
|
+
width: number;
|
|
120
|
+
height: number;
|
|
121
|
+
fps: number;
|
|
122
|
+
safe?: SafeArea;
|
|
123
|
+
// 안전 영역 가장자리에서 이 몫 안쪽 띠
|
|
124
|
+
band?: number;
|
|
125
|
+
minSec?: number;
|
|
126
|
+
skipScenes?: string[];
|
|
127
|
+
// 장면이 주인공을 짚지 않았을 때 「가장 큰 글」로 칠 최소 글자 크기(1080 기준 px)
|
|
128
|
+
heroPx?: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 주인공 상자가 안전 영역 가장자리 4% 띠 안으로 들어가거나 넘어간 채 0.5초 넘게 — 카메라가 밀었거나 처음부터 구석에 놓였다.
|
|
132
|
+
// 장면이 짚은 주인공(data-kf-hero)은 늘 본다. 짚지 않아 「가장 큰 글」로 어림할 때는 그 장면 카메라 층이 처음 자리(배율 1·이동 0)를
|
|
133
|
+
// 벗어난 프레임만 센다 — HUD 구석 숫자·지면 밖으로 넘치는 큰 글처럼 일부러 가장자리에 둔 판을 카메라 탓으로 잡지 않게
|
|
134
|
+
export function heroEdge(samples: CamSample[], texts: FrameText[], scenes: SceneSpan[], o: EdgeOptions): Finding[] {
|
|
135
|
+
const sa = o.safe ?? { top: 0, bottom: 0, left: 0, right: 0 };
|
|
136
|
+
const band = o.band ?? 0.04;
|
|
137
|
+
const minSec = o.minSec ?? 0.5;
|
|
138
|
+
const sw = o.width - sa.left - sa.right;
|
|
139
|
+
const sh = o.height - sa.top - sa.bottom;
|
|
140
|
+
const inner = { x0: sa.left + sw * band, y0: sa.top + sh * band, x1: o.width - sa.right - sw * band, y1: o.height - sa.bottom - sh * band };
|
|
141
|
+
const heroPx = (o.heroPx ?? 72) * (Math.min(o.width, o.height) / 1080);
|
|
142
|
+
const skip = new Set(o.skipScenes ?? []);
|
|
143
|
+
|
|
144
|
+
// 프레임마다 주인공 상자 — 짚은 것이 있으면 그것, 없으면 가장 큰 글(자막 판은 빼고)
|
|
145
|
+
const marked = new Map<number, { sel: string; box: Box }[]>();
|
|
146
|
+
for (const s of samples) if (s.heroes.length) marked.set(Math.round(s.t * o.fps), s.heroes);
|
|
147
|
+
const away = (l: CamLayer) => Math.abs(Math.hypot(l.a, l.b) - 1) > 0.002 || Math.abs(l.b) > 0.0005 || Math.abs(l.e) > 1 || Math.abs(l.f) > 1 || !l.flat;
|
|
148
|
+
const moved = samples.map((s) => ({ t: s.t, scenes: new Set(s.layers.filter(away).map((l) => l.scene ?? "")) }));
|
|
149
|
+
let mi = 0;
|
|
150
|
+
const camAway = (t: number, scene: string) => {
|
|
151
|
+
while (mi + 1 < moved.length && Math.abs(moved[mi + 1].t - t) <= Math.abs(moved[mi].t - t)) mi++;
|
|
152
|
+
const m = moved[mi];
|
|
153
|
+
return !!m && Math.abs(m.t - t) <= 2.5 / o.fps && m.scenes.has(scene);
|
|
154
|
+
};
|
|
155
|
+
const frames: { t: number; what: string; box: Box }[] = [];
|
|
156
|
+
const seen = new Set<number>();
|
|
157
|
+
for (const f of texts) {
|
|
158
|
+
const k = Math.round(f.t * o.fps);
|
|
159
|
+
seen.add(k);
|
|
160
|
+
const hs = marked.get(k);
|
|
161
|
+
if (hs) {
|
|
162
|
+
for (const h of hs) frames.push({ t: f.t, what: h.sel, box: h.box });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
let best: FrameText["hits"][number] | undefined;
|
|
166
|
+
for (const h of f.hits) {
|
|
167
|
+
if (!h.box || h.via === "caption" || h.alpha < 0.6 || h.sizePx < heroPx || !/[\p{L}\p{N}]/u.test(h.text)) continue;
|
|
168
|
+
if (!best || h.sizePx > best.sizePx) best = h;
|
|
169
|
+
}
|
|
170
|
+
if (best?.box && camAway(f.t, sceneOf(f.t, scenes) ?? "")) frames.push({ t: f.t, what: `「${best.text.slice(0, 24)}」`, box: best.box });
|
|
171
|
+
}
|
|
172
|
+
for (const [k, hs] of marked) if (!seen.has(k)) for (const h of hs) frames.push({ t: k / o.fps, what: h.sel, box: h.box });
|
|
173
|
+
frames.sort((a, b) => a.t - b.t);
|
|
174
|
+
|
|
175
|
+
const sides = (b: Box) => [b.x0 < inner.x0 && "왼쪽", b.x1 > inner.x1 && "오른쪽", b.y0 < inner.y0 && "위", b.y1 > inner.y1 && "아래"].filter(Boolean) as string[];
|
|
176
|
+
const byScene = new Map<string, { runs: { from: number; to: number }[]; what: Set<string>; side: Set<string>; worst: number }>();
|
|
177
|
+
let run: { from: number; to: number; scene: string } | null = null;
|
|
178
|
+
const close = () => {
|
|
179
|
+
if (run && run.to - run.from >= minSec - 1e-6) byScene.get(run.scene)!.runs.push({ from: run.from, to: run.to });
|
|
180
|
+
run = null;
|
|
181
|
+
};
|
|
182
|
+
const at = new Map<number, typeof frames>();
|
|
183
|
+
for (const f of frames) (at.get(f.t) ?? at.set(f.t, []).get(f.t)!).push(f);
|
|
184
|
+
for (const [t, fs] of [...at].sort((a, b) => a[0] - b[0])) {
|
|
185
|
+
const scene = sceneOf(t, scenes) ?? "";
|
|
186
|
+
const hit = skip.has(scene) ? [] : fs.map((f) => ({ f, s: sides(f.box) })).filter((x) => x.s.length);
|
|
187
|
+
if (!hit.length) {
|
|
188
|
+
close();
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const acc = byScene.get(scene) ?? byScene.set(scene, { runs: [], what: new Set(), side: new Set(), worst: 0 }).get(scene)!;
|
|
192
|
+
for (const { f, s } of hit) {
|
|
193
|
+
acc.what.add(f.what);
|
|
194
|
+
s.forEach((x) => acc.side.add(x));
|
|
195
|
+
acc.worst = Math.max(acc.worst, inner.x0 - f.box.x0, f.box.x1 - inner.x1, inner.y0 - f.box.y0, f.box.y1 - inner.y1);
|
|
196
|
+
}
|
|
197
|
+
if (run && (run.scene !== scene || t - run.to > 2.5 / o.fps)) close();
|
|
198
|
+
if (run) run.to = t;
|
|
199
|
+
else run = { from: t, to: t, scene };
|
|
200
|
+
}
|
|
201
|
+
close();
|
|
202
|
+
|
|
203
|
+
const out: Finding[] = [];
|
|
204
|
+
for (const [scene, acc] of byScene) {
|
|
205
|
+
if (!acc.runs.length) continue;
|
|
206
|
+
const total = acc.runs.reduce((a, r) => a + (r.to - r.from), 0);
|
|
207
|
+
out.push({
|
|
208
|
+
rule: "layout.edge",
|
|
209
|
+
severity: "warn",
|
|
210
|
+
from: acc.runs[0].from,
|
|
211
|
+
to: acc.runs[acc.runs.length - 1].to,
|
|
212
|
+
message: `장면 ${scene || "?"} 의 주인공 ${[...acc.what].slice(0, 2).join(" · ")} 이 안전 영역 ${[...acc.side].join("·")} 가장자리 ${Math.round(band * 100)}% 띠에 ${total.toFixed(1)}초 붙어 있다(가장 깊이 ${Math.round(acc.worst)}px) — 주인공은 안전 영역 가운데에. 카메라가 밀었으면 그 이동을 걷는다`,
|
|
213
|
+
detail: { scene, seconds: r1(total), sides: [...acc.side], worstPx: Math.round(acc.worst), heroes: [...acc.what].slice(0, 3), runs: acc.runs.slice(0, 8).map((r) => [r1(r.from), r1(r.to)]) },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return out.sort((a, b) => a.from - b.from);
|
|
217
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Box, Finding, FrameText } from "../types.ts";
|
|
2
|
+
import { runs } from "./runs.ts";
|
|
3
|
+
import { assembleLines, roundBox } from "./text.ts";
|
|
4
|
+
|
|
5
|
+
// 화면 자막 판(엔진이 장면 위에 얹은 data-kf-captions)이 장면 글을 덮는 곳. 엔진이 장면을 자막 띠 위로 올리지만,
|
|
6
|
+
// 장면이 일부러 띠 자리까지 내려오게 짰거나(안전 영역 밖 배치) 띠보다 큰 움직임이면 여전히 겹친다
|
|
7
|
+
export function captionOverlap(frames: FrameText[], o: { fps: number; minSec?: number }): Finding[] {
|
|
8
|
+
const minSec = o.minSec ?? 0.3;
|
|
9
|
+
const seen = new Map<string, { times: number[]; box: Box; caption: string }>();
|
|
10
|
+
for (const f of frames) {
|
|
11
|
+
const lines = assembleLines(f.hits).filter((l) => l.box && l.alpha > 0.3);
|
|
12
|
+
const caps = lines.filter((l) => l.hits[0]?.via === "caption");
|
|
13
|
+
if (!caps.length) continue;
|
|
14
|
+
// 글 상자에 판의 안쪽 여백(글자 크기의 절반·⅓)을 더하면 판 자리다
|
|
15
|
+
const size = Math.max(...caps.map((l) => l.sizePx));
|
|
16
|
+
const plate: Box = {
|
|
17
|
+
x0: Math.min(...caps.map((l) => l.box!.x0)) - size * 0.5,
|
|
18
|
+
y0: Math.min(...caps.map((l) => l.box!.y0)) - size * 0.3,
|
|
19
|
+
x1: Math.max(...caps.map((l) => l.box!.x1)) + size * 0.5,
|
|
20
|
+
y1: Math.max(...caps.map((l) => l.box!.y1)) + size * 0.3,
|
|
21
|
+
};
|
|
22
|
+
const caption = caps.map((l) => l.text).join(" ");
|
|
23
|
+
for (const l of lines) {
|
|
24
|
+
if (l.hits[0]?.via === "caption" || !/[\p{L}\p{N}]/u.test(l.text)) continue;
|
|
25
|
+
const b = l.box!;
|
|
26
|
+
const w = Math.min(b.x1, plate.x1) - Math.max(b.x0, plate.x0);
|
|
27
|
+
const h = Math.min(b.y1, plate.y1) - Math.max(b.y0, plate.y0);
|
|
28
|
+
if (w <= 2 || h <= 2) continue;
|
|
29
|
+
let s = seen.get(l.text);
|
|
30
|
+
if (!s) seen.set(l.text, (s = { times: [], box: b, caption }));
|
|
31
|
+
s.times.push(f.t);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const out: Finding[] = [];
|
|
35
|
+
for (const [text, s] of seen) {
|
|
36
|
+
for (const r of runs(s.times, 1.5 / o.fps)) {
|
|
37
|
+
if (r.to - r.from + 1 / o.fps < minSec) continue;
|
|
38
|
+
out.push({
|
|
39
|
+
rule: "caption.overlap",
|
|
40
|
+
severity: "warn",
|
|
41
|
+
from: r.from,
|
|
42
|
+
to: r.to + 1 / o.fps,
|
|
43
|
+
message: `자막 판이 장면 글 "${text.slice(0, 40)}" 을 덮는다 — 그 글을 안전 영역(ctx.safe) 안 위쪽으로 옮긴다. 자막 띠 자리(안전 영역 아래쪽)는 비워 둔다`,
|
|
44
|
+
detail: { text: text.slice(0, 120), box: roundBox(s.box), caption: s.caption.slice(0, 120) },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { FLASH_GRID, type Thumb } from "../image.ts";
|
|
2
|
+
import type { Finding } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
// 빛에 민감한 사람을 위한 섬광 안전(ITU-R BT.1702·WCAG 2.3.1 취지). 박마다 번쩍이는 드롭·컷 연출이 흔해 기계로 센다.
|
|
5
|
+
// 섬광 1회 = 화면 25% 넘는 영역의 평균 상대휘도가 10%p 넘게 올랐다가(또는 내렸다가) 되돌아오는 한 쌍(어두운 쪽이 0.8 아래일 때).
|
|
6
|
+
|
|
7
|
+
export interface FlashOptions {
|
|
8
|
+
// 아무 1초 창에 이보다 많으면 오류(style.json motion.maxFlashPerSec, 기본 3)
|
|
9
|
+
maxPerSec?: number;
|
|
10
|
+
// 장면 경계·드롭처럼 일부러 끊는 시각 — 컷 휘도 차 판정에 쓴다
|
|
11
|
+
cuts?: number[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const G = FLASH_GRID;
|
|
15
|
+
const AREA = 0.25;
|
|
16
|
+
const SWING = 0.1;
|
|
17
|
+
|
|
18
|
+
// 감마 밝기(0..255) → 상대휘도 근사(0..1). 썸네일은 회색조라 채널별 선형화 대신 밝기 하나로 어림한다
|
|
19
|
+
const lin = new Float32Array(256).map((_, v) => {
|
|
20
|
+
const c = v / 255;
|
|
21
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function tiles(t: Thumb): Float32Array {
|
|
25
|
+
const w = 160;
|
|
26
|
+
const h = Math.round(t.luma.length / w);
|
|
27
|
+
const sum = new Float32Array(G * G);
|
|
28
|
+
const cnt = new Float32Array(G * G);
|
|
29
|
+
for (let y = 0; y < h; y++) {
|
|
30
|
+
const r = Math.min(G - 1, Math.floor((y * G) / h));
|
|
31
|
+
for (let x = 0; x < w; x++) {
|
|
32
|
+
const k = r * G + Math.min(G - 1, Math.floor((x * G) / w));
|
|
33
|
+
sum[k] += lin[t.luma[y * w + x]];
|
|
34
|
+
cnt[k]++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (let k = 0; k < sum.length; k++) sum[k] /= cnt[k] || 1;
|
|
38
|
+
return sum;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Turn {
|
|
42
|
+
t: number;
|
|
43
|
+
sign: 1 | -1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 칸 하나의 시계열에서 오르내림 — 지난 꺾임 뒤의 가장 낮은(높은) 값에서 swing 넘게 반대로 가면 한 번 꺾인 것
|
|
47
|
+
function turns(series: number[], times: number[], swing: number, dark: (lo: number) => boolean): Turn[] {
|
|
48
|
+
const out: Turn[] = [];
|
|
49
|
+
let lo = series[0];
|
|
50
|
+
let hi = series[0];
|
|
51
|
+
let state = 0;
|
|
52
|
+
for (let i = 1; i < series.length; i++) {
|
|
53
|
+
const v = series[i];
|
|
54
|
+
if (state !== 1 && v - lo >= swing && dark(lo)) {
|
|
55
|
+
out.push({ t: times[i], sign: 1 });
|
|
56
|
+
state = 1;
|
|
57
|
+
hi = v;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (state !== -1 && hi - v >= swing && dark(v)) {
|
|
61
|
+
out.push({ t: times[i], sign: -1 });
|
|
62
|
+
state = -1;
|
|
63
|
+
lo = v;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (state >= 0) hi = Math.max(hi, v);
|
|
67
|
+
if (state <= 0) lo = Math.min(lo, v);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 칸마다 꺾인 시각을 모아, 같은 쪽으로 동시에(이웃 두 표본 안) 꺾인 칸이 화면 25% 를 넘으면 화면 전체가 한 번 꺾인 것
|
|
73
|
+
function frameTurns(perTile: Turn[][], step: number): Turn[] {
|
|
74
|
+
const all = perTile.flatMap((ts, k) => ts.map((x) => ({ ...x, k }))).sort((a, b) => a.t - b.t);
|
|
75
|
+
const out: Turn[] = [];
|
|
76
|
+
const need = Math.ceil(AREA * G * G);
|
|
77
|
+
for (let i = 0; i < all.length; i++) {
|
|
78
|
+
const a = all[i];
|
|
79
|
+
if (out.length && out[out.length - 1].sign === a.sign && a.t - out[out.length - 1].t <= step * 2.5) continue;
|
|
80
|
+
const tiles = new Set<number>();
|
|
81
|
+
for (let j = i; j < all.length && all[j].t - a.t <= step * 2.5; j++) if (all[j].sign === a.sign) tiles.add(all[j].k);
|
|
82
|
+
if (tiles.size >= need) out.push({ t: a.t, sign: a.sign });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 반대로 꺾인 이웃 둘 = 섬광 1회 — 아무 1초 창 안의 꺾임 n 번이면 섬광 ⌊n/2⌋ 번. max 를 넘는 구간을 묶어 돌려준다
|
|
88
|
+
function overLimit(ts: Turn[], max: number): { from: number; to: number; count: number }[] {
|
|
89
|
+
const out: { from: number; to: number; count: number }[] = [];
|
|
90
|
+
for (let i = 0; i < ts.length; i++) {
|
|
91
|
+
let j = i;
|
|
92
|
+
while (j + 1 < ts.length && ts[j + 1].t - ts[i].t < 1) j++;
|
|
93
|
+
const count = Math.floor((j - i + 1) / 2);
|
|
94
|
+
if (count <= max) continue;
|
|
95
|
+
const last = out[out.length - 1];
|
|
96
|
+
if (last && ts[i].t <= last.to) {
|
|
97
|
+
last.to = Math.max(last.to, ts[j].t);
|
|
98
|
+
last.count = Math.max(last.count, count);
|
|
99
|
+
} else out.push({ from: ts[i].t, to: ts[j].t, count });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function flashes(thumbs: Thumb[], o: FlashOptions = {}): Finding[] {
|
|
105
|
+
if (thumbs.length < 3) return [];
|
|
106
|
+
const max = o.maxPerSec ?? 3;
|
|
107
|
+
const step = thumbs[1].t - thumbs[0].t;
|
|
108
|
+
const times = thumbs.map((t) => t.t);
|
|
109
|
+
const lum = thumbs.map(tiles);
|
|
110
|
+
const out: Finding[] = [];
|
|
111
|
+
const r2 = (x: number) => Math.round(x * 100) / 100;
|
|
112
|
+
|
|
113
|
+
// 1) 휘도 섬광 — 어두운 쪽이 0.8 아래인 오르내림만
|
|
114
|
+
const lt = Array.from({ length: G * G }, (_, k) =>
|
|
115
|
+
turns(
|
|
116
|
+
lum.map((l) => l[k]),
|
|
117
|
+
times,
|
|
118
|
+
SWING,
|
|
119
|
+
(lo) => lo < 0.8,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
for (const r of overLimit(frameTurns(lt, step), max)) {
|
|
123
|
+
out.push({
|
|
124
|
+
rule: "frame.flash",
|
|
125
|
+
severity: "error",
|
|
126
|
+
from: r.from,
|
|
127
|
+
to: r.to,
|
|
128
|
+
message: `화면 25% 넘는 영역이 1초에 ${r.count}번 번쩍인다 — ${max}번까지(빛에 민감한 사람이 발작할 수 있다). 박마다 섬광이면 두 박·한 마디에 한 번으로, 섬광은 2마디 안에서 끝낸다`,
|
|
129
|
+
detail: { flashesPerSec: r.count, max },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 2) 채도 높은 빨강 깜빡임 — 화면 25% 넘게 빨강이 켜졌다 꺼지기를 1초에 두 번 넘게
|
|
134
|
+
if (thumbs.every((t) => t.red)) {
|
|
135
|
+
const rt = Array.from({ length: G * G }, (_, k) =>
|
|
136
|
+
turns(
|
|
137
|
+
thumbs.map((t) => t.red![k]),
|
|
138
|
+
times,
|
|
139
|
+
0.5,
|
|
140
|
+
() => true,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
for (const r of overLimit(frameTurns(rt, step), 1)) {
|
|
144
|
+
out.push({
|
|
145
|
+
rule: "frame.flash-red",
|
|
146
|
+
severity: "error",
|
|
147
|
+
from: r.from,
|
|
148
|
+
to: r.to,
|
|
149
|
+
message: `채도 높은 빨강이 화면 25% 넘게 1초에 ${r.count}번 깜빡인다 — 빨강 전체 깜빡임은 쓰지 않는다. 빨강은 화면 10% 아래 작은 요소로`,
|
|
150
|
+
detail: { flashesPerSec: r.count },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 3) 빠른 줄무늬·나선 — 센 가장자리가 빽빽하고 대비가 큰 칸이 다음 표본에서 크게 바뀐다. 화면 25% 넘게 0.3초 넘으면 경고
|
|
156
|
+
const hot = thumbs.map((t, i) => (i ? stripeShare(thumbs[i - 1], t) : 0));
|
|
157
|
+
let start = -1;
|
|
158
|
+
for (let i = 0; i <= hot.length; i++) {
|
|
159
|
+
const on = i < hot.length && hot[i] > AREA;
|
|
160
|
+
if (on && start < 0) start = i;
|
|
161
|
+
if (!on && start >= 0) {
|
|
162
|
+
const from = times[start];
|
|
163
|
+
const to = times[i - 1];
|
|
164
|
+
if (to - from >= 0.3 - 1e-6) {
|
|
165
|
+
out.push({
|
|
166
|
+
rule: "frame.flash-pattern",
|
|
167
|
+
severity: "warn",
|
|
168
|
+
from,
|
|
169
|
+
to,
|
|
170
|
+
message: `대비 큰 줄무늬·나선이 빠르게 움직이며 화면 ${Math.round(Math.max(...hot.slice(start, i)) * 100)}% 를 덮는다(${(to - from).toFixed(1)}초) — 25% 아래로 줄이거나 느리게`,
|
|
171
|
+
detail: { seconds: r2(to - from) },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
start = -1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 4) 컷 휘도 차 — 이어지는 컷 앞뒤 화면 평균 상대휘도가 50%p 넘게 뛰면 경고(어둠 ↔ 흰 화면을 박마다 오가는 컷)
|
|
179
|
+
const mean = lum.map((l) => l.reduce((a, b) => a + b, 0) / l.length);
|
|
180
|
+
const near = (t: number) => times.findIndex((x) => x >= t - 1e-3);
|
|
181
|
+
const seen = new Set<number>();
|
|
182
|
+
for (const c of o.cuts ?? []) {
|
|
183
|
+
const i = near(c);
|
|
184
|
+
if (i <= 0 || i >= thumbs.length || seen.has(i)) continue;
|
|
185
|
+
seen.add(i);
|
|
186
|
+
const jump = Math.abs(mean[i] - mean[i - 1]);
|
|
187
|
+
if (jump <= 0.5) continue;
|
|
188
|
+
out.push({
|
|
189
|
+
rule: "frame.flash-cut",
|
|
190
|
+
severity: "warn",
|
|
191
|
+
from: times[i],
|
|
192
|
+
to: times[i],
|
|
193
|
+
message: `${times[i].toFixed(2)}초 컷 앞뒤 화면 밝기(상대휘도)가 ${Math.round(jump * 100)}%p 뛴다 — 50% 아래로(어둠과 흰 화면을 이어 붙이지 않는다)`,
|
|
194
|
+
detail: { jump: r2(jump), before: r2(mean[i - 1]), after: r2(mean[i]) },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 칸마다: 센 가장자리 몫 ≥ 0.3 이고 밝기 표준편차 ≥ 50(대비 큰 줄무늬) 이면서 앞 표본과 평균 절대차 ≥ 25(빠르게 움직임) — 그런 칸의 화면 몫
|
|
201
|
+
function stripeShare(a: Thumb, b: Thumb): number {
|
|
202
|
+
const w = 160;
|
|
203
|
+
const h = Math.round(b.luma.length / w);
|
|
204
|
+
if (a.luma.length !== b.luma.length) return 0;
|
|
205
|
+
let n = 0;
|
|
206
|
+
for (let ty = 0; ty < G; ty++) {
|
|
207
|
+
for (let tx = 0; tx < G; tx++) {
|
|
208
|
+
const y0 = Math.floor((ty * h) / G);
|
|
209
|
+
const y1 = Math.floor(((ty + 1) * h) / G);
|
|
210
|
+
const x0 = Math.floor((tx * w) / G);
|
|
211
|
+
const x1 = Math.floor(((tx + 1) * w) / G);
|
|
212
|
+
let edges = 0;
|
|
213
|
+
let px = 0;
|
|
214
|
+
let s = 0;
|
|
215
|
+
let s2 = 0;
|
|
216
|
+
let d = 0;
|
|
217
|
+
for (let y = y0; y < y1; y++) {
|
|
218
|
+
for (let x = x0; x < x1; x++) {
|
|
219
|
+
const i = y * w + x;
|
|
220
|
+
const v = b.luma[i];
|
|
221
|
+
px++;
|
|
222
|
+
s += v;
|
|
223
|
+
s2 += v * v;
|
|
224
|
+
d += Math.abs(v - a.luma[i]);
|
|
225
|
+
if (x + 1 < x1 && y + 1 < y1 && Math.abs(v - b.luma[i + 1]) + Math.abs(v - b.luma[i + w]) > 48) edges++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!px) continue;
|
|
229
|
+
const m = s / px;
|
|
230
|
+
const std = Math.sqrt(Math.max(0, s2 / px - m * m));
|
|
231
|
+
if (edges / px >= 0.3 && std >= 50 && d / px >= 25) n++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return n / (G * G);
|
|
235
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { meanAbsDiff, type Thumb } from "../image.ts";
|
|
2
|
+
import type { Finding } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
export interface FreezeOptions {
|
|
5
|
+
// 160×90 회색조 평균 절대차(0..255). 커서 깜빡임 한 번이면 넘는 정도.
|
|
6
|
+
threshold?: number;
|
|
7
|
+
errorSec?: number;
|
|
8
|
+
warnSec?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// 바뀐 면적(픽셀 몫) — 「거의 멈춤」을 숫자로 말해 준다.
|
|
12
|
+
function changedArea(a: Uint8Array, b: Uint8Array): number {
|
|
13
|
+
let n = 0;
|
|
14
|
+
for (let i = 0; i < a.length; i++) if (Math.abs(a[i] - b[i]) > 8) n++;
|
|
15
|
+
return n / a.length;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function diffs(thumbs: Thumb[]): number[] {
|
|
19
|
+
const out = [0];
|
|
20
|
+
for (let i = 1; i < thumbs.length; i++) out.push(meanAbsDiff(thumbs[i - 1].luma, thumbs[i].luma));
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 이웃 프레임끼리 비교하면 60fps 의 느린 움직임이 멈춤으로 보인다. 멈춤이 시작된 프레임(닻)과 비교해
|
|
25
|
+
// 닻에서 얼마나 멀어졌는지로 잰다 — 10fps 로 찍든 60fps 로 읽든 같은 답이 나온다.
|
|
26
|
+
// 공통 규칙: 단 0.1초도 쉬지 않는다 — 끝 카드·큰 숫자·로고도 예외 없이 0.1초(60fps 6프레임) 넘게 멈추면 오류
|
|
27
|
+
export const MAX_FREEZE_SEC = 0.1;
|
|
28
|
+
|
|
29
|
+
export function freezes(thumbs: Thumb[], o: FreezeOptions = {}): Finding[] {
|
|
30
|
+
const thr = o.threshold ?? 0.35;
|
|
31
|
+
const errorSec = o.errorSec ?? MAX_FREEZE_SEC;
|
|
32
|
+
const warnSec = Math.min(o.warnSec ?? errorSec, errorSec);
|
|
33
|
+
const out: Finding[] = [];
|
|
34
|
+
let a = 0;
|
|
35
|
+
while (a < thumbs.length - 1) {
|
|
36
|
+
let j = a + 1;
|
|
37
|
+
while (j < thumbs.length && meanAbsDiff(thumbs[a].luma, thumbs[j].luma) < thr) j++;
|
|
38
|
+
const end = j - 1;
|
|
39
|
+
const len = thumbs[end].t - thumbs[a].t;
|
|
40
|
+
// 「이보다 오래」 멈출 때만 — 딱 경계 길이는 재는 간격 안의 오차다.
|
|
41
|
+
if (len > warnSec + 1e-6) {
|
|
42
|
+
const severity = len > errorSec + 1e-6 ? "error" : "warn";
|
|
43
|
+
const area = changedArea(thumbs[a].luma, thumbs[end].luma);
|
|
44
|
+
out.push({
|
|
45
|
+
rule: "frame.freeze",
|
|
46
|
+
severity,
|
|
47
|
+
from: thumbs[a].t,
|
|
48
|
+
to: thumbs[end].t,
|
|
49
|
+
message: `화면이 ${len.toFixed(2)}초 ${area > 0 ? `거의 멈췄다(바뀐 면적 ${(area * 100).toFixed(1)}%)` : "멈췄다"}${severity === "warn" ? ` — style.json maxFreezeSec ${warnSec}초 넘음` : ""} — 그 사이에 눈에 띄는 움직임 하나를 둔다`,
|
|
50
|
+
detail: { seconds: Math.round(len * 100) / 100, changedArea: Math.round(area * 10000) / 10000 },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
a = Math.max(end, a + 1);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 한 색으로 칠해진 화면. 드롭 섬광처럼 한두 프레임은 일부러 그럴 수 있어 길이로 거른다.
|
|
59
|
+
export function blanks(thumbs: Thumb[], duration: number, o: { std?: number; minSec?: number } = {}): Finding[] {
|
|
60
|
+
const stdMax = o.std ?? 2.5;
|
|
61
|
+
const minSec = o.minSec ?? 0.5;
|
|
62
|
+
const out: Finding[] = [];
|
|
63
|
+
let start = -1;
|
|
64
|
+
const step = thumbs.length > 1 ? thumbs[1].t - thumbs[0].t : 0;
|
|
65
|
+
const close = (end: number) => {
|
|
66
|
+
if (start < 0) return;
|
|
67
|
+
const from = thumbs[start].t;
|
|
68
|
+
const to = thumbs[end].t + step;
|
|
69
|
+
const len = to - from;
|
|
70
|
+
const edge = from <= 0.001 || to >= duration - step - 0.001;
|
|
71
|
+
if (len >= minSec) {
|
|
72
|
+
const severity = edge && len <= 1.0 ? "warn" : "error";
|
|
73
|
+
const mean = thumbs[start].mean;
|
|
74
|
+
out.push({
|
|
75
|
+
rule: "frame.blank",
|
|
76
|
+
severity,
|
|
77
|
+
from,
|
|
78
|
+
to,
|
|
79
|
+
message: `${len.toFixed(2)}초 동안 화면이 한 색(밝기 ${Math.round(mean)})뿐이다${edge ? " — 시작·끝이면 짧게 줄인다" : " — 장면이 비었거나 그리기가 실패했다"}`,
|
|
80
|
+
detail: { seconds: Math.round(len * 100) / 100, mean: Math.round(mean) },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
start = -1;
|
|
84
|
+
};
|
|
85
|
+
for (let i = 0; i < thumbs.length; i++) {
|
|
86
|
+
if (thumbs[i].std < stdMax) {
|
|
87
|
+
if (start < 0) start = i;
|
|
88
|
+
} else close(i - 1);
|
|
89
|
+
}
|
|
90
|
+
close(thumbs.length - 1);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 이웃 프레임 차가 갑자기 튀는 곳 = 컷. 앞뒤 평소 움직임보다 훨씬 커야 한다.
|
|
95
|
+
export function cuts(thumbs: Thumb[], o: { min?: number; ratio?: number } = {}): number[] {
|
|
96
|
+
const min = o.min ?? 22;
|
|
97
|
+
const ratio = o.ratio ?? 4;
|
|
98
|
+
const d = diffs(thumbs);
|
|
99
|
+
const out: number[] = [];
|
|
100
|
+
for (let i = 1; i < d.length; i++) {
|
|
101
|
+
const around = [d[i - 2], d[i - 1], d[i + 1], d[i + 2]].filter((x) => x !== undefined) as number[];
|
|
102
|
+
const base = around.length ? around.sort((a, b) => a - b)[around.length >> 1] : 0;
|
|
103
|
+
if (d[i] >= min && d[i] >= ratio * (base + 0.5)) out.push(thumbs[i].t);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 한 프레임 튐 — 앞뒤 프레임끼리는 비슷한데 가운데 한 프레임만 둘 다와 크게 다르면 깜빡임·한 프레임 오류다.
|
|
109
|
+
// 일부러 하는 컷·섬광은 장면 경계·드롭 시각 근처면 뺀다
|
|
110
|
+
export function spikes(thumbs: Thumb[], o: { exclude?: number[]; ratio?: number; floor?: number } = {}): Finding[] {
|
|
111
|
+
const ratio = o.ratio ?? 3;
|
|
112
|
+
const floor = o.floor ?? 2.5;
|
|
113
|
+
const step = thumbs.length > 1 ? thumbs[1].t - thumbs[0].t : 0;
|
|
114
|
+
const near = (t: number) => (o.exclude ?? []).some((x) => Math.abs(x - t) <= step * 1.5 + 1e-3);
|
|
115
|
+
const out: Finding[] = [];
|
|
116
|
+
for (let i = 1; i < thumbs.length - 1; i++) {
|
|
117
|
+
const a = thumbs[i - 1].luma;
|
|
118
|
+
const b = thumbs[i].luma;
|
|
119
|
+
const c = thumbs[i + 1].luma;
|
|
120
|
+
const jump = Math.min(meanAbsDiff(a, b), meanAbsDiff(b, c));
|
|
121
|
+
if (jump < floor) continue;
|
|
122
|
+
const around = meanAbsDiff(a, c);
|
|
123
|
+
if (jump <= ratio * Math.max(around, 0.5)) continue;
|
|
124
|
+
if (near(thumbs[i].t)) continue;
|
|
125
|
+
out.push({
|
|
126
|
+
rule: "frame.spike",
|
|
127
|
+
severity: "warn",
|
|
128
|
+
from: thumbs[i].t,
|
|
129
|
+
to: thumbs[i].t + step,
|
|
130
|
+
message: `${thumbs[i].t.toFixed(2)}초 한 프레임만 앞뒤와 크게 다르다(앞뒤 차 ${jump.toFixed(1)} · 건너 차 ${around.toFixed(1)}) — 깜빡임이거나 한 프레임 오류. 일부러 한 섬광이면 드롭·장면 경계에 붙인다`,
|
|
131
|
+
detail: { jump: Math.round(jump * 100) / 100, around: Math.round(around * 100) / 100 },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|