summer-engine 4.3.0 → 4.6.0

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 (231) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +3 -1
  3. package/.codex-plugin/plugin.json +5 -3
  4. package/.cursor-plugin/plugin.json +4 -2
  5. package/.factory-plugin/plugin.json +4 -2
  6. package/AGENTS.md +2 -2
  7. package/CHANGELOG.md +100 -0
  8. package/README.md +67 -27
  9. package/commands/start.md +19 -15
  10. package/commands/summer.md +1 -0
  11. package/dist/cli/command-usage.d.ts +3 -1
  12. package/dist/cli/command-usage.js +10 -4
  13. package/dist/cli/commands/create.d.ts +1 -25
  14. package/dist/cli/commands/create.js +10 -138
  15. package/dist/cli/commands/dev.d.ts +66 -0
  16. package/dist/cli/commands/dev.js +234 -0
  17. package/dist/cli/commands/install.d.ts +15 -58
  18. package/dist/cli/commands/install.js +31 -481
  19. package/dist/cli/commands/login.js +1 -1
  20. package/dist/cli/commands/ops.d.ts +17 -1
  21. package/dist/cli/commands/ops.js +97 -9
  22. package/dist/cli/commands/preview.d.ts +58 -0
  23. package/dist/cli/commands/preview.js +181 -0
  24. package/dist/cli/commands/run.d.ts +1 -17
  25. package/dist/cli/commands/run.js +17 -206
  26. package/dist/cli/commands/setup.js +175 -32
  27. package/dist/cli/index.js +5 -0
  28. package/dist/core/agent-host.d.ts +3 -0
  29. package/dist/core/agent-host.js +1 -1
  30. package/dist/core/api-client.d.ts +10 -2
  31. package/dist/core/api-client.js +13 -8
  32. package/dist/core/capabilities/agent-playbook.js +1 -0
  33. package/dist/core/capabilities/apps.d.ts +9 -9
  34. package/dist/core/capabilities/asset-import.d.ts +4 -1
  35. package/dist/core/capabilities/asset-import.js +15 -6
  36. package/dist/core/capabilities/capture-gameplay.js +1 -1
  37. package/dist/core/capabilities/connect-signal.d.ts +2 -2
  38. package/dist/core/capabilities/content-make/index.d.ts +81 -0
  39. package/dist/core/capabilities/content-make/index.js +706 -0
  40. package/dist/core/capabilities/content-make/media.d.ts +57 -0
  41. package/dist/core/capabilities/content-make/media.js +172 -0
  42. package/dist/core/capabilities/content-make/plan.d.ts +130 -0
  43. package/dist/core/capabilities/content-make/plan.js +433 -0
  44. package/dist/core/capabilities/content-make/render.d.ts +64 -0
  45. package/dist/core/capabilities/content-make/render.js +370 -0
  46. package/dist/core/capabilities/content-make/study.d.ts +13 -0
  47. package/dist/core/capabilities/content-make/study.js +165 -0
  48. package/dist/core/capabilities/content-make/types.d.ts +173 -0
  49. package/dist/core/capabilities/content-make/types.js +6 -0
  50. package/dist/core/capabilities/content-make/voice.d.ts +7 -0
  51. package/dist/core/capabilities/content-make/voice.js +27 -0
  52. package/dist/core/capabilities/events.d.ts +2 -2
  53. package/dist/core/capabilities/export-game.d.ts +4 -1
  54. package/dist/core/capabilities/export-game.js +4 -3
  55. package/dist/core/capabilities/export-presets.d.ts +2 -1
  56. package/dist/core/capabilities/export-presets.js +2 -1
  57. package/dist/core/capabilities/go-live.d.ts +14 -0
  58. package/dist/core/capabilities/go-live.js +408 -0
  59. package/dist/core/capabilities/image-generation.d.ts +6 -6
  60. package/dist/core/capabilities/item-manifest.d.ts +92 -0
  61. package/dist/core/capabilities/item-manifest.js +247 -0
  62. package/dist/core/capabilities/placement.d.ts +7 -7
  63. package/dist/core/capabilities/projects-local.d.ts +68 -0
  64. package/dist/core/capabilities/projects-local.js +172 -0
  65. package/dist/core/capabilities/publish-build.d.ts +13 -2
  66. package/dist/core/capabilities/publish-build.js +87 -21
  67. package/dist/core/capabilities/publish-download.js +1 -1
  68. package/dist/core/capabilities/runtime-control.d.ts +7 -2
  69. package/dist/core/capabilities/runtime-control.js +27 -1
  70. package/dist/core/capabilities/seeing/args.d.ts +2 -2
  71. package/dist/core/capabilities/store-checks.d.ts +45 -0
  72. package/dist/core/capabilities/store-checks.js +85 -0
  73. package/dist/core/capabilities/store-details.d.ts +37 -0
  74. package/dist/core/capabilities/store-details.js +94 -0
  75. package/dist/core/capabilities/summer-bundle.d.ts +21 -2
  76. package/dist/core/capabilities/summer-bundle.js +54 -7
  77. package/dist/core/capabilities/sync-items.d.ts +89 -0
  78. package/dist/core/capabilities/sync-items.js +352 -0
  79. package/dist/core/capabilities/tool-dispatch.js +66 -6
  80. package/dist/core/capabilities/video-ogv.d.ts +49 -0
  81. package/dist/core/capabilities/video-ogv.js +260 -0
  82. package/dist/core/capability-skew.js +2 -2
  83. package/dist/core/cli-usage-beacon.js +26 -2
  84. package/dist/core/cli-usage.d.ts +5 -2
  85. package/dist/core/cli-usage.js +8 -2
  86. package/dist/core/engine-install.d.ts +53 -0
  87. package/dist/core/engine-install.js +93 -0
  88. package/dist/core/engine-installer.d.ts +134 -0
  89. package/dist/core/engine-installer.js +760 -0
  90. package/dist/core/engine-launch.d.ts +126 -0
  91. package/dist/core/engine-launch.js +398 -0
  92. package/dist/core/engine-self-start.d.ts +63 -0
  93. package/dist/core/engine-self-start.js +282 -0
  94. package/dist/core/engine.d.ts +7 -1
  95. package/dist/core/engine.js +9 -2
  96. package/dist/core/error-codes.d.ts +4 -1
  97. package/dist/core/error-codes.js +7 -3
  98. package/dist/core/library-read.d.ts +2 -2
  99. package/dist/core/library-search.d.ts +2 -2
  100. package/dist/core/local-project.d.ts +50 -0
  101. package/dist/core/local-project.js +234 -0
  102. package/dist/core/ops/areas.js +9 -3
  103. package/dist/core/ops/catalog.d.ts +9 -0
  104. package/dist/core/ops/catalog.js +56 -4
  105. package/dist/core/ops/engine-answer.d.ts +4 -1
  106. package/dist/core/ops/engine-answer.js +16 -3
  107. package/dist/core/ops/engine.js +4 -4
  108. package/dist/core/ops/save-files.d.ts +6 -1
  109. package/dist/core/ops/save-files.js +67 -50
  110. package/dist/core/ops/search.js +9 -2
  111. package/dist/core/preview/input.d.ts +35 -0
  112. package/dist/core/preview/input.js +105 -0
  113. package/dist/core/preview/launch-config.d.ts +52 -0
  114. package/dist/core/preview/launch-config.js +137 -0
  115. package/dist/core/preview/page.d.ts +22 -0
  116. package/dist/core/preview/page.js +232 -0
  117. package/dist/core/preview/preview-source.d.ts +179 -0
  118. package/dist/core/preview/preview-source.js +350 -0
  119. package/dist/core/preview/server.d.ts +26 -0
  120. package/dist/core/preview/server.js +218 -0
  121. package/dist/core/telemetry.d.ts +20 -16
  122. package/dist/core/telemetry.js +92 -16
  123. package/dist/core/templates.d.ts +20 -0
  124. package/dist/core/templates.js +300 -3
  125. package/dist/core/tool-profiles.d.ts +2 -2
  126. package/dist/core/tool-profiles.js +1 -0
  127. package/dist/core/usage-fields.d.ts +41 -0
  128. package/dist/core/usage-fields.js +150 -0
  129. package/dist/core/util/sleep.d.ts +3 -0
  130. package/dist/core/util/sleep.js +12 -0
  131. package/dist/installer/agent-config.d.ts +29 -0
  132. package/dist/installer/agent-config.js +75 -11
  133. package/dist/installer/agent-detect.d.ts +44 -0
  134. package/dist/installer/agent-detect.js +166 -0
  135. package/dist/installer/agent-table.d.ts +19 -0
  136. package/dist/installer/agent-table.js +130 -0
  137. package/dist/installer/setup-error.d.ts +19 -0
  138. package/dist/installer/setup-error.js +34 -0
  139. package/dist/installer/version-check.d.ts +5 -0
  140. package/dist/installer/version-check.js +21 -0
  141. package/dist/mcp/ops-surface.d.ts +7 -0
  142. package/dist/mcp/ops-surface.js +40 -3
  143. package/dist/mcp/server.d.ts +14 -4
  144. package/dist/mcp/server.js +42 -8
  145. package/dist/mcp/tool-surface.d.ts +8 -0
  146. package/dist/mcp/tool-surface.js +19 -2
  147. package/dist/mcp/tools/creator-tools.js +92 -14
  148. package/dist/mcp/tools/debug-tools.js +2 -0
  149. package/dist/mcp/tools/local-project-tools.d.ts +6 -0
  150. package/dist/mcp/tools/local-project-tools.js +33 -0
  151. package/dist/mcp/tools/navigation-tools.js +2 -1
  152. package/dist/mcp/tools/project-tools.js +5 -8
  153. package/dist/mcp/tools/seeing-tools.js +1 -1
  154. package/dist/mcp/tools/visual-tools.js +1 -1
  155. package/dist/mcp/tools/with-engine.js +1 -1
  156. package/dist/project-memory/agent-instructions.js +11 -3
  157. package/gemini-extension.json +3 -1
  158. package/library/references/mcp-tools-reference/mcp-tools-reference.md +18 -5
  159. package/library/skills/README.md +12 -8
  160. package/library/skills/animated-loop/SKILL.md +10 -16
  161. package/library/skills/cinematic-cutscene/SKILL.md +7 -18
  162. package/library/skills/headless-scripting/SKILL.md +7 -4
  163. package/library/skills/in-game-purchases/SKILL.md +25 -8
  164. package/library/skills/in-game-purchases/resource.yaml +2 -1
  165. package/library/skills/launch-ready/SKILL.md +351 -0
  166. package/library/skills/launch-ready/resource.yaml +45 -0
  167. package/library/skills/make-content/SKILL.md +202 -0
  168. package/library/skills/make-content/resource.yaml +32 -0
  169. package/library/skills/make-game/SKILL.md +6 -4
  170. package/library/skills/make-game/resource.yaml +2 -1
  171. package/library/skills/navigate-summer/SKILL.md +4 -2
  172. package/library/skills/new-project/SKILL.md +15 -22
  173. package/library/skills/new-project/resource.yaml +2 -1
  174. package/library/skills/painted-25d-world/SKILL.md +366 -0
  175. package/library/skills/painted-25d-world/_building-blocks/blender-route.md +33 -0
  176. package/library/skills/painted-25d-world/_building-blocks/blockout_builder.gd +149 -0
  177. package/library/skills/painted-25d-world/_building-blocks/capture_paint_view.gd +32 -0
  178. package/library/skills/painted-25d-world/_building-blocks/paint_projection.gdshader +23 -0
  179. package/library/skills/painted-25d-world/_building-blocks/paintcam_check.gd +25 -0
  180. package/library/skills/painted-25d-world/_building-blocks/painted_door.gd +17 -0
  181. package/library/skills/painted-25d-world/_building-blocks/painted_location.gd +56 -0
  182. package/library/skills/painted-25d-world/_building-blocks/painted_player.gd +125 -0
  183. package/library/skills/painted-25d-world/_building-blocks/painted_world.gd +111 -0
  184. package/library/skills/painted-25d-world/_building-blocks/tools/cam.py +57 -0
  185. package/library/skills/painted-25d-world/_building-blocks/tools/detail4k.py +81 -0
  186. package/library/skills/painted-25d-world/_building-blocks/tools/overlay.py +17 -0
  187. package/library/skills/painted-25d-world/_building-blocks/tools/preview.py +60 -0
  188. package/library/skills/painted-25d-world/resource.yaml +39 -0
  189. package/library/skills/publish-your-game/SKILL.md +13 -14
  190. package/library/skills/publish-your-game/resource.yaml +4 -3
  191. package/library/skills/release-checklist/SKILL.md +188 -0
  192. package/library/skills/release-checklist/resource.yaml +40 -0
  193. package/library/skills/release-readiness/SKILL.md +1 -1
  194. package/library/skills/release-readiness/resource.yaml +1 -1
  195. package/library/skills/store-art/SKILL.md +1 -1
  196. package/library/skills/store-art/resource.yaml +1 -1
  197. package/library/skills/store-listing/SKILL.md +1 -1
  198. package/library/skills/store-listing/resource.yaml +1 -1
  199. package/library/skills/summer-cli/SKILL.md +2 -0
  200. package/library/skills/summer-store/SKILL.md +295 -39
  201. package/library/skills/summer-store/resource.yaml +9 -3
  202. package/library/skills/trailer-shot/SKILL.md +3 -3
  203. package/library/templates/README.md +5 -0
  204. package/library/tools/content-make/resource.yaml +132 -0
  205. package/library/tools/frame-shot/GUIDE.md +1 -1
  206. package/library/tools/go-live/resource.yaml +67 -0
  207. package/library/tools/import-from-url/GUIDE.md +1 -0
  208. package/library/tools/import-from-url-batch/GUIDE.md +1 -1
  209. package/library/tools/play/GUIDE.md +2 -0
  210. package/library/tools/projects-create-local/resource.yaml +55 -0
  211. package/library/tools/projects-open-local/resource.yaml +51 -0
  212. package/library/tools/publish-build/resource.yaml +3 -3
  213. package/library/tools/screenshot/GUIDE.md +1 -1
  214. package/library/tools/store-set-details/resource.yaml +114 -0
  215. package/library/tools/sync-items/resource.yaml +72 -0
  216. package/package.json +1 -1
  217. package/registry/generated/aliases.json +6 -0
  218. package/registry/generated/counts.json +3 -3
  219. package/registry/generated/gemini-extension.json +3 -1
  220. package/registry/generated/index.json +463 -37
  221. package/registry/generated/marketplace.claude.json +1 -1
  222. package/registry/generated/plugin.claude-directory.json +4 -2
  223. package/registry/generated/plugin.claude.json +3 -1
  224. package/registry/generated/plugin.codex.json +5 -3
  225. package/registry/generated/plugin.cursor.json +4 -2
  226. package/registry/generated/plugin.factory.json +4 -2
  227. package/registry/generated/server.json +2 -2
  228. package/registry/generated/skills-index.md +12 -8
  229. package/registry/generated/skills-registry.json +62 -5
  230. package/registry/operations/legacy-names.json +40 -0
  231. package/registry/operations/public-catalog.json +10144 -387
