versioncam 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 (182) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE.md +105 -0
  3. package/README.md +463 -0
  4. package/bin/versioncam.js +29 -0
  5. package/dist/.types-render/render-page/draw.d.ts +28 -0
  6. package/dist/.types-render/render-page/main.d.ts +24 -0
  7. package/dist/.types-render/render-page/theme.d.ts +37 -0
  8. package/dist/app-server.d.ts +59 -0
  9. package/dist/app-server.js +328 -0
  10. package/dist/app-server.js.map +1 -0
  11. package/dist/cli/app.d.ts +13 -0
  12. package/dist/cli/app.js +21 -0
  13. package/dist/cli/app.js.map +1 -0
  14. package/dist/cli/commands/check.d.ts +8 -0
  15. package/dist/cli/commands/check.js +51 -0
  16. package/dist/cli/commands/check.js.map +1 -0
  17. package/dist/cli/commands/doctor.d.ts +8 -0
  18. package/dist/cli/commands/doctor.js +130 -0
  19. package/dist/cli/commands/doctor.js.map +1 -0
  20. package/dist/cli/commands/dsl.d.ts +16 -0
  21. package/dist/cli/commands/dsl.js +22 -0
  22. package/dist/cli/commands/dsl.js.map +1 -0
  23. package/dist/cli/commands/frame.d.ts +8 -0
  24. package/dist/cli/commands/frame.js +72 -0
  25. package/dist/cli/commands/frame.js.map +1 -0
  26. package/dist/cli/commands/init.d.ts +23 -0
  27. package/dist/cli/commands/init.js +109 -0
  28. package/dist/cli/commands/init.js.map +1 -0
  29. package/dist/cli/commands/inspect.d.ts +1 -0
  30. package/dist/cli/commands/inspect.js +32 -0
  31. package/dist/cli/commands/inspect.js.map +1 -0
  32. package/dist/cli/commands/install.d.ts +33 -0
  33. package/dist/cli/commands/install.js +66 -0
  34. package/dist/cli/commands/install.js.map +1 -0
  35. package/dist/cli/commands/login.d.ts +10 -0
  36. package/dist/cli/commands/login.js +49 -0
  37. package/dist/cli/commands/login.js.map +1 -0
  38. package/dist/cli/commands/measure.d.ts +1 -0
  39. package/dist/cli/commands/measure.js +36 -0
  40. package/dist/cli/commands/measure.js.map +1 -0
  41. package/dist/cli/commands/open-app.d.ts +14 -0
  42. package/dist/cli/commands/open-app.js +45 -0
  43. package/dist/cli/commands/open-app.js.map +1 -0
  44. package/dist/cli/commands/preview.d.ts +8 -0
  45. package/dist/cli/commands/preview.js +55 -0
  46. package/dist/cli/commands/preview.js.map +1 -0
  47. package/dist/cli/commands/record.d.ts +10 -0
  48. package/dist/cli/commands/record.js +86 -0
  49. package/dist/cli/commands/record.js.map +1 -0
  50. package/dist/cli/commands/render.d.ts +1 -0
  51. package/dist/cli/commands/render.js +93 -0
  52. package/dist/cli/commands/render.js.map +1 -0
  53. package/dist/cli/commands/review.d.ts +6 -0
  54. package/dist/cli/commands/review.js +89 -0
  55. package/dist/cli/commands/review.js.map +1 -0
  56. package/dist/cli/commands/sheet.d.ts +1 -0
  57. package/dist/cli/commands/sheet.js +48 -0
  58. package/dist/cli/commands/sheet.js.map +1 -0
  59. package/dist/cli/commands/stability.d.ts +14 -0
  60. package/dist/cli/commands/stability.js +110 -0
  61. package/dist/cli/commands/stability.js.map +1 -0
  62. package/dist/cli/main.d.ts +2 -0
  63. package/dist/cli/main.js +69 -0
  64. package/dist/cli/main.js.map +1 -0
  65. package/dist/cli/usage.d.ts +10 -0
  66. package/dist/cli/usage.js +46 -0
  67. package/dist/cli/usage.js.map +1 -0
  68. package/dist/config.d.ts +250 -0
  69. package/dist/config.js +154 -0
  70. package/dist/config.js.map +1 -0
  71. package/dist/core/camera.d.ts +30 -0
  72. package/dist/core/camera.js +94 -0
  73. package/dist/core/camera.js.map +1 -0
  74. package/dist/core/compose.d.ts +38 -0
  75. package/dist/core/compose.js +81 -0
  76. package/dist/core/compose.js.map +1 -0
  77. package/dist/core/cursor.d.ts +38 -0
  78. package/dist/core/cursor.js +103 -0
  79. package/dist/core/cursor.js.map +1 -0
  80. package/dist/core/easing.d.ts +15 -0
  81. package/dist/core/easing.js +33 -0
  82. package/dist/core/easing.js.map +1 -0
  83. package/dist/core/loop.d.ts +30 -0
  84. package/dist/core/loop.js +96 -0
  85. package/dist/core/loop.js.map +1 -0
  86. package/dist/core/motion-defaults.d.ts +61 -0
  87. package/dist/core/motion-defaults.js +62 -0
  88. package/dist/core/motion-defaults.js.map +1 -0
  89. package/dist/core/rng.d.ts +13 -0
  90. package/dist/core/rng.js +27 -0
  91. package/dist/core/rng.js.map +1 -0
  92. package/dist/core/sse.d.ts +15 -0
  93. package/dist/core/sse.js +16 -0
  94. package/dist/core/sse.js.map +1 -0
  95. package/dist/core/timeline.d.ts +146 -0
  96. package/dist/core/timeline.js +81 -0
  97. package/dist/core/timeline.js.map +1 -0
  98. package/dist/core/timing.d.ts +31 -0
  99. package/dist/core/timing.js +29 -0
  100. package/dist/core/timing.js.map +1 -0
  101. package/dist/core/typing.d.ts +12 -0
  102. package/dist/core/typing.js +35 -0
  103. package/dist/core/typing.js.map +1 -0
  104. package/dist/driver/clip.d.ts +71 -0
  105. package/dist/driver/clip.js +120 -0
  106. package/dist/driver/clip.js.map +1 -0
  107. package/dist/driver/compare.d.ts +34 -0
  108. package/dist/driver/compare.js +40 -0
  109. package/dist/driver/compare.js.map +1 -0
  110. package/dist/driver/gate.d.ts +36 -0
  111. package/dist/driver/gate.js +27 -0
  112. package/dist/driver/gate.js.map +1 -0
  113. package/dist/driver/launch.d.ts +43 -0
  114. package/dist/driver/launch.js +47 -0
  115. package/dist/driver/launch.js.map +1 -0
  116. package/dist/driver/page-hooks.d.ts +72 -0
  117. package/dist/driver/page-hooks.js +129 -0
  118. package/dist/driver/page-hooks.js.map +1 -0
  119. package/dist/driver/reports.d.ts +34 -0
  120. package/dist/driver/reports.js +42 -0
  121. package/dist/driver/reports.js.map +1 -0
  122. package/dist/driver/session.d.ts +285 -0
  123. package/dist/driver/session.js +773 -0
  124. package/dist/driver/session.js.map +1 -0
  125. package/dist/driver/settle.d.ts +41 -0
  126. package/dist/driver/settle.js +82 -0
  127. package/dist/driver/settle.js.map +1 -0
  128. package/dist/env.d.ts +11 -0
  129. package/dist/env.js +41 -0
  130. package/dist/env.js.map +1 -0
  131. package/dist/fixtures.d.ts +13 -0
  132. package/dist/fixtures.js +13 -0
  133. package/dist/fixtures.js.map +1 -0
  134. package/dist/index.d.ts +30 -0
  135. package/dist/index.js +16 -0
  136. package/dist/index.js.map +1 -0
  137. package/dist/inspect/inspect.d.ts +70 -0
  138. package/dist/inspect/inspect.js +176 -0
  139. package/dist/inspect/inspect.js.map +1 -0
  140. package/dist/inspect/measure.d.ts +40 -0
  141. package/dist/inspect/measure.js +107 -0
  142. package/dist/inspect/measure.js.map +1 -0
  143. package/dist/loader.d.ts +28 -0
  144. package/dist/loader.js +143 -0
  145. package/dist/loader.js.map +1 -0
  146. package/dist/page/assets/index-DUom5amc.js +1 -0
  147. package/dist/page/index.html +18 -0
  148. package/dist/render/encode.d.ts +40 -0
  149. package/dist/render/encode.js +183 -0
  150. package/dist/render/encode.js.map +1 -0
  151. package/dist/render/ffmpeg.d.ts +13 -0
  152. package/dist/render/ffmpeg.js +72 -0
  153. package/dist/render/ffmpeg.js.map +1 -0
  154. package/dist/render/presentation.d.ts +19 -0
  155. package/dist/render/presentation.js +27 -0
  156. package/dist/render/presentation.js.map +1 -0
  157. package/dist/render/render.d.ts +59 -0
  158. package/dist/render/render.js +144 -0
  159. package/dist/render/render.js.map +1 -0
  160. package/dist/render/sampling.d.ts +47 -0
  161. package/dist/render/sampling.js +129 -0
  162. package/dist/render/sampling.js.map +1 -0
  163. package/dist/render/sequence.d.ts +24 -0
  164. package/dist/render/sequence.js +105 -0
  165. package/dist/render/sequence.js.map +1 -0
  166. package/dist/render/serve.d.ts +35 -0
  167. package/dist/render/serve.js +124 -0
  168. package/dist/render/serve.js.map +1 -0
  169. package/dist/review/review.d.ts +54 -0
  170. package/dist/review/review.js +229 -0
  171. package/dist/review/review.js.map +1 -0
  172. package/dist/scene.d.ts +23 -0
  173. package/dist/scene.js +2 -0
  174. package/dist/scene.js.map +1 -0
  175. package/dsl.md +119 -0
  176. package/package.json +76 -0
  177. package/plugin/.claude-plugin/plugin.json +9 -0
  178. package/plugin/README.md +105 -0
  179. package/plugin/agents/versioncam-reviewer.md +63 -0
  180. package/plugin/skills/versioncam/SKILL.md +235 -0
  181. package/plugin/skills/versioncam/authoring.md +226 -0
  182. package/plugin/skills/versioncam/onboarding.md +199 -0
