kasaframe 0.1.1 → 0.1.3

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 (200) hide show
  1. package/NOTICE +10 -8
  2. package/README.md +16 -1
  3. package/app/server/engine.ts +133 -15
  4. package/app/server/main.ts +7 -3
  5. package/app/web/main.ts +218 -36
  6. package/app/web/style.css +37 -4
  7. package/bin/hooks.mjs +14 -1
  8. package/bin/kf.mjs +20 -2
  9. package/docs/AGENTS.md +64 -16
  10. package/music/README.md +31 -7
  11. package/package.json +2 -2
  12. package/packages/adapters/src/project.ts +1 -1
  13. package/packages/check/src/check.ts +10 -3
  14. package/packages/check/src/rules/assets.ts +4 -1
  15. package/packages/check/src/rules/frames.ts +2 -1
  16. package/packages/check/src/rules/layout.ts +30 -6
  17. package/packages/cli/package.json +2 -1
  18. package/packages/cli/src/blur.ts +15 -3
  19. package/packages/cli/src/commands/bench.ts +123 -0
  20. package/packages/cli/src/commands/blender.ts +72 -49
  21. package/packages/cli/src/commands/direct.ts +210 -0
  22. package/packages/cli/src/commands/doctor.ts +9 -1
  23. package/packages/cli/src/commands/look.ts +9 -2
  24. package/packages/cli/src/commands/lyrics.ts +47 -1
  25. package/packages/cli/src/commands/make.ts +729 -150
  26. package/packages/cli/src/commands/music.ts +78 -0
  27. package/packages/cli/src/commands/narrate.ts +238 -0
  28. package/packages/cli/src/commands/new.ts +14 -5
  29. package/packages/cli/src/commands/render.ts +94 -60
  30. package/packages/cli/src/commands/series.ts +110 -0
  31. package/packages/cli/src/commands/setup.ts +42 -3
  32. package/packages/cli/src/commands/theme.ts +89 -2
  33. package/packages/cli/src/deps.ts +6 -6
  34. package/packages/cli/src/kf.ts +14 -3
  35. package/packages/cli/src/mix.ts +131 -0
  36. package/packages/cli/src/pool.ts +34 -0
  37. package/packages/cli/src/segments.ts +402 -0
  38. package/packages/core/src/index.ts +1 -1
  39. package/packages/core/src/project.ts +11 -0
  40. package/packages/director/package.json +23 -0
  41. package/packages/director/src/assemble.ts +361 -0
  42. package/packages/director/src/brief.ts +122 -0
  43. package/packages/director/src/catalog.ts +49 -0
  44. package/packages/director/src/content.ts +343 -0
  45. package/packages/director/src/fallback.ts +58 -0
  46. package/packages/director/src/index.ts +13 -0
  47. package/packages/director/src/pattern.ts +90 -0
  48. package/packages/director/src/patterns/fill/cells.ts +115 -0
  49. package/packages/director/src/patterns/formula/stack.ts +93 -0
  50. package/packages/director/src/patterns/util.ts +58 -0
  51. package/packages/director/src/scene.ts +62 -0
  52. package/packages/director/src/select.ts +163 -0
  53. package/packages/director/src/transitions.ts +115 -0
  54. package/packages/gen/PROVIDERS.md +39 -4
  55. package/packages/gen/src/ask.ts +132 -0
  56. package/packages/gen/src/audio.ts +91 -0
  57. package/packages/gen/src/blender.ts +54 -0
  58. package/packages/gen/src/capabilities.ts +151 -0
  59. package/packages/gen/src/generate.ts +4 -2
  60. package/packages/gen/src/image/codex.ts +117 -0
  61. package/packages/gen/src/index.ts +10 -2
  62. package/packages/gen/src/keys.ts +26 -0
  63. package/packages/gen/src/music/elevenlabs.ts +41 -0
  64. package/packages/gen/src/music/index.ts +120 -0
  65. package/packages/gen/src/music/library.ts +73 -0
  66. package/packages/gen/src/music/lyria.ts +52 -0
  67. package/packages/gen/src/music/types.ts +28 -0
  68. package/packages/gen/src/registry.ts +6 -3
  69. package/packages/gen/src/tts/dashscope.ts +64 -0
  70. package/packages/gen/src/tts/elevenlabs.ts +84 -0
  71. package/packages/gen/src/tts/gemini.ts +73 -0
  72. package/packages/gen/src/tts/index.ts +241 -0
  73. package/packages/gen/src/tts/local.ts +42 -0
  74. package/packages/gen/src/tts/types.ts +44 -0
  75. package/packages/gen/src/types.ts +4 -0
  76. package/packages/gen/src/video/h3.ts +5 -0
  77. package/packages/gen/src/video/http.ts +1 -1
  78. package/packages/jizura/src/index.ts +2 -2
  79. package/packages/jizura/src/install.ts +198 -155
  80. package/packages/jizura/src/layer.ts +75 -32
  81. package/packages/jizura/src/pin.ts +4 -1
  82. package/packages/jizura/src/vendor.ts +140 -0
  83. package/packages/jizura/src/vite.ts +34 -7
  84. package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
  85. package/packages/jizura/vendor/NOTICE +43 -0
  86. package/packages/jizura/vendor/engine.json +47 -0
  87. package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
  88. package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
  89. package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
  90. package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
  91. package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  92. package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
  93. package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
  94. package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
  95. package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
  96. package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
  97. package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
  98. package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
  99. package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
  100. package/packages/jizura/vendor/fonts/fonts.json +52 -0
  101. package/packages/jizura/vendor/jizura_engine.js +35024 -0
  102. package/packages/motion/README.md +11 -0
  103. package/packages/motion/package.json +1 -0
  104. package/packages/motion/src/captions.ts +32 -24
  105. package/packages/motion/src/moves/brushWipe.ts +14 -1
  106. package/packages/motion/src/moves/dress.ts +191 -0
  107. package/packages/motion/src/moves/echoStack.ts +3 -3
  108. package/packages/motion/src/moves/fit.ts +36 -0
  109. package/packages/motion/src/moves/ghostStack.ts +14 -5
  110. package/packages/motion/src/moves/imageSwarm.ts +8 -4
  111. package/packages/motion/src/moves/index.ts +23 -4
  112. package/packages/motion/src/moves/karaokeLine.ts +17 -3
  113. package/packages/motion/src/moves/menu.ts +138 -0
  114. package/packages/motion/src/moves/neonSign.ts +2 -1
  115. package/packages/motion/src/moves/slamWords.ts +14 -7
  116. package/packages/motion/src/moves/sparkTrail.ts +16 -0
  117. package/packages/motion/src/moves/splitFlap.ts +4 -2
  118. package/packages/motion/src/moves/stepWords.ts +19 -8
  119. package/packages/motion/src/moves/tickerRows.ts +13 -9
  120. package/packages/motion/src/moves/tokenPicker.ts +4 -3
  121. package/packages/motion/src/moves/wordWall.ts +23 -21
  122. package/packages/motion/src/moves/worldRoll.ts +2 -1
  123. package/packages/motion/src/transition3d/index.ts +10 -3
  124. package/packages/motion/src/transition3d/rig.ts +107 -9
  125. package/packages/motion/src/transition3d/shatter.ts +5 -2
  126. package/packages/motion/src/transition3d/space.ts +173 -0
  127. package/packages/motion/src/transition3d/whip.ts +19 -6
  128. package/packages/motion/src/transition3d/world.ts +54 -0
  129. package/packages/motion/src/transitions2d/catalog.ts +1 -1
  130. package/packages/patterns/package.json +15 -0
  131. package/packages/patterns/src/bars/rank.ts +119 -0
  132. package/packages/patterns/src/before-after/shrink.ts +127 -0
  133. package/packages/patterns/src/before-after/strike.ts +125 -0
  134. package/packages/patterns/src/code/terminal.ts +133 -0
  135. package/packages/patterns/src/compare/bars.ts +142 -0
  136. package/packages/patterns/src/compare/duel.ts +103 -0
  137. package/packages/patterns/src/end/lockup.ts +85 -0
  138. package/packages/patterns/src/end/slam.ts +91 -0
  139. package/packages/patterns/src/flow/graph.ts +250 -0
  140. package/packages/patterns/src/hook-number/counter.ts +101 -0
  141. package/packages/patterns/src/hook-number/roll.ts +179 -0
  142. package/packages/patterns/src/hook-number/slam.ts +91 -0
  143. package/packages/patterns/src/index.ts +38 -0
  144. package/packages/patterns/src/kit.ts +449 -0
  145. package/packages/patterns/src/limit/gauge.ts +117 -0
  146. package/packages/patterns/src/list/cards.ts +131 -0
  147. package/packages/patterns/src/list/cycle.ts +22 -0
  148. package/packages/patterns/src/media/panels.ts +100 -0
  149. package/packages/patterns/src/objects.ts +234 -0
  150. package/packages/patterns/src/quote/karaoke.ts +126 -0
  151. package/packages/patterns/src/quote/slam.ts +22 -0
  152. package/packages/patterns/src/screen/focus.ts +85 -0
  153. package/packages/patterns/src/steps/path.ts +137 -0
  154. package/packages/patterns/src/steps/stack.ts +111 -0
  155. package/packages/patterns/src/term-reveal/chips.ts +92 -0
  156. package/packages/patterns/src/term-reveal/expand.ts +127 -0
  157. package/packages/patterns/src/timeline/axis.ts +144 -0
  158. package/packages/patterns/src/title/mask.ts +85 -0
  159. package/packages/patterns/src/title/slam.ts +23 -0
  160. package/packages/patterns/src/toggle-sim/switch.ts +92 -0
  161. package/packages/patterns/src/trend/columns.ts +108 -0
  162. package/packages/patterns/src/trend/draw.ts +140 -0
  163. package/packages/patterns/src/types.ts +67 -0
  164. package/packages/patterns/src/words/moves.ts +42 -0
  165. package/packages/patterns/src/words/slam.ts +4 -0
  166. package/packages/plan/src/asks.ts +256 -0
  167. package/packages/plan/src/brief.ts +136 -7
  168. package/packages/plan/src/captions.ts +5 -2
  169. package/packages/plan/src/index.ts +6 -2
  170. package/packages/plan/src/llm.ts +64 -2
  171. package/packages/plan/src/look.ts +3 -1
  172. package/packages/plan/src/plan.ts +67 -13
  173. package/packages/plan/src/series.ts +76 -0
  174. package/packages/plan/src/types.ts +53 -2
  175. package/packages/themes/package.json +5 -2
  176. package/packages/themes/src/index.ts +9 -0
  177. package/packages/themes/src/slotkit.ts +79 -0
  178. package/packages/themes/src/slots.ts +46 -0
  179. package/packages/themes/src/stage3d.ts +20 -0
  180. package/series/og-shorts.json +14 -0
  181. package/skills/kasaframe/SKILL.md +4 -3
  182. package/templates/_shared/project/lib/stage.ts +70 -50
  183. package/templates/_shared/scenes/default.ts +10 -7
  184. package/templates/_shared/scenes/drop.ts +11 -8
  185. package/templates/_shared/scenes/end.ts +9 -6
  186. package/templates/_shared/scenes/hook.ts +8 -6
  187. package/templates/_shared/scenes/step.ts +13 -4
  188. package/templates/ad/template.json +20 -1
  189. package/templates/demo/template.json +11 -0
  190. package/templates/explainer/template.json +54 -3
  191. package/templates/launch/template.json +34 -1
  192. package/templates/mv/scenes/default.ts +11 -8
  193. package/templates/mv/template.json +4 -2
  194. package/templates/promo/template.json +42 -1
  195. package/templates/shorts/scenes/hook.ts +8 -5
  196. package/templates/shorts/template.json +72 -3
  197. package/templates/square/template.json +21 -1
  198. package/themes/debug-hud/slots.ts +99 -0
  199. package/themes/og-measure/slots.ts +88 -0
  200. package/themes/riso-zine/slots.ts +81 -0