@@ -0,0 +1,370 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ffmpeg, FPS, frames, H, loudness, magick, probe, run, signalStats, W } from "./media.js";
4
+ /**
5
+ * The render, ported from the beat-for-beat pipeline that made the pieces
6
+ * the team liked: every shot is cut to an exact frame count on the
7
+ * reference's cut list, captions are frame-exact transparent tracks drawn
8
+ * with ImageMagick, the voice sits over the game's own sound at -14 LUFS.
9
+ * Shots are cached by their spec, so a re-render only redoes what changed.
10
+ */
11
+ const FONTS = {
12
+ sans: ["/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"],
13
+ rounded: ["/System/Library/Fonts/Supplemental/Arial Rounded Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"],
14
+ serif: ["/System/Library/Fonts/Supplemental/Georgia Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"],
15
+ };
16
+ /** The font file for a caption: the game's own (kit or input) when given, else a system font of the same kind. */
17
+ export function fontFile(kind, own) {
18
+ if (own && existsSync(own) && kind !== "serif")
19
+ return own;
20
+ return FONTS[kind].find((file) => existsSync(file)) ?? "Arial-Bold";
21
+ }
22
+ /** One shot of exactly n frames, with its own game sound (or silence). */
23
+ async function renderShot(spec, n, outV, outA) {
24
+ const d = n / FPS;
25
+ const silence = ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-t", d.toFixed(6), outA];
26
+ if (spec.color || !spec.src) {
27
+ await ffmpeg(["-f", "lavfi", "-i", `color=c=${spec.color ?? "black"}:s=${W}x${H}:r=${FPS}`, "-frames:v", String(n), "-c:v", "libx264", "-crf", "16", "-pix_fmt", "yuv420p", outV]);
28
+ await ffmpeg(silence);
29
+ return;
30
+ }
31
+ const src = spec.src;
32
+ const info = await probe(src);
33
+ const still = /\.(png|jpe?g|webp)$/i.test(src);
34
+ const speed = spec.speed ?? 1;
35
+ const z0 = spec.z0 ?? 1;
36
+ const z1 = spec.z1 ?? z0;
37
+ const zmin = Math.min(z0, z1);
38
+ let ch = info.height / zmin;
39
+ let cw = (ch * W) / H;
40
+ if (cw > info.width) {
41
+ cw = info.width;
42
+ ch = (cw * H) / W;
43
+ }
44
+ cw = Math.floor(cw / 2) * 2;
45
+ ch = Math.floor(ch / 2) * 2;
46
+ const x0 = Math.round(Math.min(Math.max((spec.cx ?? 0.5) * info.width - cw / 2, 0), info.width - cw));
47
+ const y0 = Math.round(Math.min(Math.max((spec.cy ?? 0.5) * info.height - ch / 2, 0), info.height - ch));
48
+ const a = z0 / zmin;
49
+ const b = (z1 - z0) / zmin;
50
+ const zoom = `(${a.toFixed(5)}+${b.toFixed(5)}*t/${d.toFixed(5)})`;
51
+ const vf = [
52
+ still ? `fps=${FPS}` : `setpts=(PTS-STARTPTS)/${speed},fps=${FPS}`,
53
+ `crop=${cw}:${ch}:${x0}:${y0}`,
54
+ `scale=w='trunc(${W}*${zoom}/2)*2':h='trunc(${H}*${zoom}/2)*2':eval=frame:flags=lanczos`,
55
+ `crop=${W}:${H}:'(iw-${W})/2':'(ih-${H})/2'`,
56
+ "setsar=1",
57
+ // a short source holds its last frame instead of shortening the cut
58
+ "tpad=stop_mode=clone:stop_duration=30",
59
+ "format=yuv420p",
60
+ ].join(",");
61
+ const input = still ? ["-loop", "1", "-framerate", String(FPS), "-t", (d + 0.5).toFixed(3), "-i", src] : ["-ss", (spec.t ?? 0).toFixed(3), "-t", (d * speed + 0.5).toFixed(3), "-i", src];
62
+ await ffmpeg([...input, "-vf", vf, "-frames:v", String(n), "-an", "-c:v", "libx264", "-crf", "16", "-preset", "medium", outV]);
63
+ if (still || spec.mute || !info.hasAudio) {
64
+ await ffmpeg(silence);
65
+ return;
66
+ }
67
+ await ffmpeg([
68
+ "-ss", (spec.t ?? 0).toFixed(3), "-i", src, "-vn",
69
+ "-af", `aresample=48000,apad,atrim=0:${d.toFixed(6)},afade=t=in:d=0.01,afade=t=out:st=${Math.max(d - 0.01, 0).toFixed(4)}:d=0.01`,
70
+ "-ac", "2", "-ar", "48000", outA,
71
+ ]);
72
+ }
73
+ async function frameCount(file) {
74
+ const { stdout } = await run("ffprobe", ["-v", "error", "-count_packets", "-select_streams", "v:0", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", file]);
75
+ return Number(stdout.trim());
76
+ }
77
+ /** The cut: every shot on its frame-exact slot, concatenated, plus the game-sound bed on the same grid. */
78
+ export async function buildBase(shots, cuts, dir, onShot) {
79
+ const seg = join(dir, "seg");
80
+ mkdirSync(seg, { recursive: true });
81
+ const fc = cuts.map(frames);
82
+ const videos = [];
83
+ const audios = [];
84
+ for (let i = 0; i < shots.length; i++) {
85
+ const n = fc[i + 1] - fc[i];
86
+ if (n <= 0)
87
+ throw new Error(`Shot ${i} has no frames (cuts ${cuts[i]} to ${cuts[i + 1]}).`);
88
+ const v = join(seg, `v${String(i).padStart(3, "0")}.mp4`);
89
+ const a = join(seg, `a${String(i).padStart(3, "0")}.wav`);
90
+ const keyFile = `${v}.key`;
91
+ const key = JSON.stringify({ spec: shots[i], n, v: 2 });
92
+ if (!(existsSync(v) && existsSync(a) && existsSync(keyFile) && readFileSync(keyFile, "utf8") === key)) {
93
+ await renderShot(shots[i], n, v, a);
94
+ const got = await frameCount(v);
95
+ if (got !== n)
96
+ throw new Error(`Shot ${i} has ${got} frames; its slot needs ${n}.`);
97
+ writeFileSync(keyFile, key);
98
+ }
99
+ videos.push(v);
100
+ audios.push(a);
101
+ onShot?.(i + 1, shots.length);
102
+ }
103
+ const list = join(seg, "vlist.txt");
104
+ writeFileSync(list, videos.map((v) => `file '${v}'\n`).join(""));
105
+ const base = join(dir, "base.mp4");
106
+ const bed = join(dir, "bed.wav");
107
+ await ffmpeg(["-f", "concat", "-safe", "0", "-i", list, "-c", "copy", base]);
108
+ await ffmpeg([...audios.flatMap((a) => ["-i", a]), "-filter_complex", `${audios.map((_, i) => `[${i}:a]`).join("")}concat=n=${audios.length}:v=0:a=1[a]`, "-map", "[a]", bed]);
109
+ return { base, bed, frameCuts: fc };
110
+ }
111
+ /** A transparent track of pictures, each held for its frame count (frame-exact, unlike image durations in a concat list). */
112
+ export async function overlayTrack(states, out) {
113
+ const dir = `${out}_parts`;
114
+ rmSync(dir, { recursive: true, force: true });
115
+ mkdirSync(dir, { recursive: true });
116
+ const parts = [];
117
+ for (const [index, [picture, n]] of states.entries()) {
118
+ if (n <= 0)
119
+ continue;
120
+ const part = join(dir, `p${String(index).padStart(4, "0")}.mov`);
121
+ await ffmpeg(["-loop", "1", "-framerate", String(FPS), "-i", picture, "-frames:v", String(n), "-c:v", "qtrle", "-pix_fmt", "argb", part]);
122
+ parts.push(part);
123
+ }
124
+ writeFileSync(`${out}.txt`, parts.map((part) => `file '${part}'\n`).join(""));
125
+ await ffmpeg(["-f", "concat", "-safe", "0", "-i", `${out}.txt`, "-c", "copy", out]);
126
+ return out;
127
+ }
128
+ function styled(text, style) {
129
+ let out = text.replace(/\[[^\]]*\]/g, "").replace(/\s+/g, " ").trim().replace(/'/g, "’");
130
+ if (style.case === "lower")
131
+ out = out.toLowerCase();
132
+ if (style.case === "upper")
133
+ out = out.toUpperCase();
134
+ return style.quoted ? `“${out}”` : out;
135
+ }
136
+ async function textWidth(text, font, size) {
137
+ const { stdout } = await magick(["-font", font, "-pointsize", String(size), `label:${text}`, "-format", "%w", "info:"]);
138
+ return Number(stdout);
139
+ }
140
+ /** Breaks a caption into as few balanced lines as keep every line inside the safe width (at most four). */
141
+ async function wrap(text, font, size, maxWidth) {
142
+ const words = text.split(" ");
143
+ if (words.length < 2 || (await textWidth(text, font, size)) <= maxWidth)
144
+ return text;
145
+ for (let count = 2; count <= Math.min(4, words.length); count++) {
146
+ const target = text.length / count;
147
+ const lines = [];
148
+ let current = [];
149
+ for (const word of words) {
150
+ if (current.length && [...current, word].join(" ").length > target * 1.15 && lines.length < count - 1) {
151
+ lines.push(current.join(" "));
152
+ current = [];
153
+ }
154
+ current.push(word);
155
+ }
156
+ lines.push(current.join(" "));
157
+ const widths = await Promise.all(lines.map((line) => textWidth(line, font, size)));
158
+ if (widths.every((width) => width <= maxWidth))
159
+ return lines.join("\n");
160
+ }
161
+ return words.reduce((rows, word, index) => (index % 2 ? [...rows.slice(0, -1), `${rows[rows.length - 1]} ${word}`] : [...rows, word]), []).join("\n");
162
+ }
163
+ /**
164
+ * One caption on a transparent frame, at the reference's place and in its
165
+ * look: the text with a dark outline (no shadow, no glow). With a highlight,
166
+ * the longest word takes that colour. Returns its box on the frame.
167
+ */
168
+ export async function captionPng(text, style, font, out) {
169
+ const label = styled(text, style);
170
+ // a long held line sets smaller, as the references do, so it stays two or three lines
171
+ const size = style.mode === "words" ? style.size : Math.round(Math.max(56, Math.min(style.size, style.size * Math.sqrt(30 / Math.max(30, label.length)))));
172
+ const stroke = style.outline ? Math.max(4, Math.round(size / 8)) : 0;
173
+ // centred text stays inside the safe area (0.06-0.86 of the width) with room for the outline
174
+ const maxWidth = W * 0.68;
175
+ const tmp = `${out}.label.png`;
176
+ const words = label.split(" ");
177
+ const fits = (await textWidth(label, font, size)) <= maxWidth;
178
+ const hi = fits && style.highlight && style.mode === "words" && words.length > 1 ? words.reduce((best, word, index) => (word.replace(/\W/g, "").length > words[best].replace(/\W/g, "").length ? index : best), 0) : -1;
179
+ if (hi >= 0) {
180
+ // word by word so one word can take the highlight colour
181
+ const parts = [];
182
+ for (const [index, word] of words.entries()) {
183
+ const part = `${out}.w${index}.png`;
184
+ const fill = index === hi ? style.highlight : style.color;
185
+ await magick(["-background", "none", "-font", font, "-pointsize", String(size), "-fill", fill, ...(stroke ? ["-stroke", "black", "-strokewidth", String(stroke)] : []), `label:${word}`, "-stroke", "none", "-fill", fill, `label:${word}`, "-gravity", "center", "-compose", "over", "-composite", part]);
186
+ parts.push(part);
187
+ }
188
+ await magick(["-background", "none", ...parts.flatMap((part, index) => (index < parts.length - 1 ? [part, "-size", `${Math.round(size / 4)}x1`, "xc:none"] : [part])), "+append", tmp]);
189
+ for (const part of parts)
190
+ rmSync(part, { force: true });
191
+ }
192
+ else {
193
+ const wrapped = await wrap(label, font, size, maxWidth);
194
+ await magick([
195
+ "-background", "none", "-font", font, "-pointsize", String(size), "-interline-spacing", String(-Math.round(size / 6)), "-gravity", "center",
196
+ "-fill", style.color, ...(stroke ? ["-stroke", "black", "-strokewidth", String(stroke)] : []), `label:${wrapped}`,
197
+ "-stroke", "none", "-fill", style.color, `label:${wrapped}`, "-compose", "over", "-composite", tmp,
198
+ ]);
199
+ }
200
+ const { stdout } = await magick([tmp, "-format", "%w %h", "info:"]);
201
+ const [w, h] = stdout.trim().split(" ").map(Number);
202
+ const cy = Math.round(style.y * H);
203
+ await magick(["-size", `${W}x${H}`, "xc:none", tmp, "-gravity", "center", "-geometry", `+0${cy - H / 2 >= 0 ? "+" : ""}${cy - H / 2}`, "-composite", `PNG32:${out}`]);
204
+ rmSync(tmp, { force: true });
205
+ return { left: (W - w) / 2 / W, top: (cy - h / 2) / H, right: (W + w) / 2 / W, bottom: (cy + h / 2) / H };
206
+ }
207
+ /** A caption track: each item over its time, blank between. Returns the track and every caption's box. */
208
+ export async function captionTrack(items, end, style, font, dir) {
209
+ if (!items.length)
210
+ return null;
211
+ const capDir = join(dir, "cap");
212
+ rmSync(capDir, { recursive: true, force: true });
213
+ mkdirSync(capDir, { recursive: true });
214
+ const blank = join(capDir, "blank.png");
215
+ await magick(["-size", `${W}x${H}`, "xc:none", `PNG32:${blank}`]);
216
+ const states = [];
217
+ const boxes = [];
218
+ let t = 0;
219
+ for (const [index, item] of items.entries()) {
220
+ const from = Math.max(item.from, t);
221
+ const to = Math.min(item.to, end);
222
+ if (to <= from)
223
+ continue;
224
+ if (frames(from) > frames(t))
225
+ states.push([blank, frames(from) - frames(t)]);
226
+ const picture = join(capDir, `c${String(index).padStart(3, "0")}.png`);
227
+ boxes.push(await captionPng(item.text, style, font, picture));
228
+ states.push([picture, frames(to) - frames(from)]);
229
+ t = to;
230
+ }
231
+ if (frames(end) > frames(t))
232
+ states.push([blank, frames(end) - frames(t)]);
233
+ return { track: await overlayTrack(states, join(dir, "caps.mov")), boxes };
234
+ }
235
+ /** Word captions from the voice: chunks change with the words, each held until the next starts. */
236
+ export function wordCaptionItems(chunks, end) {
237
+ return chunks.map((chunk, index) => ({
238
+ text: chunk.map((word) => word.w).join(" ").replace(/[.,?!]+$/g, "").replace(/,/g, ""),
239
+ from: chunk[0].s,
240
+ to: index + 1 < chunks.length ? chunks[index + 1][0].s : end,
241
+ }));
242
+ }
243
+ /** A picture over a time span, as a track the length of the piece. */
244
+ export async function pictureTrack(file, from, to, end, out) {
245
+ const blank = `${out}.blank.png`;
246
+ await magick(["-size", `${W}x${H}`, "xc:none", `PNG32:${blank}`]);
247
+ const states = [];
248
+ if (frames(from) > 0)
249
+ states.push([blank, frames(from)]);
250
+ states.push([file, frames(Math.min(to, end)) - frames(from)]);
251
+ if (frames(end) > frames(to))
252
+ states.push([blank, frames(end) - frames(to)]);
253
+ return overlayTrack(states, out);
254
+ }
255
+ /**
256
+ * The finished piece: overlays on the cut, the voice (or a song) over the
257
+ * game-sound bed, -14 LUFS integrated and -1.5 dBTP.
258
+ */
259
+ export async function mix(opts) {
260
+ const inputs = ["-i", opts.base, ...opts.overlays.flatMap((overlay) => ["-i", overlay])];
261
+ const filters = [];
262
+ let last = "0:v";
263
+ opts.overlays.forEach((_, index) => {
264
+ filters.push(`[${last}][${index + 1}:v]overlay=0:0:eof_action=pass[o${index}]`);
265
+ last = `o${index}`;
266
+ });
267
+ let next = opts.overlays.length + 1;
268
+ const lead = opts.voice ?? opts.music;
269
+ inputs.push("-i", opts.bed);
270
+ const bedIndex = next++;
271
+ if (lead) {
272
+ inputs.push(...(!opts.voice && opts.musicStart ? ["-ss", opts.musicStart.toFixed(3)] : []), "-i", lead);
273
+ const leadIndex = next++;
274
+ filters.push(`[${leadIndex}:a]aresample=48000,apad[lead]`);
275
+ filters.push(`[${bedIndex}:a]volume=${opts.bedDb}dB[bed]`);
276
+ filters.push(`[lead][bed]amix=inputs=2:duration=longest:normalize=0,atrim=0:${opts.duration.toFixed(4)},loudnorm=I=-14:TP=-1.5:LRA=11[a]`);
277
+ }
278
+ else {
279
+ filters.push(`[${bedIndex}:a]atrim=0:${opts.duration.toFixed(4)},loudnorm=I=-14:TP=-1.5:LRA=11[a]`);
280
+ }
281
+ await ffmpeg([
282
+ ...inputs, "-filter_complex", filters.join(";"), "-map", `[${last}]`, "-map", "[a]", "-t", opts.duration.toFixed(4),
283
+ "-c:v", "libx264", "-crf", "17", "-preset", "slow", "-profile:v", "high", "-pix_fmt", "yuv420p",
284
+ "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-movflags", "+faststart", opts.out,
285
+ ]);
286
+ // one-pass loudnorm lands within a couple of LU on spiky game sound: measure, then trim the gain under a -1.5 dBTP limiter
287
+ const measured = await loudness(opts.out);
288
+ if (measured && Math.abs(measured.lufs + 14) > 0.5) {
289
+ const fixed = `${opts.out}.gain.mp4`;
290
+ await ffmpeg(["-i", opts.out, "-af", `volume=${(-14 - measured.lufs).toFixed(2)}dB,alimiter=limit=0.84:level=disabled`, "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-movflags", "+faststart", fixed]);
291
+ renameSync(fixed, opts.out);
292
+ }
293
+ }
294
+ /** Mean motion per shot (the first frame of each shot, the cut itself, left out) and over the first second. */
295
+ export async function motionReport(video, frameCuts) {
296
+ const { ydif } = await signalStats(video);
297
+ const mean = (values) => (values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0);
298
+ const shots = frameCuts.slice(0, -1).map((start, index) => {
299
+ const slice = ydif.slice(start + 1, frameCuts[index + 1]);
300
+ return Math.round(mean(slice.length ? slice : ydif.slice(start, frameCuts[index + 1])) * 100) / 100;
301
+ });
302
+ // the longest run of (nearly) identical frames in each shot
303
+ const freeze = frameCuts.slice(0, -1).map((start, index) => {
304
+ let run = 0;
305
+ let longest = 0;
306
+ for (const value of ydif.slice(start + 1, frameCuts[index + 1])) {
307
+ run = value < 0.25 ? run + 1 : 0;
308
+ longest = Math.max(longest, run);
309
+ }
310
+ return Math.round((longest / FPS) * 100) / 100;
311
+ });
312
+ return { shots, freeze, firstSecond: Math.round(mean(ydif.slice(1, FPS)) * 100) / 100 };
313
+ }
314
+ /** The cover: the frame with the most going on in the first two seconds' hook shot. */
315
+ export async function cover(video, at, out) {
316
+ await ffmpeg(["-ss", at.toFixed(3), "-i", video, "-frames:v", "1", "-q:v", "2", out]);
317
+ return out;
318
+ }
319
+ /**
320
+ * Reference and ours side by side, synced on every beat: each reference
321
+ * shot is stretched to the length of our matching shot. Our sound only;
322
+ * the reference stays silent.
323
+ */
324
+ export async function sideBySide(refFile, refBounds, ours, ourCuts, dir, out) {
325
+ const pairs = Math.min(refBounds.length, ourCuts.length) - 1;
326
+ const parts = [];
327
+ for (let i = 0; i < pairs; i++) {
328
+ const r0 = refBounds[i];
329
+ const r1 = refBounds[i + 1];
330
+ const n = frames(ourCuts[i + 1]) - frames(ourCuts[i]);
331
+ if (n <= 0)
332
+ continue;
333
+ const factor = n / FPS / Math.max(0.04, r1 - r0);
334
+ parts.push(`[s${parts.length}]trim=start=${r0.toFixed(3)}:end=${r1.toFixed(3)},setpts=(PTS-STARTPTS)*${factor.toFixed(5)},fps=${FPS},scale=540:960:force_original_aspect_ratio=decrease,pad=540:960:(ow-iw)/2:(oh-ih)/2,setsar=1,trim=end_frame=${n}[r${i}]`);
335
+ }
336
+ const labels = join(dir, "sbs_labels.png");
337
+ await magick([
338
+ "-size", "1080x960", "xc:none", "-font", fontFile("sans"), "-pointsize", "30", "-fill", "white", "-stroke", "black", "-strokewidth", "4",
339
+ "-gravity", "northwest", "-annotate", "+20+16", "reference", "-annotate", "+560+16", "ours",
340
+ "-stroke", "none", "-fill", "white", "-annotate", "+20+16", "reference", "-annotate", "+560+16", "ours", `PNG32:${labels}`,
341
+ ]);
342
+ const split = `[0:v]split=${parts.length}${parts.map((_, i) => `[s${i}]`).join("")}`;
343
+ const concat = `${parts.map((part) => `[${/\[(r\d+)\]$/.exec(part)[1]}]`).join("")}concat=n=${parts.length}:v=1:a=0[ref]`;
344
+ await ffmpeg([
345
+ "-i", refFile, "-i", ours, "-loop", "1", "-i", labels,
346
+ "-filter_complex", `${split};${parts.join(";")};${concat};[1:v]scale=540:960,setsar=1[our];[ref][our]hstack=inputs=2[pair];[pair][2:v]overlay=0:0:shortest=1[v]`,
347
+ "-map", "[v]", "-map", "1:a", "-shortest", "-c:v", "libx264", "-crf", "21", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", out,
348
+ ]);
349
+ return out;
350
+ }
351
+ /** A contact sheet: reference | ours at the middle of every shot, numbered. */
352
+ export async function contactSheet(refFile, refBounds, ours, ourCuts, dir, out) {
353
+ const tmp = join(dir, "sheet_tmp");
354
+ rmSync(tmp, { recursive: true, force: true });
355
+ mkdirSync(tmp, { recursive: true });
356
+ const tiles = [];
357
+ const pairs = Math.min(refBounds.length, ourCuts.length) - 1;
358
+ for (let i = 0; i < pairs; i++) {
359
+ const a = join(tmp, `a${i}.png`);
360
+ const b = join(tmp, `b${i}.png`);
361
+ const tile = join(tmp, `t${String(i).padStart(3, "0")}.png`);
362
+ await ffmpeg(["-ss", ((refBounds[i] + refBounds[i + 1]) / 2).toFixed(3), "-i", refFile, "-frames:v", "1", "-vf", "scale=180:320:force_original_aspect_ratio=decrease,pad=180:320:(ow-iw)/2:(oh-ih)/2", a]);
363
+ await ffmpeg(["-ss", ((ourCuts[i] + ourCuts[i + 1]) / 2).toFixed(3), "-i", ours, "-frames:v", "1", "-vf", "scale=180:320", b]);
364
+ await magick([a, b, "+append", "-gravity", "north", "-background", "black", "-fill", "white", "-font", fontFile("sans"), "-pointsize", "18", "-splice", "0x24", "-annotate", "+0+2", `${i}: ${ourCuts[i].toFixed(2)} s`, tile]);
365
+ tiles.push(tile);
366
+ }
367
+ await magick(["montage", ...tiles, "-tile", "6x", "-geometry", "+3+3", "-background", "#222222", "-depth", "8", "-quality", "85", out]);
368
+ rmSync(tmp, { recursive: true, force: true });
369
+ return out;
370
+ }
@@ -0,0 +1,13 @@
1
+ import type { FootageIndex, Reference, Study } from "./types.js";
2
+ /** Where content.make keeps its work on this computer unless told otherwise: ~/.summer/content. */
3
+ export declare function defaultContentRoot(): string;
4
+ /**
5
+ * The reference video on this computer: the file given, the cached copy,
6
+ * else a download of the original post (yt-dlp, when installed). It is
7
+ * studied only; nothing of it is rendered into the piece.
8
+ */
9
+ export declare function referenceVideo(ref: Reference, cacheDir: string, given?: string): Promise<string>;
10
+ /** Cuts, black beats, length, voice and caption look of the reference. */
11
+ export declare function studyReference(ref: Reference, file: string): Promise<Study>;
12
+ /** Every clip, still and song in the footage folders, measured. Frames pulled from a clip (name.mp4.still-3.png) are left out. */
13
+ export declare function indexFootage(paths: readonly string[], cacheDir: string, onClip?: (done: number, total: number) => void): Promise<FootageIndex>;
@@ -0,0 +1,165 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, extname, join } from "node:path";
5
+ import { BuildToolError } from "../summer-bundle.js";
6
+ import { probe, round2, run, sceneCuts, signalStats, which, windowMeans } from "./media.js";
7
+ import { parseCaptionStyle, refHasVoice, wordsOf } from "./plan.js";
8
+ /** Where content.make keeps its work on this computer unless told otherwise: ~/.summer/content. */
9
+ export function defaultContentRoot() {
10
+ return join(homedir(), ".summer", "content");
11
+ }
12
+ /**
13
+ * The reference video on this computer: the file given, the cached copy,
14
+ * else a download of the original post (yt-dlp, when installed). It is
15
+ * studied only; nothing of it is rendered into the piece.
16
+ */
17
+ export async function referenceVideo(ref, cacheDir, given) {
18
+ if (given) {
19
+ if (!existsSync(given))
20
+ throw new BuildToolError("content_reference_missing", `referenceFile ${given} does not exist.`, "Pass the path of the reference video.");
21
+ return given;
22
+ }
23
+ const dir = join(cacheDir, "references");
24
+ const cached = join(dir, `${ref.referenceId}.mp4`);
25
+ if (existsSync(cached))
26
+ return cached;
27
+ if (!ref.url)
28
+ throw new BuildToolError("content_reference_missing", `Reference ${ref.referenceId} has no link to study.`, "Pass referenceFile with the reference video.", undefined, { missing: "the reference video" });
29
+ if (!which("yt-dlp")) {
30
+ throw new BuildToolError("content_reference_missing", `The reference video is not on this computer and yt-dlp is not installed to fetch it.`, `Install yt-dlp (macOS: brew install yt-dlp), or pass referenceFile with the video from ${ref.url}.`, undefined, { missing: "the reference video (install yt-dlp)" });
31
+ }
32
+ mkdirSync(dir, { recursive: true });
33
+ await run("yt-dlp", ["-q", "--no-progress", "--no-playlist", "-f", "mp4/bestvideo*+bestaudio/best", "--merge-output-format", "mp4", "-o", cached, ref.url]);
34
+ if (!existsSync(cached))
35
+ throw new BuildToolError("content_reference_missing", `Could not fetch the reference video from ${ref.url}.`, "Pass referenceFile with the video.", undefined, { missing: "the reference video" });
36
+ return cached;
37
+ }
38
+ /** Cuts, black beats, length, voice and caption look of the reference. */
39
+ export async function studyReference(ref, file) {
40
+ const info = await probe(file);
41
+ let cuts = (await sceneCuts(file, 0.3)).filter((cut) => cut < info.duration - 0.1);
42
+ // soft cuts (pages, cards, slides that look alike) hide from a hard-cut threshold: when the beats
43
+ // say there are many more shots than it found, look again finer, one cut per 0.2 s at most
44
+ if (cuts.length < ((ref.beats?.length ?? 1) - 1) / 2) {
45
+ const finer = (await sceneCuts(file, 0.15, 0.2)).filter((cut) => cut < info.duration - 0.1);
46
+ // only when the finer look really finds that rhythm; a long take's camera swing is not a cut
47
+ if (finer.length >= ((ref.beats?.length ?? 1) - 1) / 2)
48
+ cuts = finer;
49
+ }
50
+ const { yavg } = await signalStats(file, 10);
51
+ const bounds = [0, ...cuts, info.duration];
52
+ const black = bounds.slice(0, -1).map((start, index) => {
53
+ const slice = yavg.slice(Math.floor(start * 10), Math.max(Math.floor(start * 10) + 1, Math.floor(bounds[index + 1] * 10)));
54
+ return slice.length > 0 && slice.reduce((sum, value) => sum + value, 0) / slice.length < 20;
55
+ });
56
+ const hasVoice = refHasVoice(ref);
57
+ const sound = `${ref.hook ?? ""} ${ref.voPattern ?? ""} ${ref.captionStyle ?? ""}`.toLowerCase();
58
+ return {
59
+ referenceId: ref.referenceId,
60
+ file,
61
+ duration: round2(info.duration),
62
+ width: info.width,
63
+ height: info.height,
64
+ cuts,
65
+ black,
66
+ hasVoice,
67
+ firstCut: cuts[0] ?? round2(info.duration),
68
+ caption: parseCaptionStyle(ref.captionStyle, hasVoice),
69
+ music: /music|song|track|on the beat|beat drop/.test(sound),
70
+ };
71
+ }
72
+ const VIDEO = new Set([".mp4", ".mov", ".m4v", ".webm", ".mkv"]);
73
+ const IMAGE = new Set([".png", ".jpg", ".jpeg", ".webp"]);
74
+ const AUDIO = new Set([".wav", ".mp3", ".m4a", ".ogg", ".flac"]);
75
+ function listMedia(paths) {
76
+ const out = [];
77
+ const visit = (path, depth) => {
78
+ const name = basename(path);
79
+ if (name.startsWith(".") || name.startsWith("._"))
80
+ return;
81
+ let stat;
82
+ try {
83
+ stat = statSync(path);
84
+ }
85
+ catch {
86
+ return;
87
+ }
88
+ if (stat.isDirectory()) {
89
+ if (depth > 2)
90
+ return;
91
+ for (const child of readdirSync(path).sort())
92
+ visit(join(path, child), depth + 1);
93
+ }
94
+ else
95
+ out.push(path);
96
+ };
97
+ for (const path of paths)
98
+ visit(path, 0);
99
+ return out;
100
+ }
101
+ /** Notes next to the footage: a folder's shots.json (capture mode) or a clip's own <clip>.json. */
102
+ function sidecarFor(file) {
103
+ const own = file.replace(/\.[^.]+$/, ".json");
104
+ try {
105
+ if (existsSync(own)) {
106
+ const parsed = JSON.parse(readFileSync(own, "utf8"));
107
+ return { about: [parsed.about ?? parsed.description, parsed.action].filter(Boolean).join(". "), tags: parsed.tags };
108
+ }
109
+ const shots = join(dirname(file), "shots.json");
110
+ if (existsSync(shots)) {
111
+ const parsed = JSON.parse(readFileSync(shots, "utf8"));
112
+ const row = parsed.shots?.find((shot) => shot.file === basename(file));
113
+ if (row) {
114
+ const hero = (row.hero ?? {});
115
+ const words = [row.about, row.action, row.camera, row.map, row.light, hero.look, hero.class, hero.skin].filter((value) => typeof value === "string");
116
+ return { about: [row.about, row.action].filter(Boolean).join(". "), tags: wordsOf(words.join(" ")) };
117
+ }
118
+ }
119
+ }
120
+ catch {
121
+ // a broken sidecar is no sidecar
122
+ }
123
+ return null;
124
+ }
125
+ /** Measures one clip (cached by path, size and time): motion and brightness per half second. */
126
+ async function measureClip(file, cacheDir) {
127
+ const stat = statSync(file);
128
+ const key = createHash("sha1").update(`${file}|${stat.size}|${stat.mtimeMs}|v1`).digest("hex");
129
+ const cacheFile = join(cacheDir, "footage", `${key}.json`);
130
+ const sidecar = sidecarFor(file);
131
+ const nameWords = wordsOf(`${basename(file, extname(file)).replace(/[_-]+/g, " ")} ${basename(dirname(file)).replace(/[_-]+/g, " ")}`);
132
+ const tags = [...new Set([...nameWords, ...(sidecar?.tags ?? []), ...wordsOf(sidecar?.about)])];
133
+ let measured = null;
134
+ try {
135
+ if (existsSync(cacheFile))
136
+ measured = JSON.parse(readFileSync(cacheFile, "utf8"));
137
+ }
138
+ catch {
139
+ measured = null;
140
+ }
141
+ const kind = IMAGE.has(extname(file).toLowerCase()) ? "image" : "video";
142
+ if (!measured) {
143
+ const info = await probe(file);
144
+ if (kind === "image")
145
+ measured = { duration: 0, width: info.width, height: info.height, hasAudio: false, motion: [], luma: [] };
146
+ else {
147
+ const stats = await signalStats(file);
148
+ measured = { duration: round2(info.duration), width: info.width, height: info.height, hasAudio: info.hasAudio, motion: windowMeans(stats.ydif, info.fps, 0.5), luma: windowMeans(stats.yavg, info.fps, 0.5) };
149
+ }
150
+ mkdirSync(dirname(cacheFile), { recursive: true });
151
+ writeFileSync(cacheFile, JSON.stringify(measured));
152
+ }
153
+ return { path: file, kind, ...measured, tags, ...(sidecar?.about ? { about: sidecar.about } : {}) };
154
+ }
155
+ /** Every clip, still and song in the footage folders, measured. Frames pulled from a clip (name.mp4.still-3.png) are left out. */
156
+ export async function indexFootage(paths, cacheDir, onClip) {
157
+ const files = listMedia(paths);
158
+ const media = files.filter((file) => VIDEO.has(extname(file).toLowerCase()) || (IMAGE.has(extname(file).toLowerCase()) && !/\.(mp4|mov|webm|mkv)\.still/i.test(basename(file))));
159
+ const clips = [];
160
+ for (const file of media) {
161
+ clips.push(await measureClip(file, cacheDir));
162
+ onClip?.(clips.length, media.length);
163
+ }
164
+ return { clips: clips.filter((clip) => clip.kind === "image" || clip.duration > 0.3), music: files.filter((file) => AUDIO.has(extname(file).toLowerCase())) };
165
+ }