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,273 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, extname, join, resolve } from "node:path";
3
+ import { clockScript } from "@kasaframe/clock";
4
+ import { openProject, repoRoot } from "@kasaframe/cli";
5
+ import { scanPage } from "./scan.ts";
6
+ import { serveStatic } from "./serve.ts";
7
+ import { probeVideo, videoText, videoThumbs } from "./video.ts";
8
+ import { badText, glyphProblems, offscreenText, textPolicy, type TextPolicy } from "./rules/text.ts";
9
+ import { captionOverlap } from "./rules/captions.ts";
10
+ import { genAssets } from "./rules/assets.ts";
11
+ import { blanks, cuts, freezes, spikes } from "./rules/frames.ts";
12
+ import { flashes } from "./rules/flash.ts";
13
+ import { fill, staticLayout, type SceneSpan } from "./rules/layout.ts";
14
+ import { cameraWander, heroEdge } from "./rules/camera.ts";
15
+ import { boundaries, motionRules } from "./rules/beats.ts";
16
+ import { contrast, pageErrors, repeatable } from "./rules/page.ts";
17
+ import type { Beats, Finding, MotionEvent, MotionRules, Report, SafeArea } from "./types.ts";
18
+
19
+ export interface CheckOptions {
20
+ target: string;
21
+ from?: number;
22
+ to?: number;
23
+ tabs?: number;
24
+ // 옛 페이지(window.seek 만 있는 곳)에서 meta 대신
25
+ fps?: number;
26
+ duration?: number;
27
+ width?: number;
28
+ height?: number;
29
+ query?: string;
30
+ thumbFps?: number;
31
+ shotFps?: number;
32
+ ocr?: boolean;
33
+ ocrFps?: number;
34
+ beats?: string;
35
+ style?: string;
36
+ log?: (s: string) => void;
37
+ }
38
+
39
+ interface Project {
40
+ dir: string | null;
41
+ kf: any | null;
42
+ beats: Beats | null;
43
+ rules: MotionRules;
44
+ safe?: SafeArea;
45
+ text: TextPolicy;
46
+ allowText: string[];
47
+ scenes: number[];
48
+ }
49
+
50
+ function readJson(p: string): any | null {
51
+ try {
52
+ return JSON.parse(readFileSync(p, "utf8"));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ // kf.json 이 있는 폴더가 프로젝트. mp4 는 보통 <proj>/out/ 에 있으니 위로 두 칸까지 찾는다.
59
+ function findProject(start: string): string | null {
60
+ let d = resolve(start);
61
+ for (let i = 0; i < 3; i++) {
62
+ if (existsSync(join(d, "kf.json"))) return d;
63
+ d = dirname(d);
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function loadProject(dir: string | null, o: CheckOptions): Project {
69
+ const kf = dir ? readJson(join(dir, "kf.json")) : null;
70
+ const at = (p: string | undefined, fallback: string) => (dir ? join(dir, p ?? fallback) : null);
71
+ const beatsPath = o.beats ?? at(kf?.beats, "beats.json");
72
+ const stylePath = o.style ?? at(kf?.style, "style.json");
73
+ const beats = beatsPath ? readJson(beatsPath) : null;
74
+ const style = stylePath ? readJson(stylePath) : null;
75
+ // 틀의 motion 이 style.json 위에 덮인다(SPEC 계약 7).
76
+ const template = kf?.template ? readJson(join(repoRoot, "templates", kf.template, "template.json")) : null;
77
+ const rules: MotionRules = { ...(style?.motion ?? {}), ...(template?.motion ?? {}) };
78
+ const scenes: number[] = Array.isArray(kf?.scenes) ? kf.scenes.map((s: any) => Number(s.from)).filter((t: number) => Number.isFinite(t)) : [];
79
+ return {
80
+ dir,
81
+ kf,
82
+ beats: beats && Array.isArray(beats.beats) ? beats : null,
83
+ rules,
84
+ safe: kf?.safeArea,
85
+ text: { ...(template?.text ?? {}), ...(kf?.text ?? {}) },
86
+ allowText: kf?.check?.allowText ?? [],
87
+ scenes,
88
+ };
89
+ }
90
+
91
+ // 화면 채움·장면 안 구도 변화는 장면 단위로 본다 — kf.json 장면이 없으면 영상 하나를 한 장면으로
92
+ function sceneSpans(kf: any): SceneSpan[] {
93
+ return Array.isArray(kf?.scenes) ? kf.scenes.filter((s: any) => Number.isFinite(Number(s.from))).map((s: any) => ({ id: String(s.id), from: Number(s.from), dur: Number(s.dur) || 0 })) : [];
94
+ }
95
+
96
+ // 이음매는 화면 전체를 옮기는 게 일이다 — 카메라·주인공 자리 판정에서 뺀다
97
+ function seamScenes(kf: any): string[] {
98
+ return Array.isArray(kf?.scenes) ? kf.scenes.filter((s: any) => s.role === "seam").map((s: any) => String(s.id)) : [];
99
+ }
100
+
101
+ function sortFindings(f: Finding[]): Finding[] {
102
+ return f.sort((a, b) => (a.severity === b.severity ? a.from - b.from : a.severity === "error" ? -1 : 1));
103
+ }
104
+
105
+ function finish(target: string, kind: Report["kind"], meta: Report["meta"], findings: Finding[], skipped: string[], sampled: Report["sampled"], t0: number): Report {
106
+ const counts = { error: findings.filter((f) => f.severity === "error").length, warn: findings.filter((f) => f.severity === "warn").length };
107
+ const round = (x: number) => Math.round(x * 1000) / 1000;
108
+ for (const f of findings) {
109
+ f.from = round(f.from);
110
+ f.to = round(f.to);
111
+ }
112
+ return { target, kind, ok: counts.error === 0, meta, sampled, counts, findings: sortFindings(findings), skipped, elapsedSec: round((Date.now() - t0) / 1000) };
113
+ }
114
+
115
+ // kf.json 이 없어도 TS·TSX 모듈을 부르는 페이지는 묶어야 돈다 — 엔진의 Vite 길로 연다.
116
+ function needsVite(dir: string): boolean {
117
+ if (["vite.config.ts", "vite.config.js", "vite.config.mjs"].some((f) => existsSync(join(dir, f)))) return true;
118
+ const html = join(dir, "index.html");
119
+ return existsSync(html) && /<script[^>]+src="[^"]+\.tsx?"/.test(readFileSync(html, "utf8"));
120
+ }
121
+
122
+ const VIDEO = new Set([".mp4", ".mov", ".webm", ".mkv", ".m4v"]);
123
+
124
+ export async function check(o: CheckOptions): Promise<Report> {
125
+ const isUrl = /^https?:\/\//.test(o.target);
126
+ if (!isUrl && !existsSync(o.target)) throw new Error(`없는 경로: ${o.target}`);
127
+ if (!isUrl && statSync(o.target).isFile() && VIDEO.has(extname(o.target).toLowerCase())) return checkVideo(o);
128
+ return checkPage(o);
129
+ }
130
+
131
+ async function checkPage(o: CheckOptions): Promise<Report> {
132
+ const t0 = Date.now();
133
+ const log = o.log ?? (() => {});
134
+ const isUrl = /^https?:\/\//.test(o.target);
135
+ const dir = isUrl ? null : resolve(o.target);
136
+ const projDir = dir ? findProject(dir) : null;
137
+ const proj = loadProject(projDir, o);
138
+ const skipped: string[] = [];
139
+
140
+ let url: string;
141
+ let close: () => Promise<void>;
142
+ let clock: string | undefined;
143
+ if (isUrl) {
144
+ url = o.target;
145
+ close = async () => {};
146
+ } else if (projDir || needsVite(dir!)) {
147
+ const p = await openProject(projDir ?? dir!, { hmr: false });
148
+ url = p.url;
149
+ close = p.close;
150
+ clock = await clockScript();
151
+ } else {
152
+ if (!existsSync(join(dir!, "index.html"))) throw new Error(`${dir} 에 index.html 도 kf.json 도 없다`);
153
+ const s = await serveStatic(dir!);
154
+ url = s.url + "index.html";
155
+ close = s.close;
156
+ }
157
+ if (o.query) url += (url.includes("?") ? "&" : "?") + o.query.replace(/^\?/, "");
158
+
159
+ try {
160
+ const r = await scanPage({
161
+ url,
162
+ width: o.width ?? proj.kf?.width ?? 1920,
163
+ height: o.height ?? proj.kf?.height ?? 1080,
164
+ fps: o.fps,
165
+ duration: o.duration,
166
+ from: o.from,
167
+ to: o.to,
168
+ // 팬 소리 줄이기 — 탭은 둘까지
169
+ tabs: Math.min(2, o.tabs ?? 2),
170
+ // 0.1초 멈춤을 잡으려면 0.1초보다 촘촘히 봐야 한다
171
+ thumbFps: o.thumbFps ?? 30,
172
+ shotFps: o.shotFps ?? 2,
173
+ clock,
174
+ log,
175
+ });
176
+ const { meta } = r;
177
+ if (r.legacy) skipped.push("페이지 계약(window.__kf) 대신 옛 window.seek(t) 로 훑었다");
178
+ const f: Finding[] = [];
179
+ f.push(...pageErrors(r.errors, meta.fps));
180
+ f.push(...badText(r.texts, { fps: meta.fps, allow: proj.allowText }));
181
+ f.push(...glyphProblems(r.texts, r.glyphs, meta.fps));
182
+ f.push(...offscreenText(r.texts, { width: meta.width, height: meta.height, fps: meta.fps, safe: proj.safe }));
183
+ f.push(...captionOverlap(r.texts, { fps: meta.fps }));
184
+ f.push(...contrast(r.contrast, { shotFps: o.shotFps ?? 2 }));
185
+ if (proj.text.minPx || proj.text.maxOnScreen) f.push(...textPolicy(r.texts, proj.text, { fps: meta.fps }));
186
+ if (projDir) f.push(...genAssets(projDir));
187
+ f.push(...freezes(r.thumbs, { warnSec: proj.rules.maxFreezeSec }));
188
+ f.push(...blanks(r.thumbs, meta.duration));
189
+ f.push(...spikes(r.thumbs, { exclude: [...sceneSpans(proj.kf).map((s) => s.from), ...(proj.beats?.drops ?? []), ...r.markers.filter((m) => m.kind === "scene" || m.kind === "drop").map((m) => m.t)] }));
190
+ f.push(...flashes(r.thumbs, { maxPerSec: proj.rules.maxFlashPerSec, cuts: [...sceneSpans(proj.kf).map((s) => s.from), ...cuts(r.thumbs)] }));
191
+ f.push(...fill(r.thumbs, sceneSpans(proj.kf), { width: meta.width, height: meta.height, safe: proj.safe }));
192
+ f.push(...staticLayout(r.thumbs, sceneSpans(proj.kf), { width: meta.width, height: meta.height, warnSec: proj.rules.staticSec?.warn, errorSec: proj.rules.staticSec?.error }));
193
+ const seams = seamScenes(proj.kf);
194
+ f.push(...cameraWander(r.cams, r.events, { fps: meta.fps, skipScenes: seams }));
195
+ f.push(...heroEdge(r.cams, r.texts, sceneSpans(proj.kf), { width: meta.width, height: meta.height, fps: meta.fps, safe: proj.safe, skipScenes: seams }));
196
+ if (r.repeat.length) f.push(...repeatable(r.repeat));
197
+ else skipped.push(r.mode === "step" ? "같은 프레임 두 번 찍기 — step 모드는 되감을 수 없다" : "같은 프레임 두 번 찍기 — 구간이 1초보다 짧다");
198
+
199
+ const sceneStarts = r.markers.filter((m) => m.kind === "scene").map((m) => m.t);
200
+ const starts = sceneStarts.length ? sceneStarts : proj.scenes;
201
+ if (proj.beats && starts.length) f.push(...boundaries(starts, proj.beats, { fps: meta.fps, duration: meta.duration, source: "scene" }));
202
+ else skipped.push(proj.beats ? "장면 경계 — markers·kf.json scenes 가 없다" : "장면 경계·박 — beats.json 이 없다");
203
+ motionPart(f, skipped, r.events, proj, meta);
204
+
205
+ return { ...finish(o.target, "page", meta, f, skipped, { frames: r.frames, textFrames: r.texts.length, thumbFps: r.thumbFps }, t0), mode: r.mode };
206
+ } finally {
207
+ await close();
208
+ }
209
+ }
210
+
211
+ function motionPart(f: Finding[], skipped: string[], events: MotionEvent[] | null, proj: Project, meta: Report["meta"]): void {
212
+ if (!Object.keys(proj.rules).length) {
213
+ skipped.push("움직임 규칙 — style.json motion 이 없다");
214
+ } else if (!events) {
215
+ skipped.push("움직임 규칙 — 렌더 옆 .json(events)이 없다. 프로젝트 폴더로 kf check 하면 센다");
216
+ } else {
217
+ f.push(...motionRules(events, proj.beats, proj.rules, { fps: meta.fps, duration: meta.duration }));
218
+ if (!events.length) skipped.push("움직임 규칙 — window.__kf.events 가 비었다(움직임 부품을 안 썼거나 core emit 을 안 부른다)");
219
+ if (!proj.beats) skipped.push("움직임 규칙 중 드롭·프레이즈 기준 — beats.json 이 없다");
220
+ }
221
+ }
222
+
223
+ async function checkVideo(o: CheckOptions): Promise<Report> {
224
+ const t0 = Date.now();
225
+ const log = o.log ?? (() => {});
226
+ const file = resolve(o.target);
227
+ const info = probeVideo(file);
228
+ const from = Math.max(0, o.from ?? 0);
229
+ const to = Math.min(info.duration, o.to ?? info.duration);
230
+ const proj = loadProject(findProject(dirname(file)), o);
231
+ // kf render 가 남긴 x.json — markers·events 가 들어 있어 페이지를 다시 안 열어도 된다.
232
+ const side = readJson(join(dirname(file), basename(file, extname(file)) + ".json"));
233
+ const skipped: string[] = [];
234
+ const f: Finding[] = [];
235
+ const meta = { fps: info.fps, width: info.width, height: info.height, duration: info.duration };
236
+
237
+ log(`프레임 읽기: ${from.toFixed(2)}–${to.toFixed(2)}초 · ${info.width}×${info.height} · ${info.fps.toFixed(2)}fps`);
238
+ const thumbs = await videoThumbs(file, info, from, to);
239
+ f.push(...freezes(thumbs, { warnSec: proj.rules.maxFreezeSec }));
240
+ f.push(...blanks(thumbs, info.duration));
241
+ f.push(...spikes(thumbs, { exclude: [...sceneSpans(proj.kf).map((s) => s.from), ...(proj.beats?.drops ?? [])] }));
242
+ f.push(...flashes(thumbs, { maxPerSec: proj.rules.maxFlashPerSec, cuts: [...sceneSpans(proj.kf).map((s) => s.from), ...cuts(thumbs)] }));
243
+ f.push(...fill(thumbs, sceneSpans(proj.kf), { width: info.width, height: info.height, safe: proj.safe }));
244
+ f.push(...staticLayout(thumbs, sceneSpans(proj.kf), { width: info.width, height: info.height, warnSec: proj.rules.staticSec?.warn, errorSec: proj.rules.staticSec?.error }));
245
+
246
+ let textFrames = 0;
247
+ if (o.ocr === false) skipped.push("화면 글 훑기 — --no-ocr");
248
+ else {
249
+ const ocrFps = o.ocrFps ?? 2;
250
+ log(`글 읽기(OCR ${ocrFps}fps)`);
251
+ const texts = await videoText(file, info, { fps: ocrFps, from, to });
252
+ if (!texts) skipped.push("화면 글 훑기 — OCR(macOS Vision·swiftc)이 없다. 프로젝트 폴더로 kf check 하면 글을 직접 본다");
253
+ else {
254
+ textFrames = texts.length;
255
+ f.push(...badText(texts, { fps: ocrFps, allow: proj.allowText, fuzzy: true }));
256
+ skipped.push(`mp4 글 훑기는 OCR ${ocrFps}fps — 두부 글자·화면 밖 글·대비·짧게 스친 글은 프로젝트 폴더 검사에서 본다`);
257
+ }
258
+ }
259
+
260
+ const markers: { t: number; kind: string }[] = side?.markers ?? [];
261
+ const sceneStarts = markers.filter((m) => m.kind === "scene").map((m) => m.t);
262
+ const starts = sceneStarts.length ? sceneStarts : proj.scenes;
263
+ if (proj.beats) {
264
+ if (starts.length) f.push(...boundaries(starts, proj.beats, { fps: info.fps, duration: info.duration, source: "scene" }));
265
+ else f.push(...boundaries(cuts(thumbs), proj.beats, { fps: info.fps, duration: info.duration, source: "cut" }));
266
+ } else skipped.push("장면 경계·박 — beats.json 이 없다(--beats 로 줄 수 있다)");
267
+ motionPart(f, skipped, side?.events ?? null, proj, meta);
268
+ if (proj.dir) f.push(...genAssets(proj.dir));
269
+ skipped.push("같은 프레임 두 번 찍기 — mp4 로는 못 한다");
270
+ skipped.push("카메라 떠돌기·주인공 자리(camera.wander·layout.edge) — mp4 로는 못 본다. 프로젝트 폴더로 kf check 하면 카메라 층을 직접 잰다");
271
+
272
+ return finish(o.target, "video", meta, f, skipped, { frames: thumbs.length, textFrames, thumbFps: info.fps }, t0);
273
+ }
@@ -0,0 +1,70 @@
1
+ import { mkdirSync, writeFileSync, existsSync, statSync } from "node:fs";
2
+ import { basename, dirname, extname, join, resolve } from "node:path";
3
+ import { num, parseFlags } from "@kasaframe/cli";
4
+ import { check } from "./check.ts";
5
+ import { formatReport } from "./report.ts";
6
+
7
+ export const usage = `kf check <프로젝트 폴더 | 영상.mp4 | http 주소> [옵션]
8
+
9
+ 폴더(kf.json 또는 index.html)는 페이지를 열어 프레임마다 훑는다 — 가장 정확하다.
10
+ mp4 는 프레임 차·OCR 로 본다. 옆에 kf render 가 남긴 같은 이름 .json 이 있으면 움직임 규칙도 센다.
11
+
12
+ --from <초> --to <초> 이 구간만
13
+ --json <경로> JSON 보고서 자리 (기본: 폴더/out/check.json · 영상 옆 <이름>.check.json)
14
+ --strict 경고도 실패로 친다
15
+ --tabs <n> 크롬 탭 수(기본 2, 최대 2)
16
+ --query <a=1> 페이지 주소 뒤에 붙일 질의(옛 페이지의 ?rec=1 따위)
17
+ --fps <n> --duration <초> --width <px> --height <px> window.__kf 가 없는 옛 페이지용
18
+ --beats <beats.json> --style <style.json> 프로젝트 밖 영상용
19
+ --ocr-fps <n> mp4 글 읽기 빈도(기본 2) · --no-ocr 로 끈다
20
+
21
+ 종료 코드: 0 통과 · 1 오류 있음(--strict 면 경고도) · 2 검사를 못 돌림`;
22
+
23
+ function defaultJson(target: string): string | null {
24
+ if (/^https?:\/\//.test(target)) return null;
25
+ const p = resolve(target);
26
+ if (statSync(p).isDirectory()) return join(p, "out", "check.json");
27
+ return join(dirname(p), basename(p, extname(p)) + ".check.json");
28
+ }
29
+
30
+ export async function run(argv: string[]): Promise<number> {
31
+ const { args, opts } = parseFlags(argv);
32
+ const target = args[0];
33
+ if (!target) {
34
+ console.error(usage);
35
+ return 2;
36
+ }
37
+ const opt = (k: string) => (typeof opts[k] === "string" ? (opts[k] as string) : undefined);
38
+ const n = (k: string) => (opts[k] === undefined ? undefined : num(opts[k], NaN));
39
+ try {
40
+ if (!/^https?:\/\//.test(target) && !existsSync(target)) throw new Error(`없는 경로: ${target}`);
41
+ const report = await check({
42
+ target,
43
+ from: n("from"),
44
+ to: n("to"),
45
+ tabs: n("tabs"),
46
+ fps: n("fps"),
47
+ duration: n("duration"),
48
+ width: n("width"),
49
+ height: n("height"),
50
+ query: opt("query"),
51
+ ocr: opts["no-ocr"] ? false : undefined,
52
+ ocrFps: n("ocr-fps"),
53
+ beats: opt("beats"),
54
+ style: opt("style"),
55
+ log: (s) => console.error(s),
56
+ });
57
+ if (opts.strict && report.counts.warn) report.ok = false;
58
+ const jsonPath = opt("json") ?? defaultJson(target);
59
+ console.log(formatReport(report));
60
+ if (jsonPath) {
61
+ mkdirSync(dirname(jsonPath), { recursive: true });
62
+ writeFileSync(jsonPath, JSON.stringify(report, null, 2) + "\n");
63
+ console.log(`JSON: ${jsonPath}`);
64
+ }
65
+ return report.ok ? 0 : 1;
66
+ } catch (err) {
67
+ console.error(`kf check: 검사를 못 돌렸다 — ${err instanceof Error ? err.message : String(err)}`);
68
+ return 2;
69
+ }
70
+ }
@@ -0,0 +1,146 @@
1
+ import { PNG } from "pngjs";
2
+ import type { Box } from "./types.ts";
3
+
4
+ export interface Rgba {
5
+ width: number;
6
+ height: number;
7
+ data: Uint8Array;
8
+ }
9
+
10
+ export interface Thumb {
11
+ t: number;
12
+ luma: Uint8Array;
13
+ mean: number;
14
+ std: number;
15
+ // FLASH_GRID×FLASH_GRID 칸마다 채도 높은 빨강 픽셀 몫 — 색을 받아 찍은 썸네일만(섬광 검사의 빨강 깜빡임)
16
+ red?: Float32Array;
17
+ }
18
+
19
+ export function decodePng(buf: Buffer): Rgba {
20
+ const png = PNG.sync.read(buf);
21
+ return { width: png.width, height: png.height, data: png.data };
22
+ }
23
+
24
+ export function luma(img: Rgba): Uint8Array {
25
+ const out = new Uint8Array(img.width * img.height);
26
+ for (let i = 0, j = 0; j < out.length; i += 4, j++) {
27
+ out[j] = (img.data[i] * 54 + img.data[i + 1] * 183 + img.data[i + 2] * 19) >> 8;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export function stats(l: Uint8Array): { mean: number; std: number } {
33
+ let s = 0;
34
+ let s2 = 0;
35
+ for (const v of l) {
36
+ s += v;
37
+ s2 += v * v;
38
+ }
39
+ const mean = s / l.length;
40
+ return { mean, std: Math.sqrt(Math.max(0, s2 / l.length - mean * mean)) };
41
+ }
42
+
43
+ export function thumb(t: number, l: Uint8Array): Thumb {
44
+ return { t, luma: l, ...stats(l) };
45
+ }
46
+
47
+ export const FLASH_GRID = 8;
48
+
49
+ // ITU-R BT.1702·WCAG 의 「채도 높은 빨강」 — R/(R+G+B) ≥ 0.8 이고 (R−G−B)×320 > 20(0..1 눈금)
50
+ export function redGrid(img: Rgba): Float32Array {
51
+ const g = FLASH_GRID;
52
+ const hit = new Float32Array(g * g);
53
+ const cnt = new Float32Array(g * g);
54
+ for (let y = 0; y < img.height; y++) {
55
+ const r = Math.min(g - 1, Math.floor((y * g) / img.height));
56
+ for (let x = 0; x < img.width; x++) {
57
+ const k = r * g + Math.min(g - 1, Math.floor((x * g) / img.width));
58
+ const i = (y * img.width + x) * 4;
59
+ const R = img.data[i] / 255;
60
+ const G = img.data[i + 1] / 255;
61
+ const B = img.data[i + 2] / 255;
62
+ cnt[k]++;
63
+ if (R + G + B > 0 && R / (R + G + B) >= 0.8 && (R - G - B) * 320 > 20) hit[k]++;
64
+ }
65
+ }
66
+ for (let k = 0; k < hit.length; k++) hit[k] /= cnt[k] || 1;
67
+ return hit;
68
+ }
69
+
70
+ export function colorThumb(t: number, img: Rgba): Thumb {
71
+ return { ...thumb(t, luma(img)), red: redGrid(img) };
72
+ }
73
+
74
+ export function meanAbsDiff(a: Uint8Array, b: Uint8Array): number {
75
+ if (a.length !== b.length) return 255;
76
+ let s = 0;
77
+ for (let i = 0; i < a.length; i++) s += Math.abs(a[i] - b[i]);
78
+ return s / a.length;
79
+ }
80
+
81
+ function channel(v: number): number {
82
+ const c = v / 255;
83
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
84
+ }
85
+
86
+ export function relLum(r: number, g: number, b: number): number {
87
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
88
+ }
89
+
90
+ export function contrastRatio(a: number, b: number): number {
91
+ const [hi, lo] = a > b ? [a, b] : [b, a];
92
+ return (hi + 0.05) / (lo + 0.05);
93
+ }
94
+
95
+ // 글 상자 바로 바깥 테두리 띠의 가운데값을 바탕색으로 본다. 상자 안은 글자가 섞여 못 쓴다.
96
+ export function backgroundAround(img: Rgba, box: Box, ring = 4): [number, number, number] | null {
97
+ const x0 = Math.max(0, Math.floor(box.x0) - ring);
98
+ const y0 = Math.max(0, Math.floor(box.y0) - ring);
99
+ const x1 = Math.min(img.width - 1, Math.ceil(box.x1) + ring);
100
+ const y1 = Math.min(img.height - 1, Math.ceil(box.y1) + ring);
101
+ if (x1 <= x0 || y1 <= y0) return null;
102
+ const rs: number[] = [];
103
+ const gs: number[] = [];
104
+ const bs: number[] = [];
105
+ const push = (x: number, y: number) => {
106
+ const i = (y * img.width + x) * 4;
107
+ rs.push(img.data[i]);
108
+ gs.push(img.data[i + 1]);
109
+ bs.push(img.data[i + 2]);
110
+ };
111
+ const step = Math.max(1, Math.floor((x1 - x0 + y1 - y0) / 200));
112
+ for (let x = x0; x <= x1; x += step) {
113
+ push(x, y0);
114
+ push(x, y1);
115
+ }
116
+ for (let y = y0; y <= y1; y += step) {
117
+ push(x0, y);
118
+ push(x1, y);
119
+ }
120
+ const med = (a: number[]) => a.sort((p, q) => p - q)[a.length >> 1];
121
+ return [med(rs), med(gs), med(bs)];
122
+ }
123
+
124
+ // 칠한 색(fillStyle)이 아니라 실제로 찍힌 픽셀로 잰다 — 위에 다른 것이 덮였거나 반투명이면 그게 보이는 대비다.
125
+ // 상자 안에서 바탕과 가장 멀리 떨어진 픽셀들(상위 5%)을 글자 색으로 본다. 그것도 바탕과 같으면 글이 안 보이는 것(덮임)이라 null.
126
+ export function inkContrast(img: Rgba, box: Box): number | null {
127
+ const bg = backgroundAround(img, box);
128
+ if (!bg) return null;
129
+ const bgLum = relLum(bg[0], bg[1], bg[2]);
130
+ const x0 = Math.max(0, Math.floor(box.x0));
131
+ const y0 = Math.max(0, Math.floor(box.y0));
132
+ const x1 = Math.min(img.width, Math.ceil(box.x1));
133
+ const y1 = Math.min(img.height, Math.ceil(box.y1));
134
+ const lums: number[] = [];
135
+ for (let y = y0; y < y1; y++) {
136
+ for (let x = x0; x < x1; x++) {
137
+ const i = (y * img.width + x) * 4;
138
+ lums.push(relLum(img.data[i], img.data[i + 1], img.data[i + 2]));
139
+ }
140
+ }
141
+ if (lums.length < 4) return null;
142
+ lums.sort((a, b) => Math.abs(b - bgLum) - Math.abs(a - bgLum));
143
+ const ink = lums[Math.floor(lums.length * 0.05)];
144
+ const ratio = contrastRatio(ink, bgLum);
145
+ return ratio < 1.15 ? null : ratio;
146
+ }
@@ -0,0 +1,9 @@
1
+ export { check } from "./check.ts";
2
+ export type { CheckOptions } from "./check.ts";
3
+ export { run, usage } from "./cli.ts";
4
+ export { formatReport } from "./report.ts";
5
+ export { scanPage } from "./scan.ts";
6
+ export { assembleLines, badText, glyphProblems, offscreenText } from "./rules/text.ts";
7
+ export { freezes, blanks, cuts } from "./rules/frames.ts";
8
+ export { boundaries, motionRules } from "./rules/beats.ts";
9
+ export type { Finding, Report, Severity, TextHit, FrameText, MotionEvent, MotionRules, Beats } from "./types.ts";
@@ -0,0 +1,37 @@
1
+ // mp4 는 글자 정보가 없으니 macOS Vision 으로 읽는다. 폴더의 png 를 이름 순으로 읽어 한 줄에 한 장씩 JSON 을 낸다.
2
+ // usesLanguageCorrection 을 끄는 이유: 켜면 「undefined」「NaN」 같은 낱말을 사전 낱말로 고쳐 버린다.
3
+ import AppKit
4
+ import Foundation
5
+ import Vision
6
+
7
+ let dir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "."
8
+ let files = ((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [])
9
+ .filter { $0.hasSuffix(".png") }
10
+ .sorted()
11
+
12
+ for name in files {
13
+ let path = (dir as NSString).appendingPathComponent(name)
14
+ var items: [[String: Any]] = []
15
+ if let img = NSImage(contentsOfFile: path),
16
+ let cg = img.cgImage(forProposedRect: nil, context: nil, hints: nil)
17
+ {
18
+ let req = VNRecognizeTextRequest()
19
+ req.recognitionLevel = .accurate
20
+ req.recognitionLanguages = ["ko-KR", "en-US"]
21
+ req.usesLanguageCorrection = false
22
+ try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([req])
23
+ for obs in req.results ?? [] {
24
+ guard let top = obs.topCandidates(1).first else { continue }
25
+ let b = obs.boundingBox
26
+ items.append([
27
+ "s": top.string, "c": top.confidence,
28
+ "x0": b.minX, "y0": 1 - b.maxY, "x1": b.maxX, "y1": 1 - b.minY,
29
+ ])
30
+ }
31
+ }
32
+ let obj: [String: Any] = ["file": name, "items": items]
33
+ if let data = try? JSONSerialization.data(withJSONObject: obj), let s = String(data: data, encoding: .utf8) {
34
+ print(s)
35
+ fflush(stdout)
36
+ }
37
+ }