@@ -1,27 +1,20 @@
1
- // JIZURA 엔진을 고정 커밋으로 받아 UI 를 뺀 한 파일로 묶고, 가사 언어에 쓸 글꼴을 받아 둔다(노드 쪽).
2
- // node packages/jizura/src/install.ts [--lang ko,ja] kf setup jizura 가 부른다
3
- // 결과는 패키지 밖 ~/.cache/kasaframe/jizura 에 둔다 — 설치본은 패키지 폴더(node_modules 안)에 못 쓸 수 있다.
4
- // 렌더는 오프라인이어도 돌아야 해서 글꼴을 CDN 이 아니라 여기서 낸다(vite.ts 가 /@kf-jizura/ 로 낸다).
5
- import { execFileSync } from "node:child_process";
1
+ // JIZURA 가사 층의 엔진·글꼴 자리(노드 쪽). 엔진은 패키지에 실려 있다(vendor/ — 다시 만들기는 vendor.ts).
2
+ // 한국어 가사에 자주 쓰는 얼굴은 vendor/fonts 에 실려 있고(한글·영숫자·문장부호 서브셋), 나머지 얼굴과 서브셋 밖 글자(한자·가나)는
3
+ // 처음 쓸 때 google/fonts 고정 커밋에서 원본을 받아 ~/.cache/kasaframe/jizura/fonts 에 둔다 — 설치본(node_modules 안)에는 못 쓸 수 있다.
4
+ // 못 받으면 다른 글꼴로 대신 그리지 않고 fontsMissing() 에 남긴다. 어떻게 할지는 사람이 고른다(kf 가 끝에 묻는다).
5
+ // 렌더는 오프라인이어도 돌아야 해서 CDN 이 아니라 여기서 낸다(vite.ts 가 /@kf-jizura/ 로 낸다).
6
+ // node packages/jizura/src/install.ts [--lang ko,ja] kf setup jizura — 그 언어의 나머지 얼굴을 미리 받는다
6
7
  import { createHash } from "node:crypto";
7
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
8
9
  import { homedir } from "node:os";
9
10
  import { join } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
11
- import { JIZURA_COMMIT, JIZURA_CREDIT, JIZURA_REPO, JIZURA_VERSION } from "./pin.ts";
12
+ import { GOOGLE_FONTS_COMMIT, JIZURA_COMMIT } from "./pin.ts";
12
13
 
14
+ export const vendorDir = fileURLToPath(new URL("../vendor", import.meta.url));
15
+ export const builtinFontDir = join(vendorDir, "fonts");
13
16
  export const jizuraHome = (): string => process.env.KF_JIZURA_HOME ?? join(homedir(), ".cache", "kasaframe", "jizura");
14
- export const engineDir = (): string => join(jizuraHome(), "engine");
15
17
  export const fontDir = (): string => join(jizuraHome(), "fonts");
16
- const repoDir = (): string => join(jizuraHome(), "repo");
17
-
18
- export interface FontFile {
19
- family: string;
20
- /** 가변 글꼴은 "100 900" */
21
- weight: string;
22
- file: string;
23
- lang: string;
24
- }
25
18
 
