versioncam 0.1.3 → 0.3.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 (99) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +152 -15
  3. package/dist/cli/commands/check.js +45 -4
  4. package/dist/cli/commands/check.js.map +1 -1
  5. package/dist/cli/commands/doctor.js +39 -2
  6. package/dist/cli/commands/doctor.js.map +1 -1
  7. package/dist/cli/commands/init.js +25 -0
  8. package/dist/cli/commands/init.js.map +1 -1
  9. package/dist/cli/commands/preview.js +10 -1
  10. package/dist/cli/commands/preview.js.map +1 -1
  11. package/dist/cli/commands/publish.d.ts +17 -0
  12. package/dist/cli/commands/publish.js +203 -0
  13. package/dist/cli/commands/publish.js.map +1 -0
  14. package/dist/cli/commands/record.js +2 -0
  15. package/dist/cli/commands/record.js.map +1 -1
  16. package/dist/cli/commands/render.d.ts +17 -0
  17. package/dist/cli/commands/render.js +98 -21
  18. package/dist/cli/commands/render.js.map +1 -1
  19. package/dist/cli/cwd.d.ts +46 -0
  20. package/dist/cli/cwd.js +102 -0
  21. package/dist/cli/cwd.js.map +1 -0
  22. package/dist/cli/main.js +19 -2
  23. package/dist/cli/main.js.map +1 -1
  24. package/dist/cli/usage.d.ts +3 -2
  25. package/dist/cli/usage.js +17 -4
  26. package/dist/cli/usage.js.map +1 -1
  27. package/dist/config.d.ts +35 -1
  28. package/dist/config.js +44 -0
  29. package/dist/config.js.map +1 -1
  30. package/dist/core/camera-track.d.ts +63 -0
  31. package/dist/core/camera-track.js +230 -0
  32. package/dist/core/camera-track.js.map +1 -0
  33. package/dist/core/camera.d.ts +14 -1
  34. package/dist/core/camera.js +18 -2
  35. package/dist/core/camera.js.map +1 -1
  36. package/dist/core/follow.d.ts +129 -0
  37. package/dist/core/follow.js +151 -0
  38. package/dist/core/follow.js.map +1 -0
  39. package/dist/core/motion-defaults.d.ts +29 -0
  40. package/dist/core/motion-defaults.js +29 -0
  41. package/dist/core/motion-defaults.js.map +1 -1
  42. package/dist/core/timeline.d.ts +10 -2
  43. package/dist/core/timeline.js.map +1 -1
  44. package/dist/driver/clip.d.ts +6 -0
  45. package/dist/driver/clip.js +6 -3
  46. package/dist/driver/clip.js.map +1 -1
  47. package/dist/driver/reports.d.ts +34 -2
  48. package/dist/driver/reports.js +31 -3
  49. package/dist/driver/reports.js.map +1 -1
  50. package/dist/driver/session.d.ts +36 -1
  51. package/dist/driver/session.js +72 -14
  52. package/dist/driver/session.js.map +1 -1
  53. package/dist/index.d.ts +11 -3
  54. package/dist/index.js +6 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/loader.d.ts +7 -1
  57. package/dist/loader.js +23 -8
  58. package/dist/loader.js.map +1 -1
  59. package/dist/page/assets/index-fZtacWyp.js +2 -0
  60. package/dist/page/index.html +1 -1
  61. package/dist/publish/client.d.ts +38 -0
  62. package/dist/publish/client.js +118 -0
  63. package/dist/publish/client.js.map +1 -0
  64. package/dist/publish/index.d.ts +12 -0
  65. package/dist/publish/index.js +11 -0
  66. package/dist/publish/index.js.map +1 -0
  67. package/dist/publish/manifest.d.ts +46 -0
  68. package/dist/publish/manifest.js +133 -0
  69. package/dist/publish/manifest.js.map +1 -0
  70. package/dist/publish/protocol.d.ts +203 -0
  71. package/dist/publish/protocol.js +56 -0
  72. package/dist/publish/protocol.js.map +1 -0
  73. package/dist/render/encode.d.ts +27 -1
  74. package/dist/render/encode.js +115 -37
  75. package/dist/render/encode.js.map +1 -1
  76. package/dist/render/presentation.d.ts +12 -0
  77. package/dist/render/presentation.js +15 -0
  78. package/dist/render/presentation.js.map +1 -1
  79. package/dist/render/render.js +19 -1
  80. package/dist/render/render.js.map +1 -1
  81. package/dist/render/sampling.d.ts +13 -0
  82. package/dist/render/sampling.js +47 -19
  83. package/dist/render/sampling.js.map +1 -1
  84. package/dist/render/scenes.d.ts +16 -0
  85. package/dist/render/scenes.js +108 -0
  86. package/dist/render/scenes.js.map +1 -0
  87. package/dist/render/sequence.d.ts +21 -1
  88. package/dist/render/sequence.js +71 -14
  89. package/dist/render/sequence.js.map +1 -1
  90. package/dist/review/review.js +46 -21
  91. package/dist/review/review.js.map +1 -1
  92. package/dsl.md +37 -0
  93. package/package.json +6 -2
  94. package/plugin/README.md +7 -2
  95. package/plugin/agents/versioncam-reviewer.md +3 -2
  96. package/plugin/skills/versioncam/SKILL.md +60 -14
  97. package/plugin/skills/versioncam/authoring.md +6 -1
  98. package/plugin/skills/versioncam/onboarding.md +17 -17
  99. package/dist/page/assets/index-DUom5amc.js +0 -1
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The publish protocol: what `versioncam publish` sends to version.cam, and
3
+ * what comes back.
4
+ *
5
+ * Both ends import this file. The recorder builds a publish from it and the
6
+ * service validates one against it, through `versioncam/publish`, an entry
7
+ * that pulls in nothing that needs Node. A field added on one side is a type
8
+ * error on the other until both agree, which is the only kind of protocol
9
+ * drift that cannot reach production.
10
+ *
11
+ * The shape follows what a recording is on disk. A recording is a timeline
12
+ * plus a directory of distinct states, so a publish is a timeline plus the
13
+ * states' content hashes: the service asks for the states it has never seen,
14
+ * and a version that changed one screen uploads one screen.
15
+ */
16
+ /** Bumped when either side could misread the other. */
17
+ export declare const PUBLISH_PROTOCOL = 1;
18
+ /** Where the service is, unless `VERSIONCAM_API` says otherwise. */
19
+ export declare const DEFAULT_API = "https://api.version.cam";
20
+ /**
21
+ * The audience a GitHub OpenID Connect token must be minted for. The Action
22
+ * asks for exactly this, and the service refuses any other, so a token
23
+ * minted for some other service cannot be replayed here.
24
+ */
25
+ export declare const OIDC_AUDIENCE = "version.cam";
26
+ /**
27
+ * Limits that hold on every plan. The service enforces them; they are here so
28
+ * the client can say which one a recording breaks before uploading anything.
29
+ */
30
+ export declare const HARD_CAPS: {
31
+ readonly statesPerVersion: 400;
32
+ readonly stateBytes: number;
33
+ readonly timelineBytes: number;
34
+ readonly targetsBytes: number;
35
+ readonly failureBytes: number;
36
+ readonly publishesPerHour: 20;
37
+ };
38
+ /** The step that was running when a clip failed: its index and its call. */
39
+ export type FailedStep = {
40
+ index: number;
41
+ call: string;
42
+ };
43
+ /** What the last run of a clip said about it. */
44
+ export type CheckResult = {
45
+ status: "pass";
46
+ } | {
47
+ status: "fail";
48
+ message: string;
49
+ step: FailedStep | null;
50
+ frame: number;
51
+ t: number;
52
+ };
53
+ /** One distinct state: a PNG, by the SHA-256 of its bytes. */
54
+ export type StateRef = {
55
+ hash: string;
56
+ bytes: number;
57
+ width: number;
58
+ height: number;
59
+ };
60
+ /**
61
+ * What the service needs to render a recording exactly as `versioncam render`
62
+ * would have: the render page's config without its scenes (a scenes module is
63
+ * the app's own code, and nothing runs an app's code but the app's own CI),
64
+ * and the clock the render page is opened with.
65
+ */
66
+ export type PublishedRenderConfig = {
67
+ output: {
68
+ width: number;
69
+ height: number;
70
+ };
71
+ theme: {
72
+ accent: string;
73
+ cursorScale: number;
74
+ /** The config's `theme.presentation`, or null when it is off. */
75
+ presentation: Record<string, unknown> | null;
76
+ };
77
+ /** The config's motion, every default filled in. */
78
+ motion: Record<string, unknown>;
79
+ clock: {
80
+ timezoneId: string;
81
+ locale: string;
82
+ };
83
+ };
84
+ /** A complete recording, described without its bytes. */
85
+ export type RecordingManifest = {
86
+ /**
87
+ * The SHA-256 of what makes this recording look the way it does: the
88
+ * timeline without the fields that change on every run (when it was
89
+ * recorded, the app's commit), the states' hashes in order, the targets
90
+ * and the render config. Two runs that would render the same bytes have
91
+ * the same content hash, which is how the service knows a version is
92
+ * unchanged since the last one without being sent it.
93
+ */
94
+ contentHash: string;
95
+ /** The SHA-256 of `timeline.json` exactly as it will be uploaded. */
96
+ timelineHash: string;
97
+ timelineBytes: number;
98
+ /** The SHA-256 of `targets.json`, or null for a recording without one. */
99
+ targetsHash: string | null;
100
+ /** Recorded with `--draft`: 15 frames a second, at CSS scale. */
101
+ draft: boolean;
102
+ fps: number;
103
+ durationFrames: number;
104
+ viewport: {
105
+ width: number;
106
+ height: number;
107
+ dpr: number;
108
+ };
109
+ /** Every state the timeline names, in the timeline's order. */
110
+ states: StateRef[];
111
+ renderConfig: PublishedRenderConfig;
112
+ };
113
+ /** One clip in a publish: its last check, and its recording if it passed. */
114
+ export type ClipManifest = {
115
+ id: string;
116
+ title: string;
117
+ check: CheckResult;
118
+ /** Present when the check passed and the recording is complete. */
119
+ recording: RecordingManifest | null;
120
+ /** The SHA-256 of `failure.png`, when the check failed and left one. */
121
+ failureHash: string | null;
122
+ };
123
+ /**
124
+ * The first request of a publish.
125
+ *
126
+ * With a GitHub OpenID Connect token, the repository, commit, ref and run
127
+ * come from the token and these fields are checked against it: what the
128
+ * player says a clip was recorded against is GitHub's word, not the CLI's.
129
+ * With a project token they are the only word there is.
130
+ */
131
+ export type PublishBegin = {
132
+ protocol: typeof PUBLISH_PROTOCOL;
133
+ /** The versioncam version that made the recordings. */
134
+ recorder: string;
135
+ /** `owner/repo`. Optional with a project token, which names its project. */
136
+ project: string | null;
137
+ commit: string;
138
+ branch: string;
139
+ ref: string;
140
+ runId: string | null;
141
+ clips: ClipManifest[];
142
+ };
143
+ /** A clip the service will not take a recording of, and why, in a sentence. */
144
+ export type Refusal = {
145
+ clip: string;
146
+ reason: string;
147
+ };
148
+ export type PublishBeginResponse = {
149
+ publishId: string;
150
+ /** `owner/repo`, as the service knows the project. */
151
+ project: string;
152
+ needStates: string[];
153
+ needTimelines: string[];
154
+ needTargets: string[];
155
+ needFailures: string[];
156
+ /**
157
+ * Recordings refused, by a cap or a plan. Their check results are recorded
158
+ * all the same: a limit gates storage and renders, never the truth.
159
+ */
160
+ refused: Refusal[];
161
+ };
162
+ /** What a publish made, per clip. */
163
+ export type PublishedVersion = {
164
+ clip: string;
165
+ version: string;
166
+ /** The living URL of the clip on this branch. */
167
+ url: string;
168
+ /** This version's own page. */
169
+ versionUrl: string;
170
+ check: "pass" | "fail";
171
+ /** Nothing about how it looks changed since the version before it. */
172
+ unchanged: boolean;
173
+ /** The clip is stale on the default branch after this publish. */
174
+ stale: boolean;
175
+ /** Why its recording was not stored, when it was not. */
176
+ refused: string | null;
177
+ };
178
+ export type PublishCommitResponse = {
179
+ project: string;
180
+ versions: PublishedVersion[];
181
+ };
182
+ /** Every error the service answers with has a sentence meant for a person. */
183
+ export type PublishErrorBody = {
184
+ error: string;
185
+ code: string;
186
+ };
187
+ /** Paths, relative to the API's origin. */
188
+ export declare const PUBLISH_PATHS: {
189
+ readonly begin: "/v1/publish/begin";
190
+ readonly state: (id: string, hash: string) => string;
191
+ readonly timeline: (id: string, clip: string) => string;
192
+ readonly targets: (id: string, clip: string) => string;
193
+ readonly failure: (id: string, clip: string) => string;
194
+ readonly commit: (id: string) => string;
195
+ };
196
+ /**
197
+ * The timeline as its content hash sees it: without when it was recorded or
198
+ * which commit of the app it ran against, which differ on every run and say
199
+ * nothing about what the recording shows.
200
+ */
201
+ export declare function contentOfTimeline(timeline: {
202
+ meta: Record<string, unknown>;
203
+ }): unknown;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The publish protocol: what `versioncam publish` sends to version.cam, and
3
+ * what comes back.
4
+ *
5
+ * Both ends import this file. The recorder builds a publish from it and the
6
+ * service validates one against it, through `versioncam/publish`, an entry
7
+ * that pulls in nothing that needs Node. A field added on one side is a type
8
+ * error on the other until both agree, which is the only kind of protocol
9
+ * drift that cannot reach production.
10
+ *
11
+ * The shape follows what a recording is on disk. A recording is a timeline
12
+ * plus a directory of distinct states, so a publish is a timeline plus the
13
+ * states' content hashes: the service asks for the states it has never seen,
14
+ * and a version that changed one screen uploads one screen.
15
+ */
16
+ /** Bumped when either side could misread the other. */
17
+ export const PUBLISH_PROTOCOL = 1;
18
+ /** Where the service is, unless `VERSIONCAM_API` says otherwise. */
19
+ export const DEFAULT_API = "https://api.version.cam";
20
+ /**
21
+ * The audience a GitHub OpenID Connect token must be minted for. The Action
22
+ * asks for exactly this, and the service refuses any other, so a token
23
+ * minted for some other service cannot be replayed here.
24
+ */
25
+ export const OIDC_AUDIENCE = "version.cam";
26
+ /**
27
+ * Limits that hold on every plan. The service enforces them; they are here so
28
+ * the client can say which one a recording breaks before uploading anything.
29
+ */
30
+ export const HARD_CAPS = {
31
+ statesPerVersion: 400,
32
+ stateBytes: 4 * 1024 * 1024,
33
+ timelineBytes: 2 * 1024 * 1024,
34
+ targetsBytes: 256 * 1024,
35
+ failureBytes: 4 * 1024 * 1024,
36
+ publishesPerHour: 20,
37
+ };
38
+ /** Paths, relative to the API's origin. */
39
+ export const PUBLISH_PATHS = {
40
+ begin: "/v1/publish/begin",
41
+ state: (id, hash) => `/v1/publish/${id}/state/${hash}`,
42
+ timeline: (id, clip) => `/v1/publish/${id}/timeline/${encodeURIComponent(clip)}`,
43
+ targets: (id, clip) => `/v1/publish/${id}/targets/${encodeURIComponent(clip)}`,
44
+ failure: (id, clip) => `/v1/publish/${id}/failure/${encodeURIComponent(clip)}`,
45
+ commit: (id) => `/v1/publish/${id}/commit`,
46
+ };
47
+ /**
48
+ * The timeline as its content hash sees it: without when it was recorded or
49
+ * which commit of the app it ran against, which differ on every run and say
50
+ * nothing about what the recording shows.
51
+ */
52
+ export function contentOfTimeline(timeline) {
53
+ const { recordedAt: _recordedAt, appCommit: _appCommit, ...meta } = timeline.meta;
54
+ return { ...timeline, meta };
55
+ }
56
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../src/publish/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,uDAAuD;AACvD,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAElC,oEAAoE;AACpE,MAAM,CAAC,MAAM,WAAW,GAAG,yBAAyB,CAAC;AAErD;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,aAAa,CAAC;AAE3C;;;GAGG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,gBAAgB,EAAE,GAAG;IACrB,UAAU,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI;IAC3B,aAAa,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI;IAC9B,YAAY,EAAE,GAAG,GAAG,IAAI;IACxB,YAAY,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI;IAC7B,gBAAgB,EAAE,EAAE;CACZ,CAAC;AAoJX,2CAA2C;AAC3C,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,KAAK,EAAE,mBAAmB;IAC1B,KAAK,EAAE,CAAC,EAAU,EAAE,IAAY,EAAE,EAAE,CAAC,eAAe,EAAE,UAAU,IAAI,EAAE;IACtE,QAAQ,EAAE,CAAC,EAAU,EAAE,IAAY,EAAE,EAAE,CACrC,eAAe,EAAE,aAAa,kBAAkB,CAAC,IAAI,CAAC,EAAE;IAC1D,OAAO,EAAE,CAAC,EAAU,EAAE,IAAY,EAAE,EAAE,CACpC,eAAe,EAAE,YAAY,kBAAkB,CAAC,IAAI,CAAC,EAAE;IACzD,OAAO,EAAE,CAAC,EAAU,EAAE,IAAY,EAAE,EAAE,CACpC,eAAe,EAAE,YAAY,kBAAkB,CAAC,IAAI,CAAC,EAAE;IACzD,MAAM,EAAE,CAAC,EAAU,EAAE,EAAE,CAAC,eAAe,EAAE,SAAS;CAC1C,CAAC;AAEX;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,QAEjC;IACC,MAAM,EACJ,UAAU,EAAE,WAAW,EACvB,SAAS,EAAE,UAAU,EACrB,GAAG,IAAI,EACR,GAAG,QAAQ,CAAC,IAAI,CAAC;IAClB,OAAO,EAAE,GAAG,QAAQ,EAAE,IAAI,EAAE,CAAC;AAC/B,CAAC"}
@@ -5,6 +5,19 @@ export type EncodeResult = {
5
5
  webm: string;
6
6
  poster: string;
7
7
  };