@@ -0,0 +1,72 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ /**
4
+ * Finds ffmpeg and ffprobe.
5
+ *
6
+ * A system install is preferred: it is what a developer already has, and it
7
+ * is the one whose version they can see. `ffmpeg-static` is an optional
8
+ * dependency for CI images that have neither — optional because downloading a
9
+ * ~70 MB binary during `npm install` is rude in someone else's repository.
10
+ */
11
+ let cachedFfmpeg = null;
12
+ let cachedFfprobe = null;
13
+ function onPath(binary) {
14
+ const probe = spawnSync(binary, ["-version"], { stdio: "ignore" });
15
+ return probe.status === 0;
16
+ }
17
+ function fromStaticPackage() {
18
+ try {
19
+ const require = createRequire(import.meta.url);
20
+ const resolved = require("ffmpeg-static");
21
+ return typeof resolved === "string" ? resolved : (resolved.default ?? null);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ export function ffmpegPath() {
28
+ if (cachedFfmpeg)
29
+ return cachedFfmpeg;
30
+ if (onPath("ffmpeg"))
31
+ return (cachedFfmpeg = "ffmpeg");
32
+ const fallback = fromStaticPackage();
33
+ if (fallback)
34
+ return (cachedFfmpeg = fallback);
35
+ throw new Error("ffmpeg not found. Install it (`brew install ffmpeg`, `apt install ffmpeg`) " +
36
+ "or add the optional ffmpeg-static dependency.");
37
+ }
38
+ /**
39
+ * ffprobe, which `ffmpeg-static` does not ship — it is a separate package.
40
+ * Only the system one is looked for; without it, duration checks are
41
+ * unavailable rather than wrong.
42
+ */
43
+ export function ffprobePath() {
44
+ if (cachedFfprobe)
45
+ return cachedFfprobe;
46
+ if (onPath("ffprobe"))
47
+ return (cachedFfprobe = "ffprobe");
48
+ throw new Error("ffprobe not found. It usually installs alongside ffmpeg; on a minimal " +
49
+ "image install the full ffmpeg package rather than ffmpeg-static.");
50
+ }
51
+ export function ffprobeSync(args) {
52
+ return execFileSync(ffprobePath(), args, { encoding: "utf8" });
53
+ }
54
+ /** For `versioncam doctor`: report rather than throw. */
55
+ export function ffmpegStatus() {
56
+ let ffmpeg = null;
57
+ let ffprobe = null;
58
+ try {
59
+ ffmpeg = ffmpegPath();
60
+ }
61
+ catch {
62
+ ffmpeg = null;
63
+ }
64
+ try {
65
+ ffprobe = ffprobePath();
66
+ }
67
+ catch {
68
+ ffprobe = null;
69
+ }
70
+ return { ffmpeg, ffprobe };
71
+ }
72
+ //# sourceMappingURL=ffmpeg.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpeg.js","sourceRoot":"","sources":["../../src/render/ffmpeg.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C;;;;;;;GAOG;AAEH,IAAI,YAAY,GAAkB,IAAI,CAAC;AACvC,IAAI,aAAa,GAAkB,IAAI,CAAC;AAExC,SAAS,MAAM,CAAC,MAAc;IAC5B,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC;IACnE,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC;AAC5B,CAAC;AAED,SAAS,iBAAiB;IACxB,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC/C,MAAM,QAAQ,GAAG,OAAO,CAAC,eAAe,CAAkC,CAAC;QAC3E,OAAO,OAAO,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC;IAC9E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,UAAU;IACxB,IAAI,YAAY;QAAE,OAAO,YAAY,CAAC;IACtC,IAAI,MAAM,CAAC,QAAQ,CAAC;QAAE,OAAO,CAAC,YAAY,GAAG,QAAQ,CAAC,CAAC;IACvD,MAAM,QAAQ,GAAG,iBAAiB,EAAE,CAAC;IACrC,IAAI,QAAQ;QAAE,OAAO,CAAC,YAAY,GAAG,QAAQ,CAAC,CAAC;IAC/C,MAAM,IAAI,KAAK,CACb,6EAA6E;QAC3E,+CAA+C,CAClD,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW;IACzB,IAAI,aAAa;QAAE,OAAO,aAAa,CAAC;IACxC,IAAI,MAAM,CAAC,SAAS,CAAC;QAAE,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAAC;IAC1D,MAAM,IAAI,KAAK,CACb,wEAAwE;QACtE,kEAAkE,CACrE,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,IAAc;IACxC,OAAO,YAAY,CAAC,WAAW,EAAE,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;AACjE,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,YAAY;IAI1B,IAAI,MAAM,GAAkB,IAAI,CAAC;IACjC,IAAI,OAAO,GAAkB,IAAI,CAAC;IAClC,IAAI,CAAC;QACH,MAAM,GAAG,UAAU,EAAE,CAAC;IACxB,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,GAAG,IAAI,CAAC;IAChB,CAAC;IACD,IAAI,CAAC;QACH,OAAO,GAAG,WAAW,EAAE,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,IAAI,CAAC;IACjB,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;AAC7B,CAAC"}
@@ -0,0 +1,19 @@
1
+ import type { Presentation } from "../config.js";
2
+ import type { Rect } from "../core/timeline.js";
3
+ /**
4
+ * Where the captured app is drawn inside the output canvas.
5
+ *
6
+ * With no presentation the app fills the canvas edge to edge, which is exact
7
+ * rather than approximate: the output's aspect is derived from the viewport's,
8
+ * so there is nothing to letterbox. Presentation insets it to make room for a
9
+ * backdrop and a frame.
10
+ */
11
+ export declare function frameAreaFor(canvas: {
12
+ width: number;
13
+ height: number;
14
+ }, viewport: {
15
+ width: number;
16
+ height: number;
17
+ }, presentation: Presentation | null): Rect;
18
+ /** Whether the caption sits under the app or over it. */
19
+ export declare function captionPlacement(presentation: Presentation | null): "below" | "overlay" | "none";
@@ -0,0 +1,27 @@
1
+ import { fitFrameArea } from "../core/compose.js";
2
+ /**
3
+ * Where the captured app is drawn inside the output canvas.
4
+ *
5
+ * With no presentation the app fills the canvas edge to edge, which is exact
6
+ * rather than approximate: the output's aspect is derived from the viewport's,
7
+ * so there is nothing to letterbox. Presentation insets it to make room for a
8
+ * backdrop and a frame.
9
+ */
10
+ export function frameAreaFor(canvas, viewport, presentation) {
11
+ const padding = presentation?.padding ?? 0;
12
+ if (!presentation || padding <= 0) {
13
+ return { x: 0, y: 0, w: canvas.width, h: canvas.height };
14
+ }
15
+ return fitFrameArea(canvas, viewport, padding);
16
+ }
17
+ /** Whether the caption sits under the app or over it. */
18
+ export function captionPlacement(presentation) {
19
+ const mode = presentation?.caption ?? "auto";
20
+ if (mode === "none")
21
+ return "none";
22
+ if (mode === "below" || mode === "overlay")
23
+ return mode;
24
+ // "auto": there is only room below the app if something made room.
25
+ return (presentation?.padding ?? 0) > 0 ? "below" : "overlay";
26
+ }
27
+ //# sourceMappingURL=presentation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presentation.js","sourceRoot":"","sources":["../../src/render/presentation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIlD;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAC1B,MAAyC,EACzC,QAA2C,EAC3C,YAAiC;IAEjC,MAAM,OAAO,GAAG,YAAY,EAAE,OAAO,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,YAAY,IAAI,OAAO,IAAI,CAAC,EAAE,CAAC;QAClC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;IAC3D,CAAC;IACD,OAAO,YAAY,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;AACjD,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,gBAAgB,CAC9B,YAAiC;IAEjC,MAAM,IAAI,GAAG,YAAY,EAAE,OAAO,IAAI,MAAM,CAAC;IAC7C,IAAI,IAAI,KAAK,MAAM;QAAE,OAAO,MAAM,CAAC;IACnC,IAAI,IAAI,KAAK,OAAO,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACxD,mEAAmE;IACnE,OAAO,CAAC,YAAY,EAAE,OAAO,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;AAChE,CAAC"}
@@ -0,0 +1,59 @@
1
+ import type { Browser, Page } from "playwright";
2
+ import type { ResolvedConfig } from "../config.js";
3
+ import type { Timeline } from "../core/timeline.js";
4
+ import type { EncodeResult } from "./encode.js";
5
+ export type RenderTarget = {
6
+ kind: "recording";
7
+ id: string;
8
+ dir: string;
9
+ } | {
10
+ kind: "scene";
11
+ id: string;
12
+ };
13
+ /**
14
+ * Open the render page at the exact output size, at DPR 1.
15
+ *
16
+ * DPR 1 is deliberate: the canvas is already authored in output pixels, and a
17
+ * device scale factor of 2 would make the screenshot twice as wide and then
18
+ * need downscaling — a second resample on top of the one `drawImage` already
19
+ * did.
20
+ */
21
+ export declare function openRenderPage(browser: Browser, config: ResolvedConfig, target: RenderTarget): Promise<{
22
+ page: Page;
23
+ durationFrames: number;
24
+ fps: number;
25
+ }>;
26
+ export type RenderOutcome = EncodeResult & {
27
+ id: string;
28
+ durationFrames: number;
29
+ fps: number;
30
+ sheet: string;
31
+ };
32
+ export declare function renderRecording(browser: Browser, config: ResolvedConfig, id: string): Promise<RenderOutcome>;
33
+ export declare function renderScene(browser: Browser, config: ResolvedConfig, id: string): Promise<RenderOutcome>;
34
+ export declare function contactSheet(browser: Browser, config: ResolvedConfig, target: RenderTarget, options?: {
35
+ frames?: number[];
36
+ }): Promise<string>;
37
+ /**
38
+ * One frame, rendered at output size.
39
+ *
40
+ * The same page and the same `setFrame` the encoder drives, so this is the
41
+ * frame that would be in the video rather than an approximation of it. A
42
+ * contact sheet's tile is a quarter the width and settles nothing about a
43
+ * caption's legibility or whether a control is cropped; this is the look
44
+ * that does.
45
+ */
46
+ export declare function renderFrame(browser: Browser, config: ResolvedConfig, target: RenderTarget, frame: number): Promise<Buffer>;
47
+ /** The timeline of a recording on disk, for reporting without a browser. */
48
+ export declare function readTimeline(config: ResolvedConfig, id: string): Promise<Timeline>;
49
+ export declare function withBrowser<T>(fn: (browser: Browser) => Promise<T>): Promise<T>;
50
+ declare global {
51
+ interface Window {
52
+ __render?: {
53
+ ready: Promise<void>;
54
+ setFrame: (frame: number) => Promise<void>;
55
+ durationFrames: number;
56
+ fps: number;
57
+ };
58
+ }
59
+ }
@@ -0,0 +1,144 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { launchBrowser } from "../driver/launch.js";
5
+ import { renderConfigOf } from "../config.js";
6
+ import { bundleScenes } from "../loader.js";
7
+ import { contactSheet as encodeContactSheet, encodeFromPage, } from "./encode.js";
8
+ import { distPageDir, installRenderRoutes, RENDER_ORIGIN } from "./serve.js";
9
+ /**
10
+ * Open the render page at the exact output size, at DPR 1.
11
+ *
12
+ * DPR 1 is deliberate: the canvas is already authored in output pixels, and a
13
+ * device scale factor of 2 would make the screenshot twice as wide and then
14
+ * need downscaling — a second resample on top of the one `drawImage` already
15
+ * did.
16
+ */
17
+ export async function openRenderPage(browser, config, target) {
18
+ const context = await browser.newContext({
19
+ viewport: config.output,
20
+ deviceScaleFactor: 1,
21
+ reducedMotion: "reduce",
22
+ timezoneId: config.clock.timezoneId,
23
+ locale: config.clock.locale,
24
+ });
25
+ const page = await context.newPage();
26
+ const problems = [];
27
+ page.on("pageerror", (error) => problems.push(error.message));
28
+ page.on("console", (message) => {
29
+ if (message.type() === "error")
30
+ problems.push(message.text());
31
+ });
32
+ await installRenderRoutes(page, {
33
+ distDir: distPageDir(),
34
+ renderConfig: renderConfigOf(config),
35
+ recordingDir: target.kind === "recording" ? target.dir : undefined,
36
+ scenesBundle: (await bundleScenes(config)) ?? undefined,
37
+ });
38
+ const query = target.kind === "recording"
39
+ ? "recording"
40
+ : `scene=${encodeURIComponent(target.id)}`;
41
+ const response = await page.goto(`${RENDER_ORIGIN}/?${query}&headless=1`, {
42
+ waitUntil: "domcontentloaded",
43
+ });
44
+ if (!response || !response.ok()) {
45
+ await context.close();
46
+ throw new Error(`The render page did not load (${response?.status() ?? "no response"}). ` +
47
+ `Expected it at ${distPageDir()} — run \`npm run build\` in the recorder package.`);
48
+ }
49
+ await page.waitForFunction(() => window.__render !== undefined, undefined, {
50
+ timeout: 30_000,
51
+ });
52
+ await page.evaluate(() => window.__render?.ready);
53
+ const info = await page.evaluate(() => ({
54
+ durationFrames: window.__render?.durationFrames ?? 0,
55
+ fps: window.__render?.fps ?? 60,
56
+ }));
57
+ if (info.durationFrames === 0) {
58
+ await context.close();
59
+ throw new Error(`The render page reported no frames for ${target.kind} "${target.id}".` +
60
+ (problems.length ? `\n${problems.join("\n")}` : ""));
61
+ }
62
+ return { page, ...info };
63
+ }
64
+ async function renderTarget(browser, config, target) {
65
+ const { page, durationFrames, fps } = await openRenderPage(browser, config, target);
66
+ try {
67
+ const encoded = await encodeFromPage(page, {
68
+ id: target.id,
69
+ fps,
70
+ durationFrames,
71
+ outDir: config.outputDir,
72
+ });
73
+ const sheet = await encodeContactSheet(page, {
74
+ id: target.id,
75
+ durationFrames,
76
+ outDir: config.outputDir,
77
+ });
78
+ return { ...encoded, id: target.id, durationFrames, fps, sheet };
79
+ }
80
+ finally {
81
+ await page.context().close();
82
+ }
83
+ }
84
+ export async function renderRecording(browser, config, id) {
85
+ const dir = join(config.recordingsDir, id);
86
+ if (!existsSync(join(dir, "timeline.json"))) {
87
+ throw new Error(`No recording for "${id}". Run \`versioncam record ${id}\` first.`);
88
+ }
89
+ return renderTarget(browser, config, { kind: "recording", id, dir });
90
+ }
91
+ export async function renderScene(browser, config, id) {
92
+ return renderTarget(browser, config, { kind: "scene", id });
93
+ }
94
+ export async function contactSheet(browser, config, target, options = {}) {
95
+ const { page, durationFrames } = await openRenderPage(browser, config, target);
96
+ try {
97
+ return await encodeContactSheet(page, {
98
+ id: target.id,
99
+ durationFrames,
100
+ frames: options.frames,
101
+ outDir: config.outputDir,
102
+ });
103
+ }
104
+ finally {
105
+ await page.context().close();
106
+ }
107
+ }
108
+ /**
109
+ * One frame, rendered at output size.
110
+ *
111
+ * The same page and the same `setFrame` the encoder drives, so this is the
112
+ * frame that would be in the video rather than an approximation of it. A
113
+ * contact sheet's tile is a quarter the width and settles nothing about a
114
+ * caption's legibility or whether a control is cropped; this is the look
115
+ * that does.
116
+ */
117
+ export async function renderFrame(browser, config, target, frame) {
118
+ const { page, durationFrames } = await openRenderPage(browser, config, target);
119
+ try {
120
+ if (!Number.isInteger(frame) || frame < 0 || frame >= durationFrames) {
121
+ throw new Error(`Frame ${frame} is not in "${target.id}": it runs 0–${durationFrames - 1}.`);
122
+ }
123
+ await page.evaluate((n) => window.__render?.setFrame(n), frame);
124
+ return await page.locator("#stage").screenshot({ type: "png" });
125
+ }
126
+ finally {
127
+ await page.context().close();
128
+ }
129
+ }
130
+ /** The timeline of a recording on disk, for reporting without a browser. */
131
+ export async function readTimeline(config, id) {
132
+ const file = join(config.recordingsDir, id, "timeline.json");
133
+ return JSON.parse(await readFile(file, "utf8"));
134
+ }
135
+ export async function withBrowser(fn) {
136
+ const browser = await launchBrowser();
137
+ try {
138
+ return await fn(browser);
139
+ }
140
+ finally {
141
+ await browser.close();
142
+ }
143
+ }
144
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../../src/render/render.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ import type { Mark } from "../core/timeline.js";
2
+ /**
3
+ * Which frames a contact sheet shows.
4
+ *
5
+ * Eight tiles, because that is what fits legibly on a screen at 960px wide and
6
+ * what a reviewer will actually look at one by one.
7
+ */
8
+ export declare const SHEET_TILES = 8;
9
+ /** Evenly spaced, ends included: the shape of the clip, knowing nothing about it. */
10
+ export declare function uniformFrames(durationFrames: number, count?: number): number[];
11
+ /**
12
+ * Frame 0, every beat, and the last frame.
13
+ *
14
+ * An evenly spaced sample is blind to what a clip is *about*: a pilot's sheet
15
+ * missed the one beat it had been written around, because nothing in eight
16
+ * uniform samples knew where the beats were. The marks do.
17
+ *
18
+ * With more beats than tiles the ends are kept and the middle is thinned
19
+ * evenly — a reviewer who cannot see every beat is better served by the shape
20
+ * of the whole clip than by its first eight moments.
21
+ */
22
+ export declare function markedFrames(durationFrames: number, marks: Mark[], count?: number, crossfadeFrames?: number, cuts?: readonly number[]): number[];
23
+ /**
24
+ * The frame a beat is shown at: its mark, moved past the crossfade that
25
+ * starts there — the same frame `markedFrames` puts on a sheet, so asking for
26
+ * a beat by name and looking at its tile are one picture.
27
+ */
28
+ export declare function beatFrame(mark: Mark, durationFrames: number, crossfadeFrames?: number): number;
29
+ /**
30
+ * Which frame `frame --at <label>` means, or why there is none.
31
+ *
32
+ * By label because matching a beat to a frame number was arithmetic, and an
33
+ * agent got the arithmetic wrong. The exact label first; then a prefix, case
34
+ * ignored, because a label is a phrase someone wrote and the first word is
35
+ * what gets typed back. A prefix that fits several labels is refused rather
36
+ * than settled by order — the wrong beat looked at confidently is worse than
37
+ * a question — and every refusal lists the labels there are.
38
+ */
39
+ export declare function frameAtMark(timeline: {
40
+ durationFrames: number;
41
+ marks?: readonly Mark[];
42
+ }, wanted: string, crossfadeFrames?: number): {
43
+ frame: number;
44
+ label: string;
45
+ } | {
46
+ error: string;
47
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Which frames a contact sheet shows.
3
+ *
4
+ * Eight tiles, because that is what fits legibly on a screen at 960px wide and
5
+ * what a reviewer will actually look at one by one.
6
+ */
7
+ export const SHEET_TILES = 8;
8
+ /** Evenly spaced, ends included: the shape of the clip, knowing nothing about it. */
9
+ export function uniformFrames(durationFrames, count = SHEET_TILES) {
10
+ const tiles = Math.max(2, Math.min(count, durationFrames));
11
+ return Array.from({ length: tiles }, (_, i) => Math.round((i / (tiles - 1)) * (durationFrames - 1)));
12
+ }
13
+ /**
14
+ * Frame 0, every beat, and the last frame.
15
+ *
16
+ * An evenly spaced sample is blind to what a clip is *about*: a pilot's sheet
17
+ * missed the one beat it had been written around, because nothing in eight
18
+ * uniform samples knew where the beats were. The marks do.
19
+ *
20
+ * With more beats than tiles the ends are kept and the middle is thinned
21
+ * evenly — a reviewer who cannot see every beat is better served by the shape
22
+ * of the whole clip than by its first eight moments.
23
+ */
24
+ export function markedFrames(durationFrames, marks, count = SHEET_TILES, crossfadeFrames = 0, cuts = []) {
25
+ const inRange = (frame) => Number.isInteger(frame) && frame >= 0 && frame < durationFrames;
26
+ // Past the crossfade, wherever a frame would land inside one.
27
+ //
28
+ // The renderer dissolves between states for `crossfadeFrames` after every
29
+ // cut, and a tile taken inside that window is two pages at once. A reviewer
30
+ // shown one said so and declined to judge the beat from it, which is the
31
+ // right answer to a question that should not have been asked.
32
+ //
33
+ // A mark is the worst case — it sits on the cut's first frame, where the
34
+ // blend has barely begun and the tile still shows what was on screen
35
+ // *before* the wait — but any evenly spread tile can land there too. Marks
36
+ // stay where they are, because `review` wants the state boundary; only the
37
+ // picture waits for the dissolve to finish.
38
+ const settled = (frame) => {
39
+ const cut = cuts.find((c) => frame > c && frame <= c + crossfadeFrames);
40
+ const clear = cut === undefined ? frame : cut + crossfadeFrames + 1;
41
+ return Math.min(clear, durationFrames - 1);
42
+ };
43
+ const wanted = [
44
+ 0,
45
+ ...marks.map((m) => beatFrame(m, durationFrames, crossfadeFrames)),
46
+ durationFrames - 1,
47
+ ].filter(inRange);
48
+ const unique = [...new Set(wanted)].sort((a, b) => a - b);
49
+ // Fill the rest of the grid with an even spread.
50
+ //
51
+ // A clip with one beat gave a sheet of three tiles, and everything between
52
+ // the beat and the end — a ring highlight, a camera move, whatever the clip
53
+ // spent six of its ten seconds on — appeared in none of them. The beats are
54
+ // what a sheet must not miss, not all it may show, so the spare tiles go to
55
+ // the shape of the clip around them.
56
+ if (unique.length < count) {
57
+ const filled = new Set(unique);
58
+ for (const frame of uniformFrames(durationFrames, count)) {
59
+ if (filled.size >= count)
60
+ break;
61
+ filled.add(settled(frame));
62
+ }
63
+ return [...filled].sort((a, b) => a - b);
64
+ }
65
+ if (unique.length === count)
66
+ return unique;
67
+ const kept = [unique[0]];
68
+ for (let i = 1; i <= count - 2; i += 1) {
69
+ kept.push(unique[Math.round((i * (unique.length - 1)) / (count - 1))]);
70
+ }
71
+ kept.push(unique[unique.length - 1]);
72
+ return [...new Set(kept)];
73
+ }
74
+ /**
75
+ * The frame a beat is shown at: its mark, moved past the crossfade that
76
+ * starts there — the same frame `markedFrames` puts on a sheet, so asking for
77
+ * a beat by name and looking at its tile are one picture.
78
+ */
79
+ export function beatFrame(mark, durationFrames, crossfadeFrames = 0) {
80
+ return mark.frame === 0
81
+ ? 0
82
+ : Math.min(mark.frame + crossfadeFrames, durationFrames - 1);
83
+ }
84
+ /**
85
+ * Which frame `frame --at <label>` means, or why there is none.
86
+ *
87
+ * By label because matching a beat to a frame number was arithmetic, and an
88
+ * agent got the arithmetic wrong. The exact label first; then a prefix, case
89
+ * ignored, because a label is a phrase someone wrote and the first word is
90
+ * what gets typed back. A prefix that fits several labels is refused rather
91
+ * than settled by order — the wrong beat looked at confidently is worse than
92
+ * a question — and every refusal lists the labels there are.
93
+ */
94
+ export function frameAtMark(
95
+ // `marks` optional because recordings made before marks existed have none,
96
+ // whatever the type of a current timeline says.
97
+ timeline, wanted, crossfadeFrames = 0) {
98
+ const marks = timeline.marks ?? [];
99
+ if (marks.length === 0) {
100
+ return {
101
+ error: "This recording has no beats to go by — no labelled settle(), or it " +
102
+ "was made before marks existed. Ask for a frame number or --t instead.",
103
+ };
104
+ }
105
+ const labels = [...new Set(marks.map((m) => m.label))];
106
+ const list = labels.map((l) => ` ${JSON.stringify(l)}`).join("\n");
107
+ const exact = marks.find((m) => m.label === wanted);
108
+ const prefix = wanted.toLowerCase();
109
+ const candidates = labels.filter((l) => l.toLowerCase().startsWith(prefix));
110
+ if (!exact && candidates.length > 1) {
111
+ return {
112
+ error: `${JSON.stringify(wanted)} starts more than one beat:\n${candidates
113
+ .map((l) => ` ${JSON.stringify(l)}`)
114
+ .join("\n")}`,
115
+ };
116
+ }
117
+ const mark = exact ??
118
+ marks.find((m) => candidates.length === 1 && m.label === candidates[0]);
119
+ if (!mark) {
120
+ return {
121
+ error: `No beat is labelled ${JSON.stringify(wanted)}. The beats:\n${list}`,
122
+ };
123
+ }
124
+ return {
125
+ frame: beatFrame(mark, timeline.durationFrames, crossfadeFrames),
126
+ label: mark.label,
127
+ };
128
+ }
129
+ //# sourceMappingURL=sampling.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sampling.js","sourceRoot":"","sources":["../../src/render/sampling.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ export type SequenceInput = {
2
+ id: string;
3
+ path: string;
4
+ duration: number;
5
+ };
6
+ /** Duration of a video file, in seconds. Blocking; ffprobe is quick. */
7
+ export declare function probeDuration(path: string): number;
8
+ /**
9
+ * The `xfade` chain. Each transition's `offset` is measured from the start of
10
+ * everything accumulated so far, and every crossfade shortens the total by its
11
+ * own duration — which is the part that is easy to get wrong and shows up as
12
+ * a clip that starts before the previous one has finished.
13
+ */
14
+ export declare function buildFilter(inputs: SequenceInput[]): {
15
+ filter: string;
16
+ label: string;
17
+ total: number;
18
+ };
19
+ export declare function buildSequence(ids: readonly string[], outDir: string): Promise<{
20
+ path: string;
21
+ total: number;
22
+ used: string[];
23
+ missing: string[];
24
+ }>;
@@ -0,0 +1,105 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { ffmpegPath, ffprobeSync } from "./ffmpeg.js";
5
+ /**
6
+ * Stitches finished clips into one continuous piece.
7
+ *
8
+ * Done with ffmpeg's `xfade` over the already-encoded clips rather than by
9
+ * re-rendering a combined timeline. Re-rendering would mean holding every
10
+ * clip's frames in one browser page at once for no gain in quality, and the
11
+ * clips have to stand alone anyway — the sequence is an arrangement of
12
+ * finished things, not another thing.
13
+ *
14
+ * No title cards between clips: each already carries its own caption, and a
15
+ * card per clip adds a sixth of the running time for nothing.
16
+ */
17
+ const TRANSITION_S = 0.4;
18
+ /** Duration of a video file, in seconds. Blocking; ffprobe is quick. */
19
+ export function probeDuration(path) {
20
+ const out = ffprobeSync([
21
+ "-v",
22
+ "error",
23
+ "-show_entries",
24
+ "format=duration",
25
+ "-of",
26
+ "default=nw=1:nk=1",
27
+ path,
28
+ ]);
29
+ const seconds = Number.parseFloat(out.trim());
30
+ if (!Number.isFinite(seconds)) {
31
+ throw new Error(`Could not read a duration from ${path}`);
32
+ }
33
+ return seconds;
34
+ }
35
+ /**
36
+ * The `xfade` chain. Each transition's `offset` is measured from the start of
37
+ * everything accumulated so far, and every crossfade shortens the total by its
38
+ * own duration — which is the part that is easy to get wrong and shows up as
39
+ * a clip that starts before the previous one has finished.
40
+ */
41
+ export function buildFilter(inputs) {
42
+ if (inputs.length === 1)
43
+ return { filter: "", label: "0:v", total: inputs[0].duration };
44
+ const parts = [];
45
+ let current = "0:v";
46
+ let accumulated = inputs[0].duration;
47
+ for (let i = 1; i < inputs.length; i += 1) {
48
+ const offset = accumulated - TRANSITION_S;
49
+ const label = i === inputs.length - 1 ? "out" : `x${i}`;
50
+ parts.push(`[${current}][${i}:v]xfade=transition=fade:duration=${TRANSITION_S}:offset=${offset.toFixed(3)}[${label}]`);
51
+ current = label;
52
+ accumulated += inputs[i].duration - TRANSITION_S;
53
+ }
54
+ return { filter: parts.join(";"), label: current, total: accumulated };
55
+ }
56
+ export async function buildSequence(ids, outDir) {
57
+ const missing = [];
58
+ const inputs = [];
59
+ for (const id of ids) {
60
+ const path = join(outDir, `${id}.mp4`);
61
+ if (!existsSync(path) || statSync(path).size < 1000) {
62
+ missing.push(id);
63
+ continue;
64
+ }
65
+ inputs.push({ id, path, duration: probeDuration(path) });
66
+ }
67
+ if (inputs.length < 2) {
68
+ throw new Error(`Need at least two rendered clips to build a sequence; have ${inputs.length}.`);
69
+ }
70
+ const { filter, label, total } = buildFilter(inputs);
71
+ const out = join(outDir, "sequence.mp4");
72
+ await new Promise((resolve, reject) => {
73
+ const child = spawn(ffmpegPath(), [
74
+ "-y",
75
+ ...inputs.flatMap((input) => ["-i", input.path]),
76
+ "-filter_complex",
77
+ filter,
78
+ "-map",
79
+ `[${label}]`,
80
+ "-c:v",
81
+ "libx264",
82
+ "-preset",
83
+ "slow",
84
+ "-crf",
85
+ "18",
86
+ "-pix_fmt",
87
+ "yuv420p",
88
+ "-movflags",
89
+ "+faststart",
90
+ out,
91
+ ], { stdio: ["ignore", "ignore", "pipe"] });
92
+ let stderr = "";
93
+ child.stderr.on("data", (c) => {
94
+ stderr += c.toString();
95
+ });
96
+ child.on("close", (code) => {
97
+ if (code === 0)
98
+ resolve();
99
+ else
100
+ reject(new Error(`sequence failed:\n${stderr.slice(-1200)}`));
101
+ });
102
+ });
103
+ return { path: out, total, used: inputs.map((i) => i.id), missing };
104
+ }
105
+ //# sourceMappingURL=sequence.js.map