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.
Files changed (338) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +146 -0
  3. package/README.md +77 -2
  4. package/app/package.json +12 -0
  5. package/app/server/engine.ts +415 -0
  6. package/app/server/main.ts +205 -0
  7. package/app/web/env.d.ts +2 -0
  8. package/app/web/icons.ts +31 -0
  9. package/app/web/index.html +21 -0
  10. package/app/web/main.ts +1013 -0
  11. package/app/web/style.css +430 -0
  12. package/bin/hooks.mjs +51 -0
  13. package/bin/kf.mjs +14 -0
  14. package/docs/AGENTS.md +238 -0
  15. package/music/README.md +13 -0
  16. package/package.json +44 -4
  17. package/packages/adapters/package.json +36 -0
  18. package/packages/adapters/src/css.ts +11 -0
  19. package/packages/adapters/src/gsap.ts +26 -0
  20. package/packages/adapters/src/index.ts +9 -0
  21. package/packages/adapters/src/lottie.ts +22 -0
  22. package/packages/adapters/src/media.ts +59 -0
  23. package/packages/adapters/src/page.ts +229 -0
  24. package/packages/adapters/src/project.ts +214 -0
  25. package/packages/adapters/src/react.ts +36 -0
  26. package/packages/adapters/src/svelte.ts +22 -0
  27. package/packages/adapters/src/three.ts +18 -0
  28. package/packages/adapters/src/vue.ts +27 -0
  29. package/packages/check/package.json +19 -0
  30. package/packages/check/src/check.ts +273 -0
  31. package/packages/check/src/cli.ts +70 -0
  32. package/packages/check/src/image.ts +146 -0
  33. package/packages/check/src/index.ts +9 -0
  34. package/packages/check/src/ocr.swift +37 -0
  35. package/packages/check/src/probe.ts +328 -0
  36. package/packages/check/src/report.ts +44 -0
  37. package/packages/check/src/rules/assets.ts +42 -0
  38. package/packages/check/src/rules/beats.ts +212 -0
  39. package/packages/check/src/rules/camera.ts +217 -0
  40. package/packages/check/src/rules/captions.ts +49 -0
  41. package/packages/check/src/rules/flash.ts +235 -0
  42. package/packages/check/src/rules/frames.ts +135 -0
  43. package/packages/check/src/rules/layout.ts +257 -0
  44. package/packages/check/src/rules/page.ts +87 -0
  45. package/packages/check/src/rules/runs.ts +20 -0
  46. package/packages/check/src/rules/text.ts +326 -0
  47. package/packages/check/src/scan.ts +240 -0
  48. package/packages/check/src/serve.ts +67 -0
  49. package/packages/check/src/types.ts +80 -0
  50. package/packages/check/src/video.ts +137 -0
  51. package/packages/cli/package.json +29 -0
  52. package/packages/cli/src/blur.ts +68 -0
  53. package/packages/cli/src/browser.ts +203 -0
  54. package/packages/cli/src/command.ts +44 -0
  55. package/packages/cli/src/commands/app.ts +25 -0
  56. package/packages/cli/src/commands/beats.ts +36 -0
  57. package/packages/cli/src/commands/blender.ts +270 -0
  58. package/packages/cli/src/commands/check.ts +25 -0
  59. package/packages/cli/src/commands/doctor.ts +22 -0
  60. package/packages/cli/src/commands/gen.ts +173 -0
  61. package/packages/cli/src/commands/look.ts +39 -0
  62. package/packages/cli/src/commands/lyrics.ts +111 -0
  63. package/packages/cli/src/commands/make.ts +542 -0
  64. package/packages/cli/src/commands/new.ts +176 -0
  65. package/packages/cli/src/commands/preview.ts +43 -0
  66. package/packages/cli/src/commands/render.ts +216 -0
  67. package/packages/cli/src/commands/setup.ts +63 -0
  68. package/packages/cli/src/commands/sheet.ts +114 -0
  69. package/packages/cli/src/commands/skill.ts +43 -0
  70. package/packages/cli/src/commands/still.ts +40 -0
  71. package/packages/cli/src/commands/theme.ts +85 -0
  72. package/packages/cli/src/commands/transitions.ts +40 -0
  73. package/packages/cli/src/deps.ts +122 -0
  74. package/packages/cli/src/ffmpeg.ts +81 -0
  75. package/packages/cli/src/index.ts +8 -0
  76. package/packages/cli/src/kf.ts +53 -0
  77. package/packages/cli/src/lock.ts +72 -0
  78. package/packages/cli/src/out.ts +13 -0
  79. package/packages/cli/src/serve.ts +124 -0
  80. package/packages/clock/package.json +14 -0
  81. package/packages/clock/src/index.ts +25 -0
  82. package/packages/clock/src/install.ts +351 -0
  83. package/packages/clock/src/types.ts +45 -0
  84. package/packages/core/package.json +9 -0
  85. package/packages/core/src/beats.ts +283 -0
  86. package/packages/core/src/ease.ts +87 -0
  87. package/packages/core/src/frame.ts +99 -0
  88. package/packages/core/src/index.ts +27 -0
  89. package/packages/core/src/project.ts +146 -0
  90. package/packages/core/src/rand.ts +56 -0
  91. package/packages/core/src/spring.ts +52 -0
  92. package/packages/core/src/style.ts +100 -0
  93. package/packages/core/src/time.ts +88 -0
  94. package/packages/gen/PROVIDERS.md +55 -0
  95. package/packages/gen/package.json +9 -0
  96. package/packages/gen/src/generate.ts +204 -0
  97. package/packages/gen/src/image/og.ts +133 -0
  98. package/packages/gen/src/index.ts +18 -0
  99. package/packages/gen/src/keys.ts +60 -0
  100. package/packages/gen/src/media.ts +150 -0
  101. package/packages/gen/src/plan.ts +106 -0
  102. package/packages/gen/src/registry.ts +51 -0
  103. package/packages/gen/src/seam.ts +103 -0
  104. package/packages/gen/src/types.ts +86 -0
  105. package/packages/gen/src/video/fal.ts +140 -0
  106. package/packages/gen/src/video/h3.ts +107 -0
  107. package/packages/gen/src/video/http.ts +44 -0
  108. package/packages/gen/src/video/ltx.ts +60 -0
  109. package/packages/gen/src/video/minimax.ts +55 -0
  110. package/packages/gen/src/video/veo.ts +71 -0
  111. package/packages/jizura/package.json +16 -0
  112. package/packages/jizura/src/deny.ts +73 -0
  113. package/packages/jizura/src/index.ts +7 -0
  114. package/packages/jizura/src/install.ts +226 -0
  115. package/packages/jizura/src/layer.ts +282 -0
  116. package/packages/jizura/src/lyrics.ts +81 -0
  117. package/packages/jizura/src/pin.ts +5 -0
  118. package/packages/jizura/src/vite.ts +36 -0
  119. package/packages/motion/README.md +142 -0
  120. package/packages/motion/package.json +29 -0
  121. package/packages/motion/src/camera.ts +97 -0
  122. package/packages/motion/src/captions.ts +479 -0
  123. package/packages/motion/src/enter.ts +128 -0
  124. package/packages/motion/src/hangul.ts +129 -0
  125. package/packages/motion/src/impact.ts +100 -0
  126. package/packages/motion/src/index.ts +17 -0
  127. package/packages/motion/src/moves/brushWipe.ts +90 -0
  128. package/packages/motion/src/moves/cloneArray.ts +111 -0
  129. package/packages/motion/src/moves/curtains.ts +81 -0
  130. package/packages/motion/src/moves/echoStack.ts +70 -0
  131. package/packages/motion/src/moves/fit.ts +34 -0
  132. package/packages/motion/src/moves/flood.ts +61 -0
  133. package/packages/motion/src/moves/ghostStack.ts +79 -0
  134. package/packages/motion/src/moves/gridFloor.ts +100 -0
  135. package/packages/motion/src/moves/imageSwarm.ts +109 -0
  136. package/packages/motion/src/moves/index.ts +19 -0
  137. package/packages/motion/src/moves/irisBlades.ts +113 -0
  138. package/packages/motion/src/moves/karaokeLine.ts +94 -0
  139. package/packages/motion/src/moves/ledBoard.ts +94 -0
  140. package/packages/motion/src/moves/liquidTab.ts +72 -0
  141. package/packages/motion/src/moves/markerWrite.ts +63 -0
  142. package/packages/motion/src/moves/morphBox.ts +96 -0
  143. package/packages/motion/src/moves/neonSign.ts +77 -0
  144. package/packages/motion/src/moves/panelPop.ts +78 -0
  145. package/packages/motion/src/moves/paperTear.ts +75 -0
  146. package/packages/motion/src/moves/path.ts +64 -0
  147. package/packages/motion/src/moves/pathText.ts +107 -0
  148. package/packages/motion/src/moves/pointSphere.ts +109 -0
  149. package/packages/motion/src/moves/ransomLetters.ts +73 -0
  150. package/packages/motion/src/moves/shapeMorph.ts +143 -0
  151. package/packages/motion/src/moves/slamWords.ts +89 -0
  152. package/packages/motion/src/moves/sparkTrail.ts +114 -0
  153. package/packages/motion/src/moves/speedLines.ts +91 -0
  154. package/packages/motion/src/moves/splitFlap.ts +147 -0
  155. package/packages/motion/src/moves/stepWords.ts +117 -0
  156. package/packages/motion/src/moves/strikeOut.ts +98 -0
  157. package/packages/motion/src/moves/sunburst.ts +58 -0
  158. package/packages/motion/src/moves/tickerRows.ts +87 -0
  159. package/packages/motion/src/moves/tokenPicker.ts +114 -0
  160. package/packages/motion/src/moves/truchet.ts +85 -0
  161. package/packages/motion/src/moves/wordWall.ts +76 -0
  162. package/packages/motion/src/moves/worldRoll.ts +31 -0
  163. package/packages/motion/src/particles.ts +102 -0
  164. package/packages/motion/src/react/index.tsx +21 -0
  165. package/packages/motion/src/react/parts.tsx +431 -0
  166. package/packages/motion/src/react/time.tsx +97 -0
  167. package/packages/motion/src/text.ts +103 -0
  168. package/packages/motion/src/transition.ts +82 -0
  169. package/packages/motion/src/transition3d/cards.ts +65 -0
  170. package/packages/motion/src/transition3d/chain.ts +84 -0
  171. package/packages/motion/src/transition3d/dive.ts +181 -0
  172. package/packages/motion/src/transition3d/fold.ts +110 -0
  173. package/packages/motion/src/transition3d/gl.ts +219 -0
  174. package/packages/motion/src/transition3d/index.ts +228 -0
  175. package/packages/motion/src/transition3d/live.ts +70 -0
  176. package/packages/motion/src/transition3d/portal.ts +115 -0
  177. package/packages/motion/src/transition3d/react.tsx +29 -0
  178. package/packages/motion/src/transition3d/rig.ts +502 -0
  179. package/packages/motion/src/transition3d/shatter.ts +101 -0
  180. package/packages/motion/src/transition3d/whip.ts +65 -0
  181. package/packages/motion/src/transition3d/world.ts +683 -0
  182. package/packages/motion/src/transition3d/zoomOut.ts +114 -0
  183. package/packages/motion/src/transitions2d/catalog.ts +127 -0
  184. package/packages/motion/src/transitions2d/css.d.ts +2 -0
  185. package/packages/motion/src/transitions2d/cult-ui/LICENSE.md +9 -0
  186. package/packages/motion/src/transitions2d/cult-ui/RollingNumber.tsx +43 -0
  187. package/packages/motion/src/transitions2d/gl-transitions/LICENSE +25 -0
  188. package/packages/motion/src/transitions2d/gl-transitions/shaders.ts +1931 -0
  189. package/packages/motion/src/transitions2d/index.ts +2 -0
  190. package/packages/motion/src/transitions2d/kokonutui/LICENSE +21 -0
  191. package/packages/motion/src/transitions2d/kokonutui/MatrixText.css +16 -0
  192. package/packages/motion/src/transitions2d/kokonutui/MatrixText.tsx +94 -0
  193. package/packages/motion/src/transitions2d/magicui/HyperText.css +15 -0
  194. package/packages/motion/src/transitions2d/magicui/HyperText.tsx +115 -0
  195. package/packages/motion/src/transitions2d/magicui/LICENSE.md +21 -0
  196. package/packages/motion/src/transitions2d/magicui/MorphingText.css +25 -0
  197. package/packages/motion/src/transitions2d/magicui/MorphingText.tsx +130 -0
  198. package/packages/motion/src/transitions2d/magicui/WordRotate.tsx +42 -0
  199. package/packages/motion/src/transitions2d/react.ts +13 -0
  200. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.css +39 -0
  201. package/packages/motion/src/transitions2d/skiper-ui/ClipDiv.tsx +32 -0
  202. package/packages/motion/src/transitions2d/smoothui/LICENSE +21 -0
  203. package/packages/motion/src/transitions2d/smoothui/SharedAxisY.tsx +50 -0
  204. package/packages/motion/src/transitions2d/smoothui/TextMorph.tsx +90 -0
  205. package/packages/motion/src/transitions2d/smoothui/shaders.ts +223 -0
  206. package/packages/motion/src/transitions2d/unlumen/LICENSE +21 -0
  207. package/packages/motion/src/transitions2d/unlumen/ScrambleText.tsx +114 -0
  208. package/packages/motion/src/transitions2d/vengeance-ui/LICENSE +21 -0
  209. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.css +69 -0
  210. package/packages/motion/src/transitions2d/vengeance-ui/MorphText.tsx +45 -0
  211. package/packages/motion/src/type.ts +131 -0
  212. package/packages/motion/src/ui.ts +172 -0
  213. package/packages/plan/package.json +15 -0
  214. package/packages/plan/src/brief.ts +254 -0
  215. package/packages/plan/src/captions.ts +75 -0
  216. package/packages/plan/src/detect.ts +93 -0
  217. package/packages/plan/src/index.ts +23 -0
  218. package/packages/plan/src/layout.ts +176 -0
  219. package/packages/plan/src/llm.ts +110 -0
  220. package/packages/plan/src/look.ts +220 -0
  221. package/packages/plan/src/plan.ts +125 -0
  222. package/packages/plan/src/request.ts +55 -0
  223. package/packages/plan/src/studio.ts +53 -0
  224. package/packages/plan/src/templates.ts +42 -0
  225. package/packages/plan/src/themes.ts +48 -0
  226. package/packages/plan/src/types.ts +106 -0
  227. package/packages/studio/package.json +12 -0
  228. package/packages/studio/src/index.ts +1 -0
  229. package/packages/studio/src/plugin.ts +236 -0
  230. package/packages/studio/src/ui/main.ts +503 -0
  231. package/packages/studio/src/ui/studio.css +165 -0
  232. package/packages/studio/src/ui/timeline.ts +317 -0
  233. package/packages/themes/kit/THEME-MAKE.md +68 -0
  234. package/packages/themes/kit/THEME.md +30 -0
  235. package/packages/themes/kit/briefs/og-intro-15s/brief.json +16 -0
  236. package/packages/themes/kit/briefs/og-intro-15s/facts.ts +29 -0
  237. package/packages/themes/kit/briefs/og-intro-15s/logo.svg +12 -0
  238. package/packages/themes/kit/effects.ts +5 -0
  239. package/packages/themes/kit/sample/index.html +11 -0
  240. package/packages/themes/kit/sample/main.ts +9 -0
  241. package/packages/themes/kit/sample/scenes/scene.ts +15 -0
  242. package/packages/themes/package.json +16 -0
  243. package/packages/themes/src/factory.ts +296 -0
  244. package/packages/themes/src/fx.ts +14 -0
  245. package/packages/themes/src/index.ts +105 -0
  246. package/packages/themes/src/stage3d.ts +177 -0
  247. package/packages/themes/src/types.ts +58 -0
  248. package/presets/kawaii-pastel.json +94 -0
  249. package/presets/neutral-dark.json +82 -0
  250. package/presets/og.json +98 -0
  251. package/skills/kasaframe/SKILL.md +32 -0
  252. package/templates/_shared/project/index.html +11 -0
  253. package/templates/_shared/project/lib/react.ts +22 -0
  254. package/templates/_shared/project/lib/stage.ts +272 -0
  255. package/templates/_shared/project/main.ts +9 -0
  256. package/templates/_shared/scenes/default.ts +18 -0
  257. package/templates/_shared/scenes/drop.ts +30 -0
  258. package/templates/_shared/scenes/end.ts +23 -0
  259. package/templates/_shared/scenes/hook.ts +16 -0
  260. package/templates/_shared/scenes/seam.ts +41 -0
  261. package/templates/_shared/scenes/step.ts +40 -0
  262. package/templates/ad/preview.png +0 -0
  263. package/templates/ad/template.json +132 -0
  264. package/templates/demo/preview.png +0 -0
  265. package/templates/demo/template.json +130 -0
  266. package/templates/explainer/preview.png +0 -0
  267. package/templates/explainer/template.json +142 -0
  268. package/templates/launch/preview.png +0 -0
  269. package/templates/launch/template.json +130 -0
  270. package/templates/mv/preview.png +0 -0
  271. package/templates/mv/scenes/default.ts +95 -0
  272. package/templates/mv/template.json +176 -0
  273. package/templates/promo/preview.png +0 -0
  274. package/templates/promo/template.json +144 -0
  275. package/templates/shorts/preview.png +0 -0
  276. package/templates/shorts/scenes/hook.ts +34 -0
  277. package/templates/shorts/template.json +127 -0
  278. package/templates/square/preview.png +0 -0
  279. package/templates/square/template.json +113 -0
  280. package/themes/debug-hud/THEME.md +39 -0
  281. package/themes/debug-hud/effects.ts +389 -0
  282. package/themes/debug-hud/sample/assets/og/logo.svg +12 -0
  283. package/themes/debug-hud/sample/beats.json +1 -0
  284. package/themes/debug-hud/sample/index.html +11 -0
  285. package/themes/debug-hud/sample/kf.json +59 -0
  286. package/themes/debug-hud/sample/lib/hudkit.ts +49 -0
  287. package/themes/debug-hud/sample/lib/og.ts +29 -0
  288. package/themes/debug-hud/sample/main.ts +9 -0
  289. package/themes/debug-hud/sample/scenes/end.ts +62 -0
  290. package/themes/debug-hud/sample/scenes/gather.ts +67 -0
  291. package/themes/debug-hud/sample/scenes/models.ts +70 -0
  292. package/themes/debug-hud/sample/scenes/price.ts +79 -0
  293. package/themes/debug-hud/sample/style.json +91 -0
  294. package/themes/debug-hud/still.png +0 -0
  295. package/themes/debug-hud/style.json +91 -0
  296. package/themes/debug-hud/theme.json +68 -0
  297. package/themes/og-measure/THEME.md +47 -0
  298. package/themes/og-measure/effects.ts +432 -0
  299. package/themes/og-measure/sample/assets/brand/og-logo-full.svg +12 -0
  300. package/themes/og-measure/sample/assets/brand/og-logo.svg +12 -0
  301. package/themes/og-measure/sample/assets/data/measure.json +353 -0
  302. package/themes/og-measure/sample/beats.json +1 -0
  303. package/themes/og-measure/sample/index.html +11 -0
  304. package/themes/og-measure/sample/kf.json +66 -0
  305. package/themes/og-measure/sample/lib/data.ts +62 -0
  306. package/themes/og-measure/sample/main.ts +9 -0
  307. package/themes/og-measure/sample/measure.ts +78 -0
  308. package/themes/og-measure/sample/scenes/end.ts +47 -0
  309. package/themes/og-measure/sample/scenes/gather.ts +51 -0
  310. package/themes/og-measure/sample/scenes/models.ts +34 -0
  311. package/themes/og-measure/sample/scenes/one.ts +39 -0
  312. package/themes/og-measure/sample/scenes/price.ts +56 -0
  313. package/themes/og-measure/sample/style.json +97 -0
  314. package/themes/og-measure/still.png +0 -0
  315. package/themes/og-measure/style.json +97 -0
  316. package/themes/og-measure/theme.json +79 -0
  317. package/themes/riso-zine/THEME.md +43 -0
  318. package/themes/riso-zine/effects.ts +321 -0
  319. package/themes/riso-zine/sample/assets/og/logo.svg +12 -0
  320. package/themes/riso-zine/sample/beats.json +1 -0
  321. package/themes/riso-zine/sample/index.html +11 -0
  322. package/themes/riso-zine/sample/kf.json +59 -0
  323. package/themes/riso-zine/sample/lib/og.ts +29 -0
  324. package/themes/riso-zine/sample/lib/shot.ts +86 -0
  325. package/themes/riso-zine/sample/lib/zine.ts +42 -0
  326. package/themes/riso-zine/sample/main.ts +9 -0
  327. package/themes/riso-zine/sample/scenes/end.ts +61 -0
  328. package/themes/riso-zine/sample/scenes/gather.ts +62 -0
  329. package/themes/riso-zine/sample/scenes/models.ts +52 -0
  330. package/themes/riso-zine/sample/scenes/price.ts +82 -0
  331. package/themes/riso-zine/sample/style.json +99 -0
  332. package/themes/riso-zine/still.png +0 -0
  333. package/themes/riso-zine/style.json +99 -0
  334. package/themes/riso-zine/theme.json +75 -0
  335. package/tools/beats/beats.py +372 -0
  336. package/tools/beats/requirements.txt +3 -0
  337. package/tools/lyrics/align.py +164 -0
  338. 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
+ }