8
+ /**
9
+ * `-threads` as an output option, placed just before the output file: the
10
+ * same flag before `-i` would set the decoder's threads instead, which change
11
+ * nothing about the bytes written.
12
+ */
13
+ export declare function threadArgs(threads: number | null | undefined): string[];
14
+ /**
15
+ * Output options that make a file's bytes depend only on its frames. Without
16
+ * `bitexact` the WebM muxer writes a random segment id into every file, so no
17
+ * two renders of one recording were ever the same bytes, and both muxers
18
+ * stamp the file with the ffmpeg build that wrote it.
19
+ */
20
+ export declare const REPRODUCIBLE: readonly string[];
8
21
  export type EncodeOptions = {
9
22
  id: string;
10
23
  fps: number;
@@ -12,11 +25,20 @@ export type EncodeOptions = {
12
25
  /** Frame used for the poster image; defaults to a third of the way in. */
13
26
  posterFrame?: number;
14
27
  outDir: string;
28
+ /** Threads per encoder; null or absent leaves it to ffmpeg. */
29
+ threads?: number | null;
15
30
  };
16
31
  /**
17
32
  * Walk the render page frame by frame, screenshotting each one into both
18
33
  * encoders. The page is a pure function of the frame number, so this loop can
19
34
  * be re-run, interrupted or parallelised without changing its output.
35
+ *
36
+ * Nothing the last good render left is touched until this one has finished.
37
+ * The clip, its webm and its poster are written beside them, as
38
+ * `.partial` files, and moved over them only once both encoders have
39
+ * exited cleanly. Written in place, a scene that threw at frame 30 of 60
40
+ * left a playable half-clip, and the failed run's poster, where the last
41
+ * good render had been.
20
42
  */
21
43
  export declare function encodeFromPage(page: Page, options: EncodeOptions): Promise<EncodeResult>;
22
44
  /** A strip of frames, for reviewing a clip in a pull request. */
@@ -24,7 +46,11 @@ export declare function contactSheet(page: Page, options: {
24
46
  id: string;
25
47
  durationFrames: number;
26
48
  count?: number;
27
- /** Exactly which frames to show. Evenly spaced when absent. */
49
+ /**
50
+ * Exactly which frames to show. Evenly spaced when absent, which suits a
51
+ * scene; a recording's come from `evenFrames`, which knows where its
52
+ * dissolves are.
53
+ */
28
54
  frames?: number[];
29
55
  outDir: string;
30
56
  }): Promise<string>;
@@ -1,16 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { ffmpegPath } from "./ffmpeg.js";
5
5
  import { uniformFrames } from "./sampling.js";
6
6
  /**
7
- * Pipe PNG frames into ffmpeg twice — once for H.264, once for VP9.
7
+ * Pipe PNG frames into ffmpeg twice: once for H.264, once for VP9.
8
8
  *
9
9
  * Two encoders rather than a transcode because both come from the same
10
10
  * lossless frames; transcoding the mp4 into a webm would compound artefacts
11
11
  * for no saving worth having on a ten-second clip.
12
12
  */
13
- function startEncoder(args) {
13
+ function startEncoder(args, what) {
14
14
  const child = spawn(ffmpegPath(), args, {
15
15
  stdio: ["pipe", "ignore", "pipe"],
16
16
  });
@@ -18,19 +18,49 @@ function startEncoder(args) {
18
18
  child.stderr?.on("data", (chunk) => {
19
19
  stderr += chunk.toString();
20
20
  });
21
- child.on("error", (error) => {
22
- throw new Error(`ffmpeg could not be started (${error.message}). Install it, or add ` +
23
- "the optional ffmpeg-static dependency.");
24
- });
25
- child.on("close", (code) => {
26
- if (code !== 0) {
27
- console.error(stderr.split("\n").slice(-25).join("\n"));
28
- throw new Error(`ffmpeg exited with ${code}`);
29
- }
21
+ // A write to an encoder that has exited fails with EPIPE. The write's own
22
+ // callback and `done` both report it; left unhandled here, it would crash.
23
+ child.stdin?.on("error", () => { });
24
+ const done = new Promise((resolveDone, rejectDone) => {
25
+ child.once("error", (error) => rejectDone(new Error(`ffmpeg could not be started (${error.message}). Install it, or add ` +
26
+ "the optional ffmpeg-static dependency.")));
27
+ child.once("close", (code, signal) => {
28
+ if (code === 0)
29
+ return resolveDone();
30
+ const how = signal ? `was stopped (${signal})` : `exited with ${code}`;
31
+ const tail = stderr.split("\n").slice(-25).join("\n");
32
+ rejectDone(new Error(`ffmpeg (${what}) ${how}:\n${tail}`));
33
+ });
30
34
  });
31
- return child;
35
+ // Whoever finishes the encode awaits `done`. A render stopped early only
36
+ // waits for it to settle, and must not leave it an unhandled rejection.
37
+ done.catch(() => { });
38
+ return { child, done };
39
+ }
40
+ /**
41
+ * Where `name.ext` is written until it is whole: `name.partial.ext`, the
42
+ * sequence's convention. ffmpeg picks its muxer from the extension, so the
43
+ * extension stays last.
44
+ */
45
+ function partial(file) {
46
+ return file.replace(/(\.[^./]+)$/, ".partial$1");
47
+ }
48
+ /**
49
+ * `-threads` as an output option, placed just before the output file: the
50
+ * same flag before `-i` would set the decoder's threads instead, which change
51
+ * nothing about the bytes written.
52
+ */
53
+ export function threadArgs(threads) {
54
+ return threads ? ["-threads", String(threads)] : [];
32
55
  }
33
- function mp4Args(fps, out) {
56
+ /**
57
+ * Output options that make a file's bytes depend only on its frames. Without
58
+ * `bitexact` the WebM muxer writes a random segment id into every file, so no
59
+ * two renders of one recording were ever the same bytes, and both muxers
60
+ * stamp the file with the ffmpeg build that wrote it.
61
+ */
62
+ export const REPRODUCIBLE = ["-fflags", "+bitexact"];
63
+ function mp4Args(fps, out, threads) {
34
64
  return [
35
65
  "-y",
36
66
  "-f",
@@ -55,10 +85,12 @@ function mp4Args(fps, out) {
55
85
  // Lets a browser start playing before the whole file has downloaded.
56
86
  "-movflags",
57
87
  "+faststart",
88
+ ...REPRODUCIBLE,
89
+ ...threadArgs(threads),
58
90
  out,
59
91
  ];
60
92
  }
61
- function webmArgs(fps, out) {
93
+ function webmArgs(fps, out, threads) {
62
94
  return [
63
95
  "-y",
64
96
  "-f",
@@ -78,6 +110,8 @@ function webmArgs(fps, out) {
78
110
  "0",
79
111
  "-row-mt",
80
112
  "1",
113
+ ...REPRODUCIBLE,
114
+ ...threadArgs(threads),
81
115
  out,
82
116
  ];
83
117
  }
@@ -93,18 +127,30 @@ function writeAll(stream, buffer) {
93
127
  stream.once("drain", () => resolveWrite());
94
128
  });
95
129
  }
96
- function closeEncoder(child) {
97
- return new Promise((resolveClose, rejectClose) => {
98
- child.on("close", (code) => code === 0
99
- ? resolveClose()
100
- : rejectClose(new Error(`ffmpeg exited ${code}`)));
101
- child.stdin?.end();
102
- });
130
+ /**
131
+ * One frame into one encoder. An encoder that exits early fails the write
132
+ * with ffmpeg's own account of why, rather than leaving it waiting on a
133
+ * `drain` that will never come.
134
+ */
135
+ function feed(encoder, buffer) {
136
+ return Promise.race([
137
+ writeAll(encoder.child.stdin, buffer),
138
+ encoder.done.then(() => {
139
+ throw new Error("ffmpeg finished before the last frame was sent");
140
+ }),
141
+ ]);
103
142
  }
104
143
  /**
105
144
  * Walk the render page frame by frame, screenshotting each one into both
106
145
  * encoders. The page is a pure function of the frame number, so this loop can
107
146
  * be re-run, interrupted or parallelised without changing its output.
147
+ *
148
+ * Nothing the last good render left is touched until this one has finished.
149
+ * The clip, its webm and its poster are written beside them, as
150
+ * `.partial` files, and moved over them only once both encoders have
151
+ * exited cleanly. Written in place, a scene that threw at frame 30 of 60
152
+ * left a playable half-clip, and the failed run's poster, where the last
153
+ * good render had been.
108
154
  */
109
155
  export async function encodeFromPage(page, options) {
110
156
  const outDir = options.outDir;
@@ -113,21 +159,47 @@ export async function encodeFromPage(page, options) {
113
159
  const webm = join(outDir, `${options.id}.webm`);
114
160
  const poster = join(outDir, `${options.id}-poster.png`);
115
161
  const posterFrame = options.posterFrame ?? Math.floor(options.durationFrames / 3);
116
- const h264 = startEncoder(mp4Args(options.fps, mp4));
117
- const vp9 = startEncoder(webmArgs(options.fps, webm));
118
- const stage = page.locator("#stage");
119
- for (let frame = 0; frame < options.durationFrames; frame += 1) {
120
- await page.evaluate((n) => window.__render?.setFrame(n), frame);
121
- const buffer = await stage.screenshot({
122
- type: "png",
123
- animations: "disabled",
124
- });
125
- if (frame === posterFrame)
126
- writeFileSync(poster, buffer);
127
- await writeAll(h264.stdin, buffer);
128
- await writeAll(vp9.stdin, buffer);
162
+ const outputs = [mp4, webm, poster];
163
+ const encoders = [
164
+ startEncoder(mp4Args(options.fps, partial(mp4), options.threads), "H.264"),
165
+ startEncoder(webmArgs(options.fps, partial(webm), options.threads), "VP9"),
166
+ ];
167
+ try {
168
+ const stage = page.locator("#stage");
169
+ for (let frame = 0; frame < options.durationFrames; frame += 1) {
170
+ await page.evaluate((n) => window.__render?.setFrame(n), frame);
171
+ const buffer = await stage.screenshot({
172
+ type: "png",
173
+ animations: "disabled",
174
+ });
175
+ if (frame === posterFrame)
176
+ writeFileSync(partial(poster), buffer);
177
+ for (const encoder of encoders)
178
+ await feed(encoder, buffer);
179
+ }
180
+ for (const encoder of encoders)
181
+ encoder.child.stdin?.end();
182
+ await Promise.all(encoders.map((encoder) => encoder.done));
183
+ }
184
+ catch (error) {
185
+ // Both encoders are waiting for frames that will never come. Left
186
+ // running, they held the process open after the error was reported:
187
+ // `render` said what had failed and then never exited, which in CI is a
188
+ // job that hangs until it is killed.
189
+ for (const encoder of encoders) {
190
+ encoder.child.stdin?.destroy();
191
+ encoder.child.kill("SIGKILL");
192
+ }
193
+ await Promise.allSettled(encoders.map((encoder) => encoder.done));
194
+ for (const file of outputs)
195
+ rmSync(partial(file), { force: true });
196
+ throw error;
197
+ }
198
+ for (const file of outputs) {
199
+ // No poster when the poster frame is past the end: the last one stays.
200
+ if (existsSync(partial(file)))
201
+ renameSync(partial(file), file);
129
202
  }
130
- await Promise.all([closeEncoder(h264), closeEncoder(vp9)]);
131
203
  return { frames: options.durationFrames, mp4, webm, poster };
132
204
  }
133
205
  /** A strip of frames, for reviewing a clip in a pull request. */
@@ -165,19 +237,25 @@ export async function contactSheet(page, options) {
165
237
  `scale=960:-1,tile=${columns}x${rows}`,
166
238
  "-frames:v",
167
239
  "1",
168
- sheet,
240
+ partial(sheet),
169
241
  ], { stdio: ["ignore", "ignore", "pipe"] });
170
242
  let stderr = "";
171
243
  child.stderr.on("data", (c) => {
172
244
  stderr += c.toString();
173
245
  });
246
+ child.once("error", (error) => rejectSheet(new Error(`ffmpeg could not be started (${error.message})`)));
174
247
  child.on("close", (code) => {
175
248
  if (code === 0)
176
249
  resolveSheet();
177
250
  else
178
251
  rejectSheet(new Error(`contact sheet failed:\n${stderr.slice(-800)}`));
179
252
  });
253
+ }).catch((error) => {
254
+ rmSync(partial(sheet), { force: true });
255
+ throw error;
180
256
  });
257
+ // Like the clip: the last good sheet stays until this one is whole.
258
+ renameSync(partial(sheet), sheet);
181
259
  return sheet;
182
260
  }
183
261
  //# sourceMappingURL=encode.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"encode.js","sourceRoot":"","sources":["../../src/render/encode.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"encode.js","sourceRoot":"","sources":["../../src/render/encode.ts"],"names":[],"mappings":"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"}
@@ -17,3 +17,15 @@ export declare function frameAreaFor(canvas: {
17
17
  }, presentation: Presentation | null): Rect;
18
18
  /** Whether the caption sits under the app or over it. */
19
19
  export declare function captionPlacement(presentation: Presentation | null): "below" | "overlay" | "none";
20
+ /**
21
+ * Where an overlay caption's text is anchored, and which way it runs from
22
+ * there: from `x` rightwards, or leftwards to it.
23
+ *
24
+ * Measured from the video's edge rather than the app's, so the default is
25
+ * exactly where the caption sat before it could be moved, padded or not:
26
+ * 40 pixels in from the left.
27
+ */
28
+ export declare function overlayCaptionAnchor(presentation: Presentation | null, canvasWidth: number): {
29
+ x: number;
30
+ align: "left" | "right";
31
+ };
@@ -24,4 +24,19 @@ export function captionPlacement(presentation) {
24
24
  // "auto": there is only room below the app if something made room.
25
25
  return (presentation?.padding ?? 0) > 0 ? "below" : "overlay";
26
26
  }
27
+ /**
28
+ * Where an overlay caption's text is anchored, and which way it runs from
29
+ * there: from `x` rightwards, or leftwards to it.
30
+ *
31
+ * Measured from the video's edge rather than the app's, so the default is
32
+ * exactly where the caption sat before it could be moved, padded or not:
33
+ * 40 pixels in from the left.
34
+ */
35
+ export function overlayCaptionAnchor(presentation, canvasWidth) {
36
+ const inset = presentation?.captionInset;
37
+ if (inset?.right !== undefined) {
38
+ return { x: canvasWidth - inset.right, align: "right" };
39
+ }
40
+ return { x: inset?.left ?? 40, align: "left" };
41
+ }
27
42
  //# sourceMappingURL=presentation.js.map
@@ -1 +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"}
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;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAClC,YAAiC,EACjC,WAAmB;IAEnB,MAAM,KAAK,GAAG,YAAY,EAAE,YAAY,CAAC;IACzC,IAAI,KAAK,EAAE,KAAK,KAAK,SAAS,EAAE,CAAC;QAC/B,OAAO,EAAE,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;IAC1D,CAAC;IACD,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;AACjD,CAAC"}