26
19
  export interface EngineInfo {
27
20
  commit: string;
@@ -31,181 +24,231 @@ export interface EngineInfo {
31
24
  files: string[];
32
25
  }
33
26
 
34
- // JIZURA 의 J.LANG_FACES·J.FONTS 가 그 언어에서 그리는 얼굴(google/fonts ofl 폴더). 한국어 매핑에 없는 키(dot·mono)는 일본어 얼굴을 그대로 쓴다
35
- interface Family { family: string; dir: string; weights?: number[] }
36
- const COMMON: Family[] = [
37
- { family: "IBM Plex Mono", dir: "ibmplexmono", weights: [500, 600] },
38
- { family: "DotGothic16", dir: "dotgothic16" },
27
+ export interface Upstream {
28
+ /** google/fonts ofl/<dir>/ 의 파일 이름 */
29
+ file: string;
30
+ /** 가변 글꼴은 "100 900" */
31
+ weight: string;
32
+ bytes: number;
33
+ sha256: string;
34
+ }
35
+
36
+ export interface Family {
37
+ family: string;
38
+ dir: string;
39
+ lang: "common" | "ko" | "ja";
40
+ files: Upstream[];
41
+ /** 사람이 「내장 글꼴로」를 고르면 이 얼굴 대신 그릴 내장 얼굴 */
42
+ stand?: string;
43
+ }
44
+
45
+ const up = (file: string, weight: string, bytes: number, sha256: string): Upstream => ({ file, weight, bytes, sha256 });
46
+
47
+ // JIZURA 의 J.FONTS·J.LANG_FACES 가 그 언어에서 그리는 얼굴. 한국어 매핑에 없는 키(dot·mono)는 일본어 얼굴을 그대로 쓰고 한글은 그 뒤 Noto Sans KR 로 떨어진다
48
+ export const FAMILIES: Family[] = [
49
+ { family: "IBM Plex Mono", dir: "ibmplexmono", lang: "common", files: [up("IBMPlexMono-Medium.ttf", "500", 136704, "a9b4c49bb299e05b5f6c481e7fb5e78943d2793249a0c8874ab574a2d1ea6755"), up("IBMPlexMono-SemiBold.ttf", "600", 140216, "d3c38e55c78f5b0f28009fddba4834ec503278936a5986032424c9bd2d23aa46")] },
50
+ { family: "DotGothic16", dir: "dotgothic16", lang: "common", files: [up("DotGothic16-Regular.ttf", "400", 2069236, "3ad9af88726d42b40f7f365f0dcac785af73cf20ea6f1d5b44e57cc21150b8f1")] },
51
+ { family: "Noto Sans KR", dir: "notosanskr", lang: "ko", files: [up("NotoSansKR[wght].ttf", "100 900", 10414588, "194018e6b2b293a7964f037b25c0249ce1418bc9ab3c971060a03aa57861e252")] },
52
+ { family: "Noto Serif KR", dir: "notoserifkr", lang: "ko", files: [up("NotoSerifKR[wght].ttf", "200 900", 23795420, "11f8d5de6f1b79195efba3828aaa2ec95c1178f5ae976fb23c8d53250a9938f3")] },
53
+ { family: "Black Han Sans", dir: "blackhansans", lang: "ko", files: [up("BlackHanSans-Regular.ttf", "400", 998424, "31960809284026681774a8e52dc19ebcad26cf69b0ad9d560f288296fbb52739")] },
54
+ { family: "Gowun Batang", dir: "gowunbatang", lang: "ko", files: [up("GowunBatang-Bold.ttf", "700", 8178712, "dbfcaa646e5831e7478524924f02906f550285a5050699b4e38c9950b3ec4b94")] },
55
+ { family: "Jua", dir: "jua", lang: "ko", stand: "Black Han Sans", files: [up("Jua-Regular.ttf", "400", 2119352, "769677aef240bfc3b9965f2b50748075bff885e6c6992fc591a3fb268279f898")] },
56
+ { family: "Do Hyeon", dir: "dohyeon", lang: "ko", stand: "Black Han Sans", files: [up("DoHyeon-Regular.ttf", "400", 879764, "35644be7f28e0a68a447b1f7af351dcde5674b870f24f7b5f43e26d00b4ab653")] },
57
+ { family: "Gowun Dodum", dir: "gowundodum", lang: "ko", stand: "Noto Sans KR", files: [up("GowunDodum-Regular.ttf", "400", 7229088, "a6e457933227483a11758fd0947bc74422a106d46f0bf057fdaa5af94a30067d")] },
58
+ { family: "Nanum Brush Script", dir: "nanumbrushscript", lang: "ko", stand: "Gowun Batang", files: [up("NanumBrushScript-Regular.ttf", "400", 3564804, "27ceaf578c96f594cdf07fe0181b251790acbb746a164e45c1f6473f89911a31")] },
59
+ { family: "IBM Plex Sans KR", dir: "ibmplexsanskr", lang: "ko", stand: "Noto Sans KR", files: [up("IBMPlexSansKR-Medium.ttf", "500", 2866736, "4a7130f56ce50bf10f9d383f624e50bad2c80bebc63e51f4269ece2c7d919166")] },
60
+ { family: "Noto Sans JP", dir: "notosansjp", lang: "ja", files: [up("NotoSansJP[wght].ttf", "100 900", 9589900, "c2f3b4d463500a2ddcd3849cded1fceeb9fd6d1c32e6cbecd568453ba50fc68f")] },
61
+ { family: "Noto Serif JP", dir: "notoserifjp", lang: "ja", files: [up("NotoSerifJP[wght].ttf", "200 900", 13574352, "2fd527ba12b6a44ec30d796d633360da0aeba6c5d4af1304ce12bb4dc15a7dfc")] },
62
+ { family: "Dela Gothic One", dir: "delagothicone", lang: "ja", files: [up("DelaGothicOne-Regular.ttf", "400", 2508848, "4ff87a0965f1b0505e5a2c58424bc6ad3cff27e56a82f21c2fc9d6b0e3857ee2")] },
63
+ { family: "Zen Kaku Gothic New", dir: "zenkakugothicnew", lang: "ja", files: [up("ZenKakuGothicNew-Black.ttf", "900", 2300600, "795819a979184981842994d8f4eb9e14ce443d687bd5e731d6ca67ded8f92261")] },
64
+ { family: "Zen Old Mincho", dir: "zenoldmincho", lang: "ja", files: [up("ZenOldMincho-Black.ttf", "900", 5416348, "84a80d8bca79d7d9478935045b216ed003ad40fdea5fd9116d524eb26e872cdc")] },
65
+ { family: "Kaisei Tokumin", dir: "kaiseitokumin", lang: "ja", files: [up("KaiseiTokumin-ExtraBold.ttf", "800", 4564000, "bf44bb3e23cc703bfb19111833f78e651998d0a2b1863eff9e88cecb38a8bc53")] },
66
+ { family: "M PLUS Rounded 1c", dir: "mplusrounded1c", lang: "ja", files: [up("MPLUSRounded1c-ExtraBold.ttf", "800", 3628512, "8e7c15901dca87f1451b356dda594f7d092ba252a5dcc47da74523a242493c36")] },
67
+ { family: "Mochiy Pop One", dir: "mochiypopone", lang: "ja", files: [up("MochiyPopOne-Regular.ttf", "400", 5163948, "9e009430e1316c271a5f34759c6b65fc343c4e806f193042528887e7235a92c6")] },
68
+ { family: "Yuji Syuku", dir: "yujisyuku", lang: "ja", files: [up("YujiSyuku-Regular.ttf", "400", 8430348, "82728ebafc8c97391e2dab633414a806f344b8e4e2227d307179f07b548fca61")] },
69
+ { family: "Reggae One", dir: "reggaeone", lang: "ja", files: [up("ReggaeOne-Regular.ttf", "400", 2153256, "aebe62598732d76036f30ec11bb0ec5f68938e373a06d1b4ceb6b9cf1abf3db2")] },
70
+ { family: "Rampart One", dir: "rampartone", lang: "ja", files: [up("RampartOne-Regular.ttf", "400", 3722352, "a4c60ee835175fa147eb483fac0dd8ff9007c37884ef77af86b0a868fa554d49")] },
71
+ { family: "Potta One", dir: "pottaone", lang: "ja", files: [up("PottaOne-Regular.ttf", "400", 4918516, "bde843868b56e429e1c863da2efa76df533162b226d990efcb96d533b0ca2a93")] },
72
+ { family: "Kiwi Maru", dir: "kiwimaru", lang: "ja", files: [up("KiwiMaru-Medium.ttf", "500", 5123612, "b2659f300a7d48c3f29eb273ffc5e1b26cc416ac8c37ff6bb2f3e43c2f4d235a")] },
73
+ { family: "Klee One", dir: "kleeone", lang: "ja", files: [up("KleeOne-SemiBold.ttf", "600", 8905128, "b031ec426c23ca1143ef1f7d58bee7a79efe119ed654152f121c922202b303fd")] },
74
+ { family: "Shippori Mincho B1", dir: "shipporiminchob1", lang: "ja", files: [up("ShipporiMinchoB1-ExtraBold.ttf", "800", 15088060, "bee99a242f32128e8d6a4acff2b3f1742cd42ea90748758e6e10456871887e76")] },
75
+ { family: "IBM Plex Sans JP", dir: "ibmplexsansjp", lang: "ja", files: [up("IBMPlexSansJP-Medium.ttf", "500", 2389060, "1d09f5e25b19e54b2c55da70de58f9962512c3e07ed7c3e1c9bad77ace5c22e6")] },
39
76
  ];
40
- const FAMILIES: Record<string, Family[]> = {
41
- ko: [
42
- { family: "Noto Sans KR", dir: "notosanskr" },
43
- { family: "Noto Serif KR", dir: "notoserifkr" },
44
- { family: "Black Han Sans", dir: "blackhansans" },
45
- { family: "Jua", dir: "jua" },
46
- { family: "Do Hyeon", dir: "dohyeon" },
47
- { family: "Gowun Dodum", dir: "gowundodum" },
48
- { family: "Gowun Batang", dir: "gowunbatang", weights: [700] },
49
- { family: "Nanum Brush Script", dir: "nanumbrushscript" },
50
- { family: "IBM Plex Sans KR", dir: "ibmplexsanskr", weights: [500] },
51
- ],
52
- ja: [
53
- { family: "Noto Sans JP", dir: "notosansjp" },
54
- { family: "Noto Serif JP", dir: "notoserifjp" },
55
- { family: "Dela Gothic One", dir: "delagothicone" },
56
- { family: "Zen Kaku Gothic New", dir: "zenkakugothicnew", weights: [900] },
57
- { family: "Zen Old Mincho", dir: "zenoldmincho", weights: [900] },
58
- { family: "Kaisei Tokumin", dir: "kaiseitokumin", weights: [800] },
59
- { family: "M PLUS Rounded 1c", dir: "mplusrounded1c", weights: [800] },
60
- { family: "Mochiy Pop One", dir: "mochiypopone" },
61
- { family: "Yuji Syuku", dir: "yujisyuku" },
62
- { family: "Reggae One", dir: "reggaeone" },
63
- { family: "Rampart One", dir: "rampartone" },
64
- { family: "Potta One", dir: "pottaone" },
65
- { family: "Kiwi Maru", dir: "kiwimaru", weights: [500] },
66
- { family: "Klee One", dir: "kleeone", weights: [600] },
67
- { family: "Shippori Mincho B1", dir: "shipporiminchob1", weights: [800] },
68
- { family: "IBM Plex Sans JP", dir: "ibmplexsansjp", weights: [500] },
69
- ],
70
- };
71
- export const FONT_LANGS = Object.keys(FAMILIES);
77
+ export const FONT_LANGS = ["ko", "ja"];
72
78
 
73
- const WEIGHT: Record<string, number> = { Thin: 100, ExtraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, ExtraBold: 800, Black: 900 };
79
+ export const localName = (file: string): string => file.replace(/\[.*\]/, "-VF");
74
80
 
75
- const git = (args: string[], cwd?: string) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
81
+ export interface BuiltinFont {
82
+ family: string;
83
+ weight: string;
84
+ file: string;
85
+ /** 서브셋을 뜬 원본(google/fonts 파일 이름) */
86
+ from: string;
87
+ /** 서브셋이면 남긴 글자 범위 — 원본을 그대로 실은 것은 없다 */
88
+ unicodes?: string;
89
+ }
76
90
 
77
- /** 고정 커밋만 얕게 받는다. KF_JIZURA_SRC 에 클론을 주면 그걸 읽기만 한다 */
78
- export function fetchSource(log = console.log): string {
79
- const given = process.env.KF_JIZURA_SRC;
80
- if (given) {
81
- const head = git(["rev-parse", "HEAD"], given);
82
- if (head !== JIZURA_COMMIT) log(` 주의: KF_JIZURA_SRC 가 ${head.slice(0, 7)} — 고정 커밋 ${JIZURA_COMMIT.slice(0, 7)} 이 아니다(거부 목록·색 구성 번호가 어긋날 수 있다)`);
83
- return given;
84
- }
85
- const dir = repoDir();
86
- if (existsSync(join(dir, ".git")) && git(["rev-parse", "HEAD"], dir) === JIZURA_COMMIT) return dir;
87
- mkdirSync(dir, { recursive: true });
88
- if (!existsSync(join(dir, ".git"))) {
89
- git(["init", "-q"], dir);
90
- git(["remote", "add", "origin", JIZURA_REPO], dir);
91
- }
92
- log(` ${JIZURA_REPO} @ ${JIZURA_COMMIT.slice(0, 7)} 받는 중`);
93
- git(["fetch", "-q", "--depth", "1", "origin", JIZURA_COMMIT], dir);
94
- git(["checkout", "-q", "--detach", "FETCH_HEAD"], dir);
95
- return dir;
91
+ let builtinCache: BuiltinFont[] | null = null;
92
+ export function builtinFonts(): BuiltinFont[] {
93
+ if (builtinCache) return builtinCache;
94
+ const p = join(builtinFontDir, "fonts.json");
95
+ builtinCache = existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")).fonts as BuiltinFont[]) : [];
96
+ return builtinCache;
96
97
  }
97
98
 
98
- /** src/*.js 를 업스트림 build.py 와 같은 순서로 잇되 편집기 UI(12_ui.js)는 뺀다. 업스트림 파일은 고치지 않는다 */
99
- export function buildEngine(src: string): EngineInfo {
100
- const version = readFileSync(join(src, "VERSION"), "utf8").trim();
101
- const files = readdirSync(join(src, "src")).filter((f) => f.endsWith(".js") && f !== "12_ui.js").sort();
102
- const body = files.map((f) => readFileSync(join(src, "src", f), "utf8")).join("\n").split("@VERSION@").join(version);
103
- const js = `/*! JIZURA 字面 v${version} engine — ${JIZURA_CREDIT}. 라이선스는 옆 JIZURA_LICENSE.txt\n * ${files.join(" ")} */\n${body}`;
104
- const out = engineDir();
105
- mkdirSync(out, { recursive: true });
106
- writeFileSync(join(out, "jizura_engine.js"), js);
107
- copyFileSync(join(src, "LICENSE"), join(out, "JIZURA_LICENSE.txt"));
108
- const info: EngineInfo = {
109
- commit: git(["rev-parse", "HEAD"], src),
110
- version,
111
- sha256: createHash("sha256").update(js).digest("hex"),
112
- bytes: Buffer.byteLength(js),
113
- files,
114
- };
115
- writeFileSync(join(out, "engine.json"), JSON.stringify(info, null, 1));
116
- return info;
99
+ /** text 에서 그 범위(U+AC00-D7A3,…) 밖 글자 — 서브셋에 없으니 원본이 있어야 그 얼굴로 그린다 */
100
+ export function outside(text: string, unicodes: string): string[] {
101
+ const ranges = unicodes.split(",").map((r) => r.trim().replace(/^U\+/i, "").split("-").map((h) => parseInt(h, 16)));
102
+ const out = new Set<string>();
103
+ for (const ch of text) {
104
+ const c = ch.codePointAt(0)!;
105
+ if (c < 0x20) continue;
106
+ if (!ranges.some(([a, b = a]) => c >= a && c <= b)) out.add(ch);
107
+ }
108
+ return [...out];
117
109
  }
118
110
 
119
- async function getJson(url: string): Promise<unknown> {
120
- const headers: Record<string, string> = { "user-agent": "kasaframe-jizura-fonts" };
121
- if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
122
- const r = await fetch(url, { headers });
123
- if (!r.ok) throw new Error(`${url} → ${r.status}${r.status === 403 ? " (GitHub API 한도 — GITHUB_TOKEN 을 주면 늘어난다)" : ""}`);
124
- return r.json();
111
+ export function engineInfo(): EngineInfo | null {
112
+ const p = join(vendorDir, "engine.json");
113
+ return existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")) as EngineInfo) : null;
125
114
  }
126
115
 
127
- async function download(url: string, to: string): Promise<void> {
128
- for (let i = 0; ; i++) {
116
+ // 막힌 망에서는 google/fonts 거울을 줄 수 있다 — ofl/<dir>/<파일> 꼴만 맞으면 된다. 받은 파일은 크기·sha256 으로 고정본인지 본다
117
+ const fontBase = (): string => process.env.KF_JIZURA_FONTS_URL ?? `https://raw.githubusercontent.com/google/fonts/${GOOGLE_FONTS_COMMIT}`;
118
+ const fontUrl = (dir: string, file: string): string => `${fontBase()}/ofl/${dir}/${encodeURIComponent(file)}`;
119
+
120
+ const why = (err: unknown): string => (err instanceof Error ? (err.cause instanceof Error ? `${err.message}(${err.cause.message})` : err.message) : String(err));
121
+
122
+ async function download(url: string, to: string, want?: Upstream): Promise<void> {
123
+ let last = "";
124
+ for (let i = 0; i < 3; i++) {
129
125
  try {
130
- const r = await fetch(url);
131
- if (!r.ok) throw new Error(`${r.status}`);
132
- writeFileSync(`${to}.part`, Buffer.from(await r.arrayBuffer()));
126
+ const r = await fetch(url, { signal: AbortSignal.timeout(120_000) });
127
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
128
+ const buf = Buffer.from(await r.arrayBuffer());
129
+ if (want && (buf.length !== want.bytes || createHash("sha256").update(buf).digest("hex") !== want.sha256)) {
130
+ throw new Error(`고정본(${GOOGLE_FONTS_COMMIT.slice(0, 7)})과 다른 파일이 왔다`);
131
+ }
132
+ writeFileSync(`${to}.part`, buf);
133
133
  renameSync(`${to}.part`, to);
134
134
  return;
135
135
  } catch (err) {
136
- if (i >= 3) throw new Error(`${url} 를 못 받았다: ${err instanceof Error ? err.message : err}`);
136
+ last = why(err);
137
137
  }
138
138
  }
139
+ throw new Error(`${url} — ${last}`);
139
140
  }
140
141
 
141
- /** google/fonts 의 원본(서브셋 아닌 전체) 글꼴. 받은 파일은 크기가 맞으면 건너뛴다 */
142
- export async function fetchFonts(langs: string[], log = console.log): Promise<FontFile[]> {
143
- const dir = fontDir();
144
- mkdirSync(dir, { recursive: true });
145
- const manifestPath = join(dir, "fonts.json");
146
- const have: FontFile[] = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : [];
147
- const want = [...COMMON.map((f) => ({ ...f, lang: "common" })), ...langs.flatMap((l) => (FAMILIES[l] ?? []).map((f) => ({ ...f, lang: l })))];
148
- const out = have.filter((f) => existsSync(join(dir, f.file)) && !want.some((w) => w.family === f.family));
149
- for (const fam of want) {
150
- const list = (await getJson(`https://api.github.com/repos/google/fonts/contents/ofl/${fam.dir}`)) as { name: string; size: number; download_url: string }[];
151
- const ttf = list.filter((x) => x.name.endsWith(".ttf") && !/Italic/.test(x.name));
152
- const variable = ttf.find((x) => x.name.includes("["));
153
- const picks = variable
154
- ? [{ src: variable, weight: "100 900" }]
155
- : ttf
156
- .map((x) => ({ src: x, w: WEIGHT[/-(\w+)\.ttf$/.exec(x.name)?.[1] ?? "Regular"] ?? 400 }))
157
- .filter((x) => !fam.weights || fam.weights.includes(x.w))
158
- .map((x) => ({ src: x.src, weight: String(x.w) }));
159
- if (!picks.length) throw new Error(`${fam.family}: google/fonts ofl/${fam.dir} 에 맞는 ttf 가 없다`);
160
- for (const p of picks) {
161
- const file = p.src.name.replace(/\[.*\]/, "-VF");
162
- const to = join(dir, file);
163
- if (!existsSync(to) || statSync(to).size !== p.src.size) {
164
- log(` ${fam.family} ${p.weight} — ${(p.src.size / 1048576).toFixed(1)}MB`);
165
- await download(p.src.download_url, to);
142
+ export const cached = (f: Upstream): boolean => {
143
+ const p = join(fontDir(), localName(f.file));
144
+ return existsSync(p) && statSync(p).size === f.bytes;
145
+ };
146
+
147
+ const jobs = new Map<string, Promise<void>>();
148
+
149
+ /** 그 얼굴의 원본을 받아 둔다(같은 얼굴은 한 번만, OFL 원문도 옆에) */
150
+ export function fetchFamily(fam: Family, log: (s: string) => void = console.log): Promise<void> {
151
+ let job = jobs.get(fam.family);
152
+ if (!job) {
153
+ job = (async () => {
154
+ mkdirSync(fontDir(), { recursive: true });
155
+ const ofl = join(fontDir(), `OFL-${fam.dir}.txt`);
156
+ if (!existsSync(ofl)) await download(fontUrl(fam.dir, "OFL.txt"), ofl);
157
+ for (const f of fam.files) {
158
+ if (cached(f)) continue;
159
+ log(` ${fam.family} ${f.weight} — ${(f.bytes / 1048576).toFixed(1)}MB 받는 중`);
160
+ await download(fontUrl(fam.dir, f.file), join(fontDir(), localName(f.file)), f);
166
161
  }
167
- out.push({ family: fam.family, weight: p.weight, file, lang: fam.lang });
168
- }
169
- const ofl = list.find((x) => x.name === "OFL.txt");
170
- const oflTo = join(dir, `OFL-${fam.dir}.txt`);
171
- if (ofl && !existsSync(oflTo)) await download(ofl.download_url, oflTo);
162
+ })();
163
+ jobs.set(fam.family, job);
164
+ job.catch(() => jobs.delete(fam.family));
172
165
  }
173
- writeFileSync(manifestPath, JSON.stringify(out, null, 1));
174
- return out;
166
+ return job;
175
167
  }
176
168
 
177
- export function engineInfo(): EngineInfo | null {
178
- const p = join(engineDir(), "engine.json");
179
- return existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")) as EngineInfo) : null;
169
+ export interface FaceSrc {
170
+ family: string;
171
+ weight: string;
172
+ /** /@kf-jizura/ 아래 경로 */
173
+ url: string;
180
174
  }
181
175
 
182
- export function installedFonts(): FontFile[] {
183
- const p = join(fontDir(), "fonts.json");
184
- return existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")) as FontFile[]) : [];
176
+ export interface Missing {
177
+ family: string;
178
+ reason: string;
179
+ /** 「내장 글꼴로」를 고르면 대신 그릴 내장 얼굴 — 없으면 그 길로도 못 그린다(서브셋 밖 글자) */
180
+ stand?: string;
181
+ /** 그 얼굴을 쓰려던 프로젝트 폴더 — 답을 넣을 곳 */
182
+ project?: string;
183
+ }
184
+
185
+ const missing = new Map<string, Missing>();
186
+ /** 이 프로세스에서 못 받아 그리지 못한 얼굴 — kf 가 끝에 이걸 보고 묻는다 */
187
+ export const fontsMissing = (): Missing[] => [...missing.values()];
188
+
189
+ /** 페이지가 그릴 얼굴의 파일을 고른다 — 받아 둔 원본 → 내장 글꼴(서브셋이면 글자가 다 들 때만) → 처음 쓰는 얼굴이면 받기.
190
+ * 못 받으면 사람이 「내장 글꼴로」를 골랐을 때(builtin)만 stand 로 그리고, 아니면 missing 으로 돌려준다 */
191
+ export async function provideFonts(families: string[], text: string, o: { builtin?: boolean; project?: string; log?: (s: string) => void } = {}): Promise<{ faces: FaceSrc[]; missing: Missing[] }> {
192
+ const packed = builtinFonts();
193
+ const fits = (b: BuiltinFont) => !b.unicodes || !outside(text, b.unicodes).length;
194
+ const inBox = (name: string) => {
195
+ const list = packed.filter((b) => b.family === name);
196
+ return list.length && list.every(fits) ? list : null;
197
+ };
198
+ const faces: FaceSrc[] = [];
199
+ const miss: Missing[] = [];
200
+ await Promise.all(
201
+ [...new Set(families)].map(async (name) => {
202
+ const fam = FAMILIES.find((f) => f.family === name);
203
+ if (!fam) return;
204
+ const own = () => faces.push(...fam.files.map((f) => ({ family: name, weight: f.weight, url: `fonts/${localName(f.file)}` })));
205
+ if (fam.files.every(cached)) return void own();
206
+ const box = inBox(name);
207
+ if (box) return void faces.push(...box.map((b) => ({ family: name, weight: b.weight, url: `fonts/${b.file}` })));
208
+ try {
209
+ await fetchFamily(fam, o.log);
210
+ own();
211
+ } catch (err) {
212
+ const standName = fam.stand ?? name;
213
+ const stand = inBox(standName);
214
+ if (o.builtin && stand) {
215
+ // 그 얼굴 이름으로 내장 파일을 건다 — 플랜·컷은 그대로 두고 글자 모양만 바뀐다
216
+ for (const f of fam.files) faces.push({ family: name, weight: f.weight, url: `fonts/${stand[0].file}` });
217
+ return;
218
+ }
219
+ const m: Missing = { family: name, reason: why(err), ...(stand ? { stand: standName } : {}), ...(o.project ? { project: o.project } : {}) };
220
+ missing.set(name, m);
221
+ miss.push(m);
222
+ }
223
+ }),
224
+ );
225
+ return { faces, missing: miss };
185
226
  }
186
227
 
187
228
  /** kf doctor 한 줄 — cli/deps.ts 의 Check 꼴 */
188
229
  export function jizuraCheck() {
189
230
  const info = engineInfo();
190
- const langs = [...new Set(installedFonts().map((f) => f.lang))].filter((l) => l !== "common");
191
- const ok = !!info && info.commit === JIZURA_COMMIT && langs.length > 0;
231
+ const ok = !!info && info.commit === JIZURA_COMMIT && builtinFonts().length > 0;
232
+ const packed = [...new Set(builtinFonts().map((f) => f.family))];
233
+ const rest = FAMILIES.filter((f) => !packed.includes(f.family) && f.lang !== "ja");
234
+ const have = rest.filter((f) => f.files.every(cached));
192
235
  return {
193
236
  name: "JIZURA 가사 층",
194
237
  ok,
195
238
  required: false,
196
239
  detail: info
197
- ? `v${info.version} @${info.commit.slice(0, 7)}${info.commit === JIZURA_COMMIT ? "" : ` (고정 ${JIZURA_COMMIT.slice(0, 7)} 이 아니다)`} · 글꼴 ${langs.join("·") || "없음"} — ${jizuraHome()}`
198
- : "없음 — 뮤비 가사 키네틱(jizuraLayer)만 못 쓴다. 없으면 틀 장면은 DOM 낱말로 대신한다",
199
- fix: ok ? undefined : "kf setup jizura (일본어 가사도 쓰면 node packages/jizura/src/install.ts --lang ko,ja)",
240
+ ? `v${info.version} @${info.commit.slice(0, 7)}${info.commit === JIZURA_COMMIT ? "" : ` (고정 ${JIZURA_COMMIT.slice(0, 7)} 이 아니다)`} 내장 · 글꼴 내장 ${packed.length}·받아 둔 한국어 ${have.length}/${rest.length} — 나머지는 처음 쓸 때 받는다`
241
+ : "엔진이 패키지에 없다 — 깨진 설치",
242
+ fix: ok ? undefined : "레포면 node packages/jizura/src/vendor.ts, 설치본이면 kasaframe 를 다시 깐다",
200
243
  };
201
244
  }
202
245
 
246
+ /** kf setup jizura — 그 언어에서 내장에 없는 얼굴을 미리 받는다(오프라인으로 굽기 전에) */
203
247
  export async function install(langs: string[], log = console.log): Promise<void> {
204
- const src = fetchSource(log);
205
- const info = buildEngine(src);
206
- log(` 엔진 v${info.version} @${info.commit.slice(0, 7)} — ${(info.bytes / 1048576).toFixed(1)}MB, 파일 ${info.files.length}개 → ${engineDir()}`);
207
- const fonts = await fetchFonts(langs, log);
208
- log(` 글꼴 ${fonts.length}개(${langs.join("·")}) → ${fontDir()}`);
248
+ const packed = new Set(builtinFonts().map((f) => f.family));
249
+ const want = FAMILIES.filter((f) => (f.lang === "common" || langs.includes(f.lang)) && !packed.has(f.family));
250
+ for (const fam of want) await fetchFamily(fam, log);
251
+ log(` 글꼴 ${want.length}개(${langs.join("·")}) → ${fontDir()} · 내장 ${packed.size}개는 패키지에(${[...packed].join("·")})`);
209
252
  }
210
253
 
211
254
  if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
@@ -219,7 +262,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
219
262
  install(langs).then(
220
263
  () => process.exit(0),
221
264
  (err) => {
222
- console.error(`jizura 설치 실패: ${err instanceof Error ? err.message : err}`);
265
+ console.error(`jizura 글꼴 받기 실패: ${why(err)}`);
223
266
  process.exit(1);
224
267
  },
225
268
  );
@@ -1,10 +1,11 @@
1
1
  // JIZURA 字面(MIT, (c) 2026 hakoniwa) 엔진을 결정적인 가사 층으로 — 장면 위 캔버스 한 장에 시각 t 의 가사를 그린다.
2
2
  // 플랜 하나 = JIZURA 프로젝트 하나(곡 전체 가사·박). 같은 설정을 쓰는 장면끼리는 한 플랜을 나눠 쓰고, 장면마다 다른 플랜을 고른다.
3
- // 엔진·글꼴은 kf setup jizura 가 ~/.cache/kasaframe/jizura 에 두고 서버가 /@kf-jizura/ 로 낸다(vite.ts).
3
+ // 엔진은 패키지에 실려 있고(vendor/) 서버가 /@kf-jizura/ 로 낸다(vite.ts). 글꼴도 서버가 고른다 — 내장 서브셋·받아 둔 원본·처음 쓰면 받기.
4
4
  // 빌려 온 것: 플랜 만들기·색 구성 고르기(schemes)는 vocaloid-style-mv-pipeline(MIT, NikusonP) 의 jizura_adapter.js 를 옮겼다.
5
5
  import { onSeek } from "@kasaframe/adapters";
6
6
  import type { KfMeta, SceneCtx } from "@kasaframe/core";
7
7
  import { partPool, type JizuraRegistry, type PoolOptions } from "./deny.ts";
8
+ import type { FaceSrc, Missing } from "./install.ts";
8
9
  import { parseLyrics, toLrc, type LyricsJson } from "./lyrics.ts";
9
10
 
10
11
  const URL_BASE = "/@kf-jizura/";
@@ -89,46 +90,78 @@ async function getJson<T>(url: string | URL): Promise<T | null> {
89
90
  }
90
91
  }
91
92
 
92
- interface FontFile { family: string; weight: string; file: string; lang: string }
93
+ /** jizura — 가사 층 · builtin — 가사 층인데 못 받은 얼굴은 내장 글꼴로 · words — 가사 층 없이 낱말(틀 장면이 DOM 으로). 사람이 고른다 */
94
+ export type LyricsMode = "jizura" | "builtin" | "words";
93
95
 
94
- // 받아 둔 글꼴이 있는 언어는 CDN 에 묻지 않는다 — 렌더가 오프라인이어도, 늦게 온 조각 글꼴로 첫 프레임이 대체 글꼴이 되는 일도 없다
95
- async function localFonts(J: J): Promise<void> {
96
- const list = (await getJson<FontFile[]>(`${URL_BASE}fonts/fonts.json`)) ?? [];
97
- for (const f of list) document.fonts.add(new FontFace(f.family, `url(${URL_BASE}fonts/${encodeURIComponent(f.file)})`, { weight: f.weight, display: "block" }));
98
- const langs = new Set(list.map((f) => f.lang));
96
+ let modeJob: Promise<LyricsMode> | null = null;
97
+ /** kf.json "lyrics"(kf lyrics use 가 적는다)를 페이지 주소 ?lyrics= 가 덮는다 */
98
+ export function lyricsMode(): Promise<LyricsMode> {
99
+ modeJob ??= (async () => {
100
+ const q = new URLSearchParams(location.search).get("lyrics");
101
+ const v = q ?? (await getJson<{ lyrics?: unknown }>(new URL("kf.json", document.baseURI)))?.lyrics;
102
+ return v === "builtin" || v === "words" ? v : "jizura";
103
+ })();
104
+ return modeJob;
105
+ }
106
+
107
+ const registered = new Set<string>();
108
+
109
+ // 그릴 얼굴의 파일을 서버가 골라 준다(vite.ts) — 받아 둔 원본 · 내장 서브셋 · 처음 쓰는 얼굴이면 받기.
110
+ // 못 받은 얼굴이 있으면 던진다: CSS 대체 목록의 다른 글꼴로 조용히 그리지 않는다
111
+ async function provide(families: string[], text: string): Promise<FaceSrc[]> {
112
+ const builtin = (await lyricsMode()) === "builtin";
113
+ const r = await fetch(`${URL_BASE}fonts`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ families, text, builtin }) });
114
+ if (!r.ok) throw new Error(`글꼴 서버 ${r.status}: ${await r.text()}`);
115
+ const { faces, missing } = (await r.json()) as { faces: FaceSrc[]; missing: Missing[] };
116
+ if (missing.length) throw new Error(`JIZURA 글꼴 ${missing.map((m) => m.family).join("·")} 을 못 받았다 — 대신 그리지 않는다(kf 가 끝에 고를 길을 찍는다)`);
117
+ return faces;
118
+ }
119
+
120
+ function addFace(family: string, f: FaceSrc): void {
121
+ const key = `${family}|${f.weight}|${f.url}`;
122
+ if (registered.has(key)) return;
123
+ registered.add(key);
124
+ document.fonts.add(new FontFace(family, `url(${URL_BASE}${f.url.split("/").map(encodeURIComponent).join("/")})`, { weight: f.weight, display: "block" }));
125
+ }
126
+
127
+ // 한국어·일본어는 CDN 에 묻지 않는다 — 렌더가 오프라인이어도, 늦게 온 조각 글꼴로 첫 프레임이 대체 글꼴이 되는 일도 없다
128
+ const LOCAL = new Set(["ko", "ja"]);
129
+
130
+ function localFonts(J: J): void {
99
131
  const upstream = J.ensureFonts;
100
132
  J.ensureFonts = async (text: string, keys: string[] | null) => {
101
- if (!langs.has(J.lang) || keys?.includes("@var")) return upstream(text, keys);
133
+ if (!LOCAL.has(J.lang)) return upstream(text, keys);
102
134
  const uniq = [...new Set([...text])].join("") || "가";
103
135
  const list = (keys ?? Object.keys(J.FONTS)).filter((k: string) => J.FONTS[k]);
104
136
  const faces: { family: string; weight: number }[] = list.map((k: string) => J.faceOf(k));
105
137
  for (const b of J.langBaseFaces(list)) faces.push({ family: `"${b.family}"`, weight: b.weight });
106
- const specs = [...new Set(faces.map((f) => `${f.weight} 64px ${f.family}`))];
107
- await Promise.all(specs.map((s) => document.fonts.load(s, uniq).catch(() => null)));
138
+ // 太さ(@var)는 이름이 Noto Sans·Serif JP 인 가변 글꼴로 그린다 — 한국어면 그 이름에 가변 Noto Sans·Serif KR 을 건다(안 걸면 한글이 시스템 글꼴로 떨어진다)
139
+ const vary = !!keys?.includes("@var");
140
+ const varFaces = [["Noto Sans JP", "Noto Sans KR", 100, 900], ["Noto Serif JP", "Noto Serif KR", 200, 900]] as const;
141
+ const want = new Set(faces.map((f) => f.family.replace(/"/g, "")));
142
+ if (vary) for (const [jp, kr] of varFaces) want.add(J.lang === "ko" ? kr : jp);
143
+ const got = await provide([...want], text);
144
+ for (const f of got) addFace(f.family, f);
145
+ if (vary && J.lang === "ko") for (const [jp, kr] of varFaces) for (const f of got.filter((x) => x.family === kr)) addFace(jp, f);
146
+ const specs = new Set(faces.map((f) => `${f.weight} 64px ${f.family}`));
147
+ if (vary) for (const [jp, , lo, hi] of varFaces) specs.add(`${lo} 64px "${jp}"`).add(`${hi} 64px "${jp}"`);
148
+ await Promise.all([...specs].map((s) => document.fonts.load(s, uniq)));
108
149
  J.glyphs.clear();
109
150
  J.metrics.clear();
110
151
  };
111
152
  }
112
153
 
113
- let engine: Promise<J | null> | null = null;
154
+ let engine: Promise<J> | null = null;
114
155
 
115
- /** 엔진을 한 번만 싣는다. 깔지 않았으면 null — 부르는 쪽은 대체 화면으로 */
116
- export function loadJizura(): Promise<J | null> {
156
+ /** 실린 엔진을 한 번만 싣는다. 못 실으면 던진다 — 깨진 설치를 낱말 화면으로 덮지 않는다 */
157
+ export function loadJizura(): Promise<J> {
117
158
  engine ??= (async () => {
118
159
  if (window.J?.plan) return window.J;
119
- if (!(await getJson(`${URL_BASE}engine/engine.json`))) {
120
- console.warn("[kf] JIZURA 엔진이 없다 — kf setup jizura (가사 층 없이 그린다)");
121
- return null;
122
- }
123
- try {
124
- await loadScript(`${URL_BASE}engine/jizura_engine.js`);
125
- const J = window.J as J;
126
- await localFonts(J);
127
- return J;
128
- } catch (err) {
129
- console.warn(`[kf] JIZURA 엔진을 못 실었다 — 가사 층 없이 그린다: ${err instanceof Error ? err.message : err}`);
130
- return null;
131
- }
160
+ await loadScript(`${URL_BASE}engine/jizura_engine.js`);
161
+ const J = window.J;
162
+ if (!J?.plan) throw new Error("JIZURA 엔진을 실었는데 window.J 가 없다");
163
+ localFonts(J);
164
+ return J;
132
165
  })();
133
166
  return engine;
134
167
  }
@@ -211,8 +244,9 @@ function planFor(o: LayerOptions): Promise<PlanEntry | null> {
211
244
  let job = plans.get(key);
212
245
  if (!job) {
213
246
  job = (async () => {
247
+ if ((await lyricsMode()) === "words") return null;
214
248
  const [J, lyrics] = await Promise.all([loadJizura(), o.lyrics ? Promise.resolve(o.lyrics) : projectLyrics()]);
215
- if (!J || !lyrics?.lines.length) return null;
249
+ if (!lyrics?.lines.length) return null;
216
250
  return buildPlan(J, settings, lyrics, o.ctx);
217
251
  })();
218
252
  plans.set(key, job);
@@ -220,10 +254,11 @@ function planFor(o: LayerOptions): Promise<PlanEntry | null> {
220
254
  return job;
221
255
  }
222
256
 
223
- /** 엔진·가사가 다 있어 이 층이 그릴 수 있는지 — 틀 장면이 대체 화면을 고를 때 */
257
+ /** 이 층이 그릴지 — 틀 장면이 낱말 화면을 고를 때. 가사가 없거나 사람이 「낱말로」를 골랐으면 false, 엔진을 못 실으면 던진다 */
224
258
  export async function jizuraReady(): Promise<boolean> {
225
- const [J, lyrics] = await Promise.all([loadJizura(), projectLyrics()]);
226
- return !!J && !!lyrics?.lines.length;
259
+ if ((await lyricsMode()) === "words") return false;
260
+ const [, lyrics] = await Promise.all([loadJizura(), projectLyrics()]);
261
+ return !!lyrics?.lines.length;
227
262
  }
228
263
 
229
264
  /** 컷 목록 — 어느 컷이 화면을 덮는지 찾을 때(layout·enter·hold·exit·decor·cam·trans) */
@@ -241,6 +276,8 @@ const drawOpts = (mode: DrawMode, scale: number) =>
241
276
 
242
277
  export type JizuraPaint = ((t: number) => void) & { canvas: HTMLCanvasElement };
243
278
 
279
+ const told = new Set<string>();
280
+
244
281
  /** 장면 build 에서 부른다 — 장면을 덮는 캔버스를 짓고 (t) => 칠하기를 돌려준다. t 는 장면 안 시각.
245
282
  * 한 장면에서 플랜을 바꿔 끼우려면(드롭부터 큰 플랜) 층을 둘 짓고 안 쓰는 쪽 .canvas 를 숨긴다 */
246
283
  export function jizuraLayer(el: HTMLElement, o: LayerOptions): JizuraPaint {
@@ -254,10 +291,15 @@ export function jizuraLayer(el: HTMLElement, o: LayerOptions): JizuraPaint {
254
291
  el.append(canvas);
255
292
  const g = canvas.getContext("2d")!;
256
293
  let entry: PlanEntry | null = null;
294
+ let failed: unknown = null;
257
295
  const ready = planFor(o).then(
258
296
  (e) => (entry = e),
259
297
  (err) => {
260
- console.warn(`[kf] 가사 층(${ctx.id}): ${err instanceof Error ? err.message : err}`);
298
+ failed = err;
299
+ const msg = err instanceof Error ? err.message : String(err);
300
+ // 플랜은 장면끼리 나눠 쓴다 — 같은 실패를 장면마다 찍지 않는다
301
+ if (!told.has(msg)) console.error(`[kf] 가사 층(${ctx.id}): ${msg}`);
302
+ told.add(msg);
261
303
  return null;
262
304
  },
263
305
  );
@@ -268,10 +310,11 @@ export function jizuraLayer(el: HTMLElement, o: LayerOptions): JizuraPaint {
268
310
  entry.R.frame(g, entry.plan, T, drawOpts(mode, canvas.width / entry.plan.W));
269
311
  drawn = T;
270
312
  };
271
- // 플랜이 아직이면 그 seek 을 플랜이 설 때까지 붙든다 — 첫 프레임부터 가사가 찍힌다
313
+ // 플랜이 아직이면 그 seek 을 플랜이 설 때까지 붙든다 — 첫 프레임부터 가사가 찍힌다. 플랜을 못 세웠으면 그 프레임을 가사 없이 찍지 않고 던진다
272
314
  onSeek(async () => {
273
315
  if (want === null || drawn === want) return;
274
316
  await ready;
317
+ if (failed) throw failed;
275
318
  paint(want);
276
319
  });
277
320
  const draw = (t: number) => {
@@ -1,5 +1,8 @@
1
- // 부품 이름·스타일의 색 구성 번호·거부 목록이 이 판에 맞춰져 있다. 판을 올리면 deny.ts 를 다시 보고 견본 스틸을 다시 찍는다 — 같은 seed 라도 컷이 다시 굴려진다
1
+ // 부품 이름·스타일의 색 구성 번호·거부 목록이 이 판에 맞춰져 있다. 판을 올리면 deny.ts 를 다시 보고 견본 스틸을 다시 찍는다 — 같은 seed 라도 컷이 다시 굴려진다.
2
+ // 올린 뒤에는 node packages/jizura/src/vendor.ts 로 vendor/ 를 다시 만든다
2
3
  export const JIZURA_REPO = "https://github.com/852wa/JIZURA.git";
3
4
  export const JIZURA_COMMIT = "fc16bfe43ea4a6c25a21f1caf04d17326de14f00";
4
5
  export const JIZURA_VERSION = "0.10.1";
5
6
  export const JIZURA_CREDIT = "JIZURA 字面 (c) 2026 hakoniwa (github.com/852wa/JIZURA), MIT";
7
+ // 글꼴은 google/fonts 의 이 커밋에서 받는다 — 같은 파일이라야 어느 기계에서든 같은 프레임이 나온다(install.ts 가 크기·sha256 을 맞춰 본다)
8
+ export const GOOGLE_FONTS_COMMIT = "51303ca9e8ac9dcea7b12d307ba568fd0e6fcfca";