@parity/product-lab 0.1.1 → 0.2.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 (76) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +27 -0
  3. package/adapters/browser/playwright/README.md +2 -0
  4. package/adapters/browser/playwright/src/page-frame-capture.js +10 -2
  5. package/adapters/chain/papi/README.md +3 -1
  6. package/adapters/environment/engine/README.md +22 -4
  7. package/adapters/environment/engine/src/engine/engine-config.d.ts +1 -0
  8. package/adapters/environment/engine/src/engine/fork-bundle.d.ts +1 -1
  9. package/adapters/environment/engine/src/engine/fork-bundle.js +2 -2
  10. package/adapters/environment/engine/src/engine/fork-refresh.d.ts +4 -7
  11. package/adapters/environment/engine/src/engine/fork-refresh.js +52 -23
  12. package/adapters/environment/engine/src/engine/published-bundle.d.ts +19 -15
  13. package/adapters/environment/engine/src/engine/published-bundle.js +44 -51
  14. package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.d.ts +1 -1
  15. package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.js +5 -2
  16. package/adapters/environment/engine/src/saved-fork/saved-fork.js +1 -1
  17. package/adapters/environment/engine/src/setup/engine-build.d.ts +4 -4
  18. package/adapters/environment/engine/src/setup/engine-build.js +14 -4
  19. package/adapters/environment/engine/src/setup/patch-catalogue.d.ts +6 -0
  20. package/adapters/environment/engine/src/setup/patch-catalogue.js +5 -0
  21. package/adapters/environment/engine/src/setup/prepare-engine.d.ts +6 -5
  22. package/adapters/environment/engine/src/setup/prepare-engine.js +27 -10
  23. package/adapters/features/cucumber/README.md +4 -0
  24. package/adapters/features/cucumber/src/steps/register-feature-steps.js +15 -1
  25. package/adapters/system/node/README.md +3 -3
  26. package/adapters/video/ffmpeg/README.md +4 -0
  27. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.d.ts +1 -1
  28. package/adapters/video/ffmpeg/src/ffmpeg-video-editor.js +19 -6
  29. package/core/application/README.md +30 -1
  30. package/core/application/src/features/direct-feature-steps.d.ts +23 -0
  31. package/core/application/src/features/direct-feature-steps.js +25 -0
  32. package/core/application/src/features/feature-recording.d.ts +6 -0
  33. package/core/application/src/features/feature-recording.js +3 -0
  34. package/core/application/src/features/feature-scenario.d.ts +10 -8
  35. package/core/application/src/features/feature-scenario.js +11 -15
  36. package/core/application/src/features/feature-step-times.d.ts +11 -1
  37. package/core/application/src/features/feature-step-times.js +20 -6
  38. package/core/application/src/features/lab-feature-session.d.ts +3 -1
  39. package/core/application/src/features/lab-feature-session.js +5 -2
  40. package/core/application/src/features/run-feature-steps.d.ts +3 -1
  41. package/core/application/src/features/run-feature-steps.js +4 -2
  42. package/core/application/src/lab-runtime-use-cases.js +5 -1
  43. package/core/application/src/recordings/demo-markup.d.ts +8 -0
  44. package/core/application/src/recordings/demo-markup.js +11 -1
  45. package/core/application/src/recordings/demo-recording.d.ts +0 -2
  46. package/core/application/src/recordings/demo-recording.js +40 -23
  47. package/core/application/src/recordings/frame-sources.d.ts +20 -1
  48. package/core/application/src/recordings/frame-sources.js +89 -0
  49. package/core/application/src/recordings/record-runs.d.ts +7 -1
  50. package/core/application/src/recordings/record-runs.js +66 -11
  51. package/core/application/src/recordings/recording-draft.d.ts +4 -0
  52. package/core/application/src/recordings/recording-draft.js +13 -4
  53. package/core/domain/README.md +14 -0
  54. package/core/domain/src/recordings/demo-cut.js +6 -4
  55. package/core/domain/src/recordings/demo-holds.d.ts +12 -0
  56. package/core/domain/src/recordings/demo-holds.js +31 -0
  57. package/core/domain/src/recordings/index.d.ts +1 -0
  58. package/core/domain/src/recordings/index.js +1 -0
  59. package/core/domain/src/recordings/recording-data.d.ts +9 -1
  60. package/core/domain/src/recordings/recording-settings.d.ts +5 -1
  61. package/core/domain/src/recordings/recording-settings.js +7 -0
  62. package/core/domain/src/recordings/run-recording.d.ts +2 -0
  63. package/core/domain/src/recordings/run-recording.js +8 -1
  64. package/core/ports/README.md +6 -0
  65. package/core/ports/src/driven/recording/demo-ports.d.ts +2 -0
  66. package/core/ports/src/driven/recording/frame-capture.d.ts +4 -0
  67. package/core/ports/src/driving/demo.d.ts +2 -0
  68. package/core/ports/src/driving/feature-session.d.ts +7 -0
  69. package/entrypoints/cli/README.md +6 -3
  70. package/entrypoints/cli/src/demo-command.d.ts +1 -1
  71. package/entrypoints/cli/src/demo-command.js +10 -5
  72. package/entrypoints/mcp/README.md +3 -0
  73. package/entrypoints/panel/README.md +6 -3
  74. package/entrypoints/panel/src/visible/detached-identities.js +5 -1
  75. package/entrypoints/panel/src/visible/window-frames.js +14 -1
  76. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  // Copyright 2026 Parity Technologies (UK) Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ import { createHash } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
3
5
  import { join } from "node:path";
4
- import { createGunzip, constants as zlib } from "node:zlib";
5
6
  import { forkBundleManifestPath, forkSnapshotFrom } from "./fork-snapshot.js";
6
7
  import { BUNDLE_MARKER, markForkBundle, verifyForkBundle } from "./fork-bundle.js";
7
8
  export const MANIFEST_SEARCH_BYTES = 48 * 1024 * 1024;
@@ -92,65 +93,23 @@ export async function publishedBundle(input) {
92
93
  if (!response.ok)
93
94
  throw new Error(`GitHub answered ${response.status} for ${repository}@${tag}`);
94
95
  const release = (await response.json());
96
+ if (!release || typeof release !== "object")
97
+ throw new Error(`${repository}@${tag} release response is not an object`);
95
98
  const found = release.assets?.find((entry) => entry.name === asset);
96
99
  if (!found)
97
100
  throw new Error(`${repository}@${tag} publishes no ${asset}`);
98
- const manifest = await manifestFromStream(found.browser_download_url, input.fetch, signal);
101
+ if (typeof found.digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(found.digest))
102
+ throw new Error(`${repository}@${tag} ${asset} requires a sha256: digest with 64 lowercase hex digits from GitHub`);
99
103
  return {
100
104
  repository,
101
105
  tag,
102
106
  asset,
103
107
  url: found.browser_download_url,
104
108
  size: found.size,
105
- ...(found.digest ? { digest: found.digest } : {}),
109
+ digest: found.digest,
106
110
  updatedAt: found.updated_at,
107
- snapshot: forkSnapshotFrom(manifest),
108
111
  };
109
112
  }
110
- async function manifestFromStream(url, fetchImpl, signal) {
111
- const stop = new AbortController();
112
- const response = await fetchImpl(url, {
113
- headers: { range: `bytes=0-${MANIFEST_SEARCH_BYTES - 1}`, "user-agent": "product-lab" },
114
- signal: signal ? AbortSignal.any([signal, stop.signal]) : stop.signal,
115
- });
116
- if (!response.ok || !response.body)
117
- throw new Error(`the bundle download answered ${response.status}`);
118
- const gunzip = createGunzip({ finishFlush: zlib.Z_SYNC_FLUSH });
119
- const scanner = new TarManifestScanner();
120
- let found;
121
- let failure;
122
- gunzip.on("data", (chunk) => {
123
- if (found !== undefined || failure !== undefined)
124
- return;
125
- try {
126
- found = scanner.push(chunk);
127
- }
128
- catch (error) {
129
- failure = error;
130
- }
131
- });
132
- const reader = response.body.getReader();
133
- try {
134
- while (found === undefined && failure === undefined) {
135
- const { done, value } = await reader.read();
136
- if (value)
137
- await new Promise((resolve, reject) => gunzip.write(value, (error) => (error ? reject(error) : resolve())));
138
- await new Promise((resolve) => gunzip.flush(zlib.Z_SYNC_FLUSH, () => resolve()));
139
- if (done)
140
- break;
141
- }
142
- }
143
- finally {
144
- stop.abort();
145
- gunzip.destroy();
146
- await reader.cancel().catch(() => undefined);
147
- }
148
- if (failure !== undefined)
149
- throw failure;
150
- if (found === undefined)
151
- throw new Error(`no manifest.json in the bundle's first ${MANIFEST_SEARCH_BYTES / 1024 / 1024} MB`);
152
- return found;
153
- }
154
113
  export function bundleLocations(workspace, network) {
155
114
  const current = join(workspace, `fork-bundle-${network}`);
156
115
  return { current, next: `${current}.next`, partial: `${current}.next.partial` };
@@ -174,6 +133,17 @@ export function currentBundle(files, workspace, network) {
174
133
  const directory = bundleLocations(workspace, network).current;
175
134
  return usableBundle(files, directory) ? forkSnapshotFrom(files.readJson(forkBundleManifestPath(workspace, network)), forkBundleManifestPath(workspace, network)) : undefined;
176
135
  }
136
+ export function bundleAssetDigest(files, directory) {
137
+ try {
138
+ const marker = files.readJson(join(directory, BUNDLE_MARKER));
139
+ return typeof marker?.assetDigest === "string" && /^sha256:[0-9a-f]{64}$/.test(marker.assetDigest) ? marker.assetDigest : undefined;
140
+ }
141
+ catch (error) {
142
+ if (error instanceof SyntaxError)
143
+ return undefined;
144
+ throw error;
145
+ }
146
+ }
177
147
  export function bittenAfter(a, b) {
178
148
  if (!a || !b)
179
149
  return false;
@@ -200,14 +170,29 @@ export function startPrefetch(input) {
200
170
  env: { ...input.environment.all(), PPN_HOME: input.engine.workspace, PPN_NETWORK: input.network, PATH: path },
201
171
  });
202
172
  }
203
- export async function finishPrefetch(files, workspace, network, signal) {
204
- const { partial } = bundleLocations(workspace, network);
173
+ export async function finishPrefetch(files, workspace, network, signal, assetDigest) {
174
+ const { partial, next, current } = bundleLocations(workspace, network);
205
175
  const check = await verifyForkBundle(partial, signal);
206
176
  if (!check.complete) {
207
177
  files.remove(partial);
208
178
  throw new Error(`the download in ${partial} is not a complete bundle (${check.problem}); it was removed`);
209
179
  }
210
- markForkBundle(partial, check.snapshots, "download");
180
+ markForkBundle(partial, check.snapshots, "download", assetDigest);
181
+ if (assetDigest) {
182
+ const downloaded = files.readJson(join(partial, BUNDLE_MARKER));
183
+ for (const directory of [next, current]) {
184
+ if (!usableBundle(files, directory))
185
+ continue;
186
+ if (bundleAssetDigest(files, directory) === assetDigest)
187
+ continue;
188
+ const manifestSha256 = createHash("sha256").update(readFileSync(join(directory, "manifest.json"))).digest("hex");
189
+ if (manifestSha256 !== downloaded.manifestSha256)
190
+ continue;
191
+ const local = await verifyForkBundle(directory, signal);
192
+ if (local.complete)
193
+ markForkBundle(directory, local.snapshots, "verified in place", assetDigest);
194
+ }
195
+ }
211
196
  return completePrefetch(files, workspace, network);
212
197
  }
213
198
  export function completePrefetch(files, workspace, network) {
@@ -215,6 +200,14 @@ export function completePrefetch(files, workspace, network) {
215
200
  if (!verifiedBundle(files, partial))
216
201
  throw new Error(`the download in ${partial} is not a verified bundle (no manifest.json, no snapshots or not verified complete)`);
217
202
  const snapshot = forkSnapshotFrom(files.readJson(join(partial, "manifest.json")));
203
+ const staged = stagedBundle(files, workspace, network);
204
+ const installed = currentBundle(files, workspace, network);
205
+ const newest = [staged, installed].filter((entry) => !!entry)
206
+ .sort((a, b) => Date.parse(b.bittenAt) - Date.parse(a.bittenAt))[0];
207
+ if (newest && !bittenAfter(snapshot, newest)) {
208
+ files.remove(partial);
209
+ return newest;
210
+ }
218
211
  files.remove(next);
219
212
  if (!files.rename(partial, next))
220
213
  throw new Error(`the completed bundle disappeared from ${partial}`);
@@ -9,7 +9,7 @@ export declare const savedForkEngineState: (cacheDir: string) => string;
9
9
  export declare const savedForkSetupCommand: (fork: SavedFork, settings: Pick<SavedForkSettings, "setupCommand">) => string;
10
10
  /**
11
11
  * The prepared Engine a saved fork starts from, or `EngineNotPrepared` naming the command that prepares it. A start
12
- * takes the recorded stock build (`as-prepared`), including an explicitly selected ref. Retired patched records
12
+ * takes the recorded stock or node-workspace build (`as-prepared`), including an explicitly selected ref. Retired owned-processes records
13
13
  * are refused. `ref`: when supplied, also check that commit.
14
14
  */
15
15
  export declare function savedForkEngine(fork: SavedFork, settings: EngineSettings, commands: SyncCommandRunner, patch?: PreparedEnginePatch, ref?: string): PreparedEngine;
@@ -13,9 +13,12 @@ export function engineBuildWords(engine) {
13
13
  const ref = build.resolvedRef.slice(0, 12);
14
14
  const from = build.repository && build.repository !== defaultEngineRepository ? ` from ${build.repository}` : "";
15
15
  const tested = build.resolvedRef === testedEngineRef ? "" : ` (not the tested ${testedEngineRef.slice(0, 12)}: \`product-lab fetch\` without --engine-ref prepares it)`;
16
- return recordedPatch(build) === "none"
16
+ const patch = recordedPatch(build);
17
+ return patch === "none"
17
18
  ? `stock Engine ${ref}${from}${tested}, ${engine.checkout}`
18
- : `retired patched Engine ${ref}${from}; prepare stock Engine again, ${engine.checkout}`;
19
+ : patch === "node-workspace"
20
+ ? `Engine ${ref}${from} with node-workspace patch, ${engine.checkout}`
21
+ : `retired patched Engine ${ref}${from}; prepare stock Engine again, ${engine.checkout}`;
19
22
  }
20
23
  function syncCommands(system) {
21
24
  if (!system.syncCommands)
@@ -231,7 +231,7 @@ export function savedForkEnvironment(fork, settings) {
231
231
  dashboard: `http://127.0.0.1:${ports.DASHBOARD_PORT}`, extraPath: engine.extraPath,
232
232
  startupTimeoutMs: SAVED_FORK_STARTUP_MS, ports, nodePorts: { from: block.base + 900, to: block.base + 949 },
233
233
  template: true, isolate: true, expose: false, ...(fork.topology ? { forkTopology: fork.topology } : {}),
234
- ...(recordedPatch(prepared.build) === "none" ? { enginePatch: "none" } : {}),
234
+ enginePatch: recordedPatch(prepared.build),
235
235
  ...(settings.txpoolLog ? { txpoolLog: settings.txpoolLog } : {}), ...(settings.bulletinAuthoring ? { bulletinAuthoring: settings.bulletinAuthoring } : {}),
236
236
  seed: networkSeedDirectory(settings.seedsDir, { network: NETWORK, checkout: engine.checkout, ...(fork.topology ? { topology: fork.topology } : {}) }),
237
237
  });
@@ -3,11 +3,11 @@ import type { SyncCommandRunner } from "@parity/product-lab/ports/system";
3
3
  export declare const NODE_VERIFIER_OPTION = "--node-verifier";
4
4
  export type NodeVerifier = "metric" | "none";
5
5
  export declare function nodeVerifierArgs(verifier: NodeVerifier | undefined): string[];
6
- /** The old value is recognized only to refuse stale configurations with a repair command. */
7
- export declare const ENGINE_PATCHES: readonly ["owned-processes", "none"];
6
+ /** Stock and the optional repository fix; owned-processes is recognized only to refuse stale configurations. */
7
+ export declare const ENGINE_PATCHES: readonly ["owned-processes", "none", "node-workspace"];
8
8
  export type EnginePatch = (typeof ENGINE_PATCHES)[number];
9
9
  export declare const ENGINE_PATCH_RETIRED = "the owned-processes Engine patch is retired; prepare stock Engine v0.11.0 instead";
10
10
  /** The build must exist. The prepared checkout's pin and record are checked by preparedEngine. */
11
- export declare function engineBuildIssue(checkout: string, _commands: SyncCommandRunner, patch?: EnginePatch): string | undefined;
12
- /** Before every start, refuse retired patched configurations and incomplete builds. */
11
+ export declare function engineBuildIssue(checkout: string, commands: SyncCommandRunner, patch?: EnginePatch): string | undefined;
12
+ /** Before every start, verify the optional patch and refuse retired configurations and incomplete builds. */
13
13
  export declare function requireEngineBuild(checkout: string, commands: SyncCommandRunner, patch?: EnginePatch): void;
@@ -2,19 +2,29 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { resolvePatch } from "./patches.js";
6
+ import { NODE_WORKSPACE_PATCH } from "./patch-catalogue.js";
5
7
  import { engineSetupCommand } from "./setup-command.js";
6
8
  export const NODE_VERIFIER_OPTION = "--node-verifier";
7
9
  export function nodeVerifierArgs(verifier) {
8
10
  return verifier ? [NODE_VERIFIER_OPTION, verifier] : [];
9
11
  }
10
- export const ENGINE_PATCHES = ["owned-processes", "none"];
12
+ export const ENGINE_PATCHES = ["owned-processes", "none", "node-workspace"];
11
13
  export const ENGINE_PATCH_RETIRED = "the owned-processes Engine patch is retired; prepare stock Engine v0.11.0 instead";
12
- export function engineBuildIssue(checkout, _commands, patch = "none") {
13
- if (patch !== "none")
14
+ export function engineBuildIssue(checkout, commands, patch = "none") {
15
+ if (patch !== "none" && patch !== "node-workspace")
14
16
  return ENGINE_PATCH_RETIRED;
17
+ if (patch === "node-workspace") {
18
+ const resolved = resolvePatch("previewnet-engine", patch);
19
+ if (resolved.sha256 !== NODE_WORKSPACE_PATCH.sha256 || resolved.base !== NODE_WORKSPACE_PATCH.base)
20
+ return "node-workspace patch differs from its tested catalogue";
21
+ const check = commands.run({ command: "git", args: ["apply", "--reverse", "--check", resolved.path], cwd: checkout, timeoutMs: 30_000 });
22
+ if (check.code !== 0)
23
+ return "node-workspace patch is missing or changed in the Engine checkout";
24
+ }
15
25
  for (const file of ["packages/cli/dist/commands/start.js", "packages/cli/dist/cli.js"])
16
26
  if (!existsSync(join(checkout, file)))
17
- return `the stock Engine checkout at ${checkout} is not built (no ${file})`;
27
+ return `the ${patch === "none" ? "stock" : "patched"} Engine checkout at ${checkout} is not built (no ${file})`;
18
28
  return undefined;
19
29
  }
20
30
  export function requireEngineBuild(checkout, commands, patch = "none") {
@@ -18,3 +18,9 @@ export declare const LIGHT_TOPOLOGY_OVERLAY: {
18
18
  };
19
19
  /** The descriptor overlays a fork layout names (`ForkLayout.descriptors`), by manifest entry. */
20
20
  export declare const DESCRIPTOR_OVERLAYS: Readonly<Record<string, unknown>>;
21
+ /** Optional repository-only fix; stock preparation remains the default. */
22
+ export declare const NODE_WORKSPACE_PATCH: {
23
+ readonly name: "node-workspace";
24
+ readonly base: "e11792b234ca0c39a5fc1d47af491f82fc8e2670";
25
+ readonly sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757";
26
+ };
@@ -13,3 +13,8 @@ export const OWNED_PROCESSES_PATCH = {
13
13
  };
14
14
  export const LIGHT_TOPOLOGY_OVERLAY = { relay: { validators: 4 } };
15
15
  export const DESCRIPTOR_OVERLAYS = { "light-topology": LIGHT_TOPOLOGY_OVERLAY };
16
+ export const NODE_WORKSPACE_PATCH = {
17
+ name: "node-workspace",
18
+ base: ENGINE_RELEASE.base,
19
+ sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757",
20
+ };
@@ -29,6 +29,7 @@ export interface EngineSource {
29
29
  /**
30
30
  * `none`: stock Engine at the ref, without the owned-processes patch (an Engine that carries the fixes upstream),
31
31
  * recorded as such (engine-build.json `patch: "none"`, an empty `patchSha256`; engine.json `enginePatch: "none"`).
32
+ * `node-workspace`: repository-only native-node workspace fix at the tested base.
32
33
  * Default `none`. The retired `owned-processes` value is refused.
33
34
  */
34
35
  readonly patch?: EnginePatch;
@@ -47,7 +48,7 @@ export interface EngineBuildRecord {
47
48
  repository?: string;
48
49
  /** `none`: stock Engine, without the owned-processes patch (absent in an older record: the patch). */
49
50
  patch?: EnginePatch;
50
- /** The owned-processes patch's digest; empty for a stock build. */
51
+ /** The selected patch's digest; empty for a stock build. */
51
52
  patchSha256: string;
52
53
  preparedAt: string;
53
54
  }
@@ -68,15 +69,15 @@ export interface PreparedEngine {
68
69
  export declare class EngineNotPrepared extends Error {
69
70
  readonly reason: string;
70
71
  readonly fix: string;
71
- /** The requested stock build, or a retired legacy choice. */
72
+ /** The requested build or a retired legacy choice. */
72
73
  readonly patch: EnginePatch;
73
74
  constructor(reason: string, fix: string,
74
- /** The requested stock build, or a retired legacy choice. */
75
+ /** The requested build or a retired legacy choice. */
75
76
  patch?: EnginePatch);
76
77
  }
77
- /** `as-prepared` accepts the recorded choice only when it is stock; legacy patched records are refused. */
78
+ /** `as-prepared` accepts stock or the current repository fix; legacy owned-processes records are refused. */
78
79
  export type PreparedEnginePatch = EnginePatch | "as-prepared";
79
- /** Check the record against the pin (or explicit ref), checkout HEAD, stock digest, build and dot CLI. */
80
+ /** Check the record against the pin (or explicit ref), checkout HEAD, selected digest, build and dot CLI. */
80
81
  export declare function preparedEngine(root: string, options: {
81
82
  stateDirectory?: string;
82
83
  fix: string;
@@ -4,7 +4,8 @@ import { createHash } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
6
  import { checkedSync } from "./sync-command.js";
7
- import { ENGINE_RELEASE } from "./patch-catalogue.js";
7
+ import { resolvePatch } from "./patches.js";
8
+ import { ENGINE_RELEASE, NODE_WORKSPACE_PATCH } from "./patch-catalogue.js";
8
9
  import { ENGINE_PATCH_RETIRED, engineBuildIssue } from "./engine-build.js";
9
10
  import { applyTurnRelayChoice, turnRelayAvailable } from "../engine/turn-relay.js";
10
11
  export const defaultStateDirectory = ".product-lab";
@@ -41,8 +42,12 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
41
42
  throw new Error("Network must be previewnet or paseo-next-v2");
42
43
  setupPaths(root, ref, "", stateDirectory);
43
44
  const repository = engineRepository(source);
44
- if (source.patch && source.patch !== "none")
45
+ if (source.patch && source.patch !== "none" && source.patch !== "node-workspace")
45
46
  throw new Error(ENGINE_PATCH_RETIRED);
47
+ const patch = source.patch ?? "none";
48
+ const patchFile = patch === "node-workspace" ? resolvePatch("previewnet-engine", patch) : undefined;
49
+ if (patchFile && (patchFile.sha256 !== NODE_WORKSPACE_PATCH.sha256 || patchFile.base !== NODE_WORKSPACE_PATCH.base))
50
+ throw new Error("node-workspace patch differs from its tested catalogue");
46
51
  const output = source.output ?? "inherit";
47
52
  const revisions = /^[0-9a-f]{40}$/i.test(ref)
48
53
  ? [[ref, ref]]
@@ -65,7 +70,9 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
65
70
  revisions[0])?.[0];
66
71
  if (!resolvedRef || !/^[0-9a-f]{40}$/i.test(resolvedRef))
67
72
  throw new Error("Could not resolve an immutable Engine revision");
68
- const paths = setupPaths(root, resolvedRef, "", stateDirectory);
73
+ if (patchFile && resolvedRef !== patchFile.base)
74
+ throw new Error("node-workspace patch requires its tested Engine base");
75
+ const paths = setupPaths(root, resolvedRef, patchFile?.sha256 ?? "", stateDirectory);
69
76
  mkdirSync(paths.base, { recursive: true });
70
77
  const run = (command, args, cwd = paths.checkout) => checkedSync(commands, { command, args, cwd, output, timeoutMs: 1_800_000 });
71
78
  if (!existsSync(join(paths.checkout, ".git"))) {
@@ -83,6 +90,13 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
83
90
  }
84
91
  else if (head !== resolvedRef)
85
92
  throw new Error("Prepared Engine checkout changed; refusing to overwrite it");
93
+ if (patchFile) {
94
+ const applied = commands.run({ command: "git", args: ["apply", "--reverse", "--check", patchFile.path], cwd: paths.checkout, timeoutMs: 30_000 });
95
+ if (applied.code !== 0) {
96
+ run("git", ["apply", "--check", patchFile.path]);
97
+ run("git", ["apply", patchFile.path]);
98
+ }
99
+ }
86
100
  const manifest = JSON.parse(readFileSync(join(paths.checkout, "package.json"), "utf8"));
87
101
  if (manifest.packageManager !== "pnpm@10.33.0")
88
102
  throw new Error("Engine package manager changed; review setup before updating its toolchain");
@@ -113,7 +127,7 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
113
127
  dashboard: "http://127.0.0.1:8090",
114
128
  extraPath: [dotBin],
115
129
  startupTimeoutMs: 1_800_000,
116
- enginePatch: "none",
130
+ enginePatch: patch,
117
131
  };
118
132
  const configFile = resolve(root, stateDirectory, "engine.json");
119
133
  writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
@@ -121,8 +135,8 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
121
135
  requestedRef: ref,
122
136
  resolvedRef,
123
137
  repository,
124
- patch: "none",
125
- patchSha256: "",
138
+ patch,
139
+ patchSha256: patchFile?.sha256 ?? "",
126
140
  preparedAt: new Date().toISOString(),
127
141
  }, null, 2) + "\n");
128
142
  return configFile;
@@ -163,16 +177,19 @@ export function preparedEngine(root, options) {
163
177
  throw refuse(prepared === "none"
164
178
  ? `${stateDirectory} holds stock Engine ${String(build.resolvedRef).slice(0, 12)} (prepared without the owned-processes patch), not the patched one`
165
179
  : `${stateDirectory} holds the patched Engine ${String(build.resolvedRef).slice(0, 12)}, not stock Engine`, wanted);
166
- if (wanted !== "none")
180
+ if (wanted !== "none" && wanted !== "node-workspace")
167
181
  throw refuse(ENGINE_PATCH_RETIRED, wanted);
168
182
  const ref = options.ref ?? (options.patch === "as-prepared" ? build.resolvedRef : testedEngineRef);
169
183
  if (!/^[0-9a-f]{40}$/i.test(build.resolvedRef))
170
184
  throw refuse(`${recordFile} has no immutable Engine revision`, wanted);
171
185
  if (/^[0-9a-f]{40}$/i.test(ref) && build.resolvedRef !== ref)
172
186
  throw refuse(`${stateDirectory} holds Engine ${String(build.resolvedRef).slice(0, 12)}, this checkout of product-lab tests ${ref.slice(0, 12)}`, wanted);
173
- if (build.patchSha256 !== "")
174
- throw refuse(`${recordFile} records a patch digest for stock Engine`, wanted);
175
- const paths = setupPaths(stateDirectory, build.resolvedRef, "", ".");
187
+ const digest = wanted === "node-workspace" ? NODE_WORKSPACE_PATCH.sha256 : "";
188
+ if (wanted === "node-workspace" && build.resolvedRef !== NODE_WORKSPACE_PATCH.base)
189
+ throw refuse("node-workspace patch requires its tested Engine base", wanted);
190
+ if (build.patchSha256 !== digest)
191
+ throw refuse(wanted === "none" ? `${recordFile} records a patch digest for stock Engine` : `${recordFile} records another node-workspace patch digest`, wanted);
192
+ const paths = setupPaths(stateDirectory, build.resolvedRef, digest, ".");
176
193
  const issue = existsSync(join(paths.checkout, ".git")) ? engineBuildIssue(paths.checkout, options.commands, wanted) : `there is no checkout at ${paths.checkout}`;
177
194
  if (issue)
178
195
  throw refuse(issue, wanted);
@@ -83,3 +83,7 @@ and the run report. `error` remains the original failure text, so explanation ru
83
83
  text-judged controls cannot match diagnostic words. Status and failure reason remain those of the original result. A normal
84
84
  settled step gets no extra attachment. `entrypoints/cli/tests/abort-diagnostics.test.ts` exercises
85
85
  this through a timed-out generated step.
86
+
87
+ ## Held feature demos
88
+
89
+ registerFeatureSteps calls the optional startStep/finishStep observers for direct/custom definitions. Generated steps time themselves. The recording AfterStep hook is registered after the pause hook because Cucumber runs AfterStep hooks in reverse order, so video recording's bounded after-state capture finishes before a pause. Default frames-only capture stays asynchronous. In-process tests cover this order and custom definitions.
@@ -156,8 +156,9 @@ export function registerFeatureSteps(session, options = {}) {
156
156
  });
157
157
  api.BeforeStep(function ({ pickleStep }) {
158
158
  const state = scenarios.get(this);
159
- if (state)
159
+ if (state) {
160
160
  state.current = state.ids.indexOf(pickleStep.id);
161
+ }
161
162
  });
162
163
  if (options.beforeStep) {
163
164
  const gate = options.beforeStep;
@@ -166,6 +167,10 @@ export function registerFeatureSteps(session, options = {}) {
166
167
  await gated(gate({ scenario: pickle.name, index: state ? state.ids.indexOf(pickleStep.id) : -1, text: pickleStep.text, status: "pending" }));
167
168
  });
168
169
  }
170
+ api.BeforeStep(function () {
171
+ const state = scenarios.get(this);
172
+ state?.scenario.startStep?.(state.current);
173
+ });
169
174
  for (const view of sentences) {
170
175
  const body = async function () {
171
176
  const state = scenarios.get(this);
@@ -199,6 +204,15 @@ export function registerFeatureSteps(session, options = {}) {
199
204
  await gated(gate({ scenario: pickle.name, index: state ? state.ids.indexOf(pickleStep.id) : -1, text: pickleStep.text, status: String(result.status).toLowerCase() }));
200
205
  });
201
206
  }
207
+ api.AfterStep({ timeout: hookTimeout }, async function ({ result }) {
208
+ const state = scenarios.get(this);
209
+ if (!state)
210
+ return;
211
+ const status = String(result.status).toUpperCase();
212
+ if (status === "SKIPPED")
213
+ return;
214
+ await state.scenario.finishStep?.(state.current, { status: status === "PASSED" ? "passed" : "failed", ...(result.message ? { error: result.message } : {}) });
215
+ });
202
216
  api.After({ name: "product-lab: end the scenario", timeout: hookTimeout }, async function ({ pickle, result }) {
203
217
  const state = scenarios.get(this);
204
218
  scenarios.delete(this);
@@ -9,7 +9,7 @@ Node implementations of the system ports in [`@product-lab/ports/system`](../../
9
9
  | `GitChangeSource` | `ChangeSource` | Phase 7: files changed between the merge base of a base revision and HEAD, plus uncommitted and untracked ones, relative to the lab's directory (`../` outside it); read-only git, no fetch; an unknown base or an option-like revision is refused; every git command runs through a `CommandRunner` (default `NodeCommandRunner`) bounded by `timeoutMs` (default 30 000 ms), a non-zero exit rejects with git's first stderr line, and an uncommitted file that cannot be read rejects `revision` rather than counting as deleted (`tests/git-change-source.test.ts`, a real temporary repository). Backlog 37: `commits` (first-parent commits that changed the lab's directory between two, oldest first; refused when the first is not an ancestor), `checkout` (`git worktree add --detach` into a new directory, with the lockfile at its top and its sha256; the lab's checkout untouched) and `removeCheckout` (forced remove, the directory deleted, `git worktree prune`) |
10
10
  | `NodeSourceWatcher` | `SourceWatcher` | Hot reload of app code (backlog item 5): `fs.watch`, recursive on a watched directory (FSEvents on macOS, inotify on Linux), a watched file or a path not created yet through its nearest existing parent directory; reports changed paths relative to the lab with `/` separators (a watch of `.`, the lab's directory, reports every change under it, an editor's safe write by rename included), not debounced (the application's `AppReloads` debounces). `digest`: sha256 over each regular file's relative path and content in path order, an ignored directory skipped whole, symbolic links not followed. `tests/node-source-watcher.test.ts` (real temporary directory) |
11
11
  | `NodeConditionedLinks` | `ConditionedLinks` (backlog 31) | One identity's conditioned link to one loopback endpoint (`node-conditioned-links.ts`): a TCP proxy on a port the caller reserved, carrying every byte as sent through the domain's `ConditionedWire` (one per direction of each connection: in order, a burst at its speed, half the round trip each way while throttled); offline refuses new connections and cuts the open ones; half-closes travel through the wire, so an answer sent before its server closes still arrives. A plain `http:` or `ws:` endpoint is named as the request's `Host` in the first request head of each connection (`request-head.ts`: a local Substrate node answers "Provided Host header is not whitelisted" to any other), and a plain request is made its connection's only one (`Connection: close`); a WebSocket upgrade keeps its connection, and bytes that do not start a request pass as sent. A public endpoint is refused, never proxied. Backpressure is not modelled. `tests/node-conditioned-links.test.ts` (real loopback sockets) |
12
- | `NodeChainGates` | `ChainGates` (backlog 92) | Origin gates in front of local chain nodes (`node-chain-gates.ts`): one loopback TCP relay per plain `ws:` loopback chain route that accepts connections (a closed port, TLS and a public node are kept as they are). It holds each connection's first request head, decides it once, then pipes bytes both ways (backpressure, no per-message parsing). A request with neither `Origin` nor `Sec-Fetch-Site` (a native host, Node's fetch and WebSocket) passes; a browser's passes only from an admitted origin (`admit`), otherwise the connection is reset unanswered (a 403 would still resolve a no-cors fetch) and recorded (`refused()`: chain, gate URL, kind, origin, time). The node sees its own address as `Host` and a plain request is its connection's only one (`request-head.ts`), so no later request on a kept-alive connection skips the gate. A half-close travels on for a grace (5 s, `halfCloseGraceMs`), then both sides are cut; either side closing or failing destroys both; `close()` cuts everything. Substrate's own `--rpc-cors` cannot do this: its RPC server adds CORS headers but refuses no WebSocket upgrade. `tests/node-chain-gates.test.ts` (real loopback sockets: a closed upstream, a half-closed client, keep-alive, close) |
12
+ | `NodeChainGates` | `ChainGates` (backlog 92) | Origin gates in front of local chain nodes (`node-chain-gates.ts`): one loopback TCP relay per plain `ws:` loopback chain route that accepts connections (a closed port, TLS and a public node are kept as they are). It holds each connection's first request head, decides it once, then pipes bytes both ways (backpressure, no per-message parsing). A request with neither `Origin` nor `Sec-Fetch-Site` (a native host, Node's fetch and WebSocket) passes; a browser's passes only from an admitted origin (`admit`), otherwise the connection is reset unanswered (a 403 would still resolve a no-cors fetch) and recorded (`refused()`: chain, gate URL, kind, origin, time). The node sees its own address as `Host` and a plain request is its connection's only one (`request-head.ts`), so no later request on a kept-alive connection skips the gate. A half-close travels on for a grace (5 s, `halfCloseGraceMs`), then both sides are cut; either side closing or failing destroys both; `close()` cuts everything. Substrate's own `--rpc-cors` cannot do this: its RPC server adds CORS headers but refuses no WebSocket upgrade. `tests/node-chain-gates.test.ts` (real loopback sockets: a closed upstream, a half-closed client, keep-alive, close; the fake node does not record a plain `GET` with no browser mark, which is what a local port probe sends, and the test sends one to both ports) |
13
13
  | `NodeAppProxies` | `AppProxies` | The lab's proxy in front of an app's dev server (backlog items 64 to 66; `src/app-proxy/`): an HTTP reverse proxy on a loopback port that passes every request and answer as sent, except that an HTML document the browser loads gets the host's bridge script (the domain's `injectScriptTag`; then `no-store`, no validators), bodies are asked for uncompressed and documents unconditionally, a redirect to exactly the dev server's origin names the proxy, and the connection to the dev server is the proxy's own (one per request, since dev servers close idle kept-alive sockets). It reads the document's framing headers and never changes them (`framing()`). WebSockets are tunnelled byte for byte (`web-socket-tunnel.ts`); an update socket it understands (`VITE_UPDATES` in `vite-updates.ts`, Vite's `vite-hmr` subprotocol, checked against Vite 7.3.7) is read message by message: each announced update is put to the policy and forwarded or dropped, the same announcement on several pages' sockets is one update, and the page's side stays open when the dev server's closes (`lost`). A socket or an event stream it does not understand passes untouched and is reported once (`unknown-socket`), never for a declared dev server. `tests/node-app-proxy.test.ts` (real loopback servers, Node's WebSocket client; controls: an asset, a fragment, a conditional request, a dead dev server) |
14
14
  | `RandomIdGenerator` | `IdGenerator` | random UUIDs |
15
15
  | `ConsoleLogger` | `Logger` | level filter; details as JSON; with `redactor` (a `Redactor`'s `text`), every line scrubbed before it is written (backlog 89: the composition root passes the runtime's, so a provider logging a secret of the lab never prints it) |
@@ -70,7 +70,7 @@ The app-proxy fixture registers listener cleanup before proxy startup; an occupi
70
70
 
71
71
  The process-tree readiness controls count the anchor, command and child separately; the gated child must appear before readiness passes.
72
72
 
73
- `bunx nx run system-node:test`: real process trees for the runner (registry in memory; `tests/owned-group-anchor.test.ts`: a command exiting 0 or non-zero before any refresh with a descendant in its group, an escaped `setsid` descendant pinned as not covered, a stop during a start, a failed recording, a spawn error, a crashed controller (`tests/anchor-controller.ts`, registry on disk through `tests/disk-files.ts`) while the command runs and after it exits, reused pids, the anchor's own bounds, the command's default and named `cwd`, and a start never acknowledged: `tests/unacknowledging-anchor.mjs` as `anchor.path`, silent (stopped) or dying (refused, record kept)), real one-shot commands for `NodeCommandRunner` (exit code, cwd, env overlay and removal, stdin; timeout, abort, pre-aborted, a missing command and a zero timeout rejected; descendants: one holding the output after the command exits (settles within `drainMs`, group killed), a timeout with one in the group (both killed, on time), one that left the group (not killed, still settles by timeout + drain), and controls: a descendant with closed output neither delays nor fails a run, and 4 MiB of output is read in full with a 50 ms drain), fakes for the process table (EPERM, survivors, group capture), locks (liveness, clock), JSON-RPC transports and the connection probe (lsof output, process trees, exit 1 with a partial listing, a bounded `stop()`, and real `ps`/`lsof` with a watched process that exits), real loopback sockets for ports (a block found free at the start of the run from an OS-assigned port, or `SYSTEM_NODE_TEST_PORT_BASE`). `tests/node-process-sandbox.test.ts`: platform selection, construction and profile/plan-wrapping unit tests plus live smoke tests (gated to where `sandbox-exec` is supported) that spawn a wrapped process and assert a public connect gets `EPERM` while loopback still works, and record that the machine's own address counts as `localhost`. `tests/linux-namespace-sandbox.test.ts`: the probe and the wrap against fakes everywhere; on Linux where the probe passes, live smoke tests (this machine's own non-loopback address reachable unsandboxed and `ENETUNREACH` sandboxed, a public address refused at once, a declared loopback port reached through the forwarder and an undeclared one refused although it listens, a declared listen port reached from outside, exit codes and SIGTERM passed through) and a leaky control (a plan left unwrapped fails the same deny check, "reached … outside loopback"). `tools/linux` runs them on Linux in a container. On disk and across a crashed owner process: `tools/verify/tests/system-adapters.test.ts`.
73
+ `bunx nx run system-node:test`: real process trees for the runner (registry in memory; `tests/owned-group-anchor.test.ts`: a command exiting 0 or non-zero before any refresh with a descendant in its group, an escaped `setsid` descendant pinned as not covered, a stop during a start, a failed recording, a spawn error, a crashed controller (`tests/anchor-controller.ts`, registry on disk through `tests/disk-files.ts`) while the command runs and after it exits, reused pids, the anchor's own bounds, the command's default and named `cwd`, and a start never acknowledged: `tests/unacknowledging-anchor.mjs` as `anchor.path`, silent (stopped) or dying (refused, record kept)), real one-shot commands for `NodeCommandRunner` (exit code, cwd, env overlay and removal, stdin; timeout, abort, pre-aborted, a missing command and a zero timeout rejected; descendants: one holding the output after the command exits (settles within `drainMs`, group killed), a timeout with one in the group (both killed, on time), one that left the group (not killed, still settles by timeout + drain), and controls: a descendant with closed output neither delays nor fails a run, and 4 MiB of output is read in full with a 50 ms drain), fakes for the process table (EPERM, survivors, group capture), locks (liveness, clock), JSON-RPC transports and the connection probe (lsof output, process trees, exit 1 with a partial listing, a bounded `stop()`, and real `ps`/`lsof` with a watched process that exits), real loopback sockets for ports (a block found free at the start of the run from an OS-assigned port, or `SYSTEM_NODE_TEST_PORT_BASE`). `tests/node-process-sandbox.test.ts`: platform selection, construction and profile/plan-wrapping unit tests plus live smoke tests (gated to where `sandbox-exec` is supported) that spawn a wrapped process and assert a public connect gets `EPERM` while loopback still works, and record that the machine's own address counts as `localhost`. Their greeter cuts every connection still open when it closes, since it never reads one; the machine-address test sends a local port probe itself and is bounded (30 s), so a close that never ends fails it instead of hanging the file. `tests/linux-namespace-sandbox.test.ts`: the probe and the wrap against fakes everywhere; on Linux where the probe passes, live smoke tests (this machine's own non-loopback address reachable unsandboxed and `ENETUNREACH` sandboxed, a public address refused at once, a declared loopback port reached through the forwarder and an undeclared one refused although it listens, a declared listen port reached from outside, exit codes and SIGTERM passed through) and a leaky control (a plan left unwrapped fails the same deny check, "reached … outside loopback"). `tools/linux` runs them on Linux in a container. On disk and across a crashed owner process: `tools/verify/tests/system-adapters.test.ts`.
74
74
 
75
75
 
76
76
  ## Source revisions for compatibility
@@ -107,7 +107,7 @@ API-name correction: `bridge()` is a method on the `AppProxy` returned by `NodeA
107
107
 
108
108
  One 3000 ms deadline covers the exact application-path HEAD collision probe and native GET. Native headers are capped at 16 KiB and the script at 2 MiB. Redirects are never followed; caller cookies and conditionals are never sent to the host. A successful non-HTML application asset at the generated path is refused with 409. HTML SPA fallback is accepted, but an intentional HTML route is indistinguishable from it. Unsupported HEAD replies refuse with explicit 502; this check does not atomically protect against later application route changes. The whole namespace is reserved, including missing/expired URLs; consumers must keep their routes outside it. The native socket remains at its original endpoint, so `script-src 'self'` still needs a suitable `connect-src` allowance. Nonce/hash-only scripts and strict-dynamic are unqualified.
109
109
 
110
- Focused checks: `node --import tsx --test adapters/system/node/tests/node-app-bridge.test.ts adapters/system/node/tests/node-app-proxy.test.ts`. `APP_BRIDGE_TEST_PORT_BASE` selects the new fixture block (default 45300; one distinct ten-port slice per case); `APP_PROXY_TEST_PORT_BASE` optionally selects distinct ports for the older proxy tests, whose default remains independent OS allocation. All fixture listeners close with their tests.
110
+ Focused checks: `node --import tsx --test adapters/system/node/tests/node-app-bridge.test.ts adapters/system/node/tests/node-app-proxy.test.ts`. `APP_BRIDGE_TEST_PORT_BASE` selects the new fixture block (default 45300; one distinct ten-port slice per case); `APP_PROXY_TEST_PORT_BASE` optionally selects distinct ports for the older proxy tests, whose default remains independent OS allocation. All fixture listeners close with their tests. The bridge tests count, and wait for, only reads of a native server's bound path, so a local port probe's `GET /` is not taken for a read.
111
111
 
112
112
  ## Explicit Next App Router profile (1 October 2026)
113
113
 
@@ -37,3 +37,7 @@ generated video cut into an MP4 and a WebM of the planned length with an overlay
37
37
  where ffmpeg is not installed).
38
38
 
39
39
  Private, workspace-only package. Consumers will install the single umbrella package `@parity/product-lab` once it is published.
40
+
41
+ ## Held feature demos
42
+
43
+ VideoEditRequest.stills supplies exactly one PNG per segment; the adapter loops each at 25 fps for its planned duration, concatenates them, and encodes the result. No source-video timestamp is substituted for a missing still. The existing trim/speed/overlay path is retained when stills are absent. An offline integration test checks encoded midpoint pixels, duration, caption placement and failure visibility.
@@ -17,7 +17,7 @@ export declare function demoFilterGraph(plan: DemoPlan, overlays: readonly {
17
17
  }[], size: {
18
18
  width: number;
19
19
  height: number;
20
- }): {
20
+ }, stills?: boolean): {
21
21
  graph: string;
22
22
  output: string;
23
23
  };
@@ -6,14 +6,19 @@ import { join } from "node:path";
6
6
  const FRAME_RATE = 25;
7
7
  const INSTALL = "macOS: brew install ffmpeg; Debian/Ubuntu: apt install ffmpeg; or set PRODUCT_LAB_FFMPEG to its folder";
8
8
  const seconds = (ms) => (ms / 1000).toFixed(3);
9
- export function demoFilterGraph(plan, overlays, size) {
9
+ export function demoFilterGraph(plan, overlays, size, stills = false) {
10
10
  if (!plan.segments.length)
11
11
  throw new Error("The demo plan has no segment to cut");
12
12
  const { width, height } = size;
13
13
  const frame = 1000 / FRAME_RATE;
14
14
  const parts = plan.segments.map((segment, index) => {
15
15
  const shown = segment.outputEnd - segment.outputStart;
16
- const filters = [
16
+ const filters = stills ? [
17
+ "setpts=PTS-STARTPTS", `fps=${FRAME_RATE}`,
18
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease`,
19
+ `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=0xf6f6f4`, "setsar=1",
20
+ `trim=duration=${seconds(shown)}`,
21
+ ] : [
17
22
  `trim=start=${seconds(segment.sourceStart)}:end=${seconds(Math.max(segment.sourceEnd, segment.sourceStart + frame + 1))}`,
18
23
  `setpts=(PTS-STARTPTS)/${segment.speed.toFixed(4)}`,
19
24
  `fps=${FRAME_RATE}`,
@@ -24,10 +29,10 @@ export function demoFilterGraph(plan, overlays, size) {
24
29
  `trim=duration=${seconds(shown)}`,
25
30
  "setpts=PTS-STARTPTS",
26
31
  ];
27
- return `[0:v]${filters.join(",")}[s${index}]`;
32
+ return `[${stills ? index : 0}:v]${filters.join(",")}[s${index}]`;
28
33
  });
29
34
  const concat = `${plan.segments.map((_, index) => `[s${index}]`).join("")}concat=n=${plan.segments.length}:v=1:a=0[cut0]`;
30
- const layers = overlays.map((overlay, index) => `[cut${index}][${index + 1}:v]overlay=0:0:enable='between(t,${seconds(overlay.startMs)},${seconds(Math.max(overlay.endMs - 1, overlay.startMs))})'[cut${index + 1}]`);
35
+ const layers = overlays.map((overlay, index) => `[cut${index}][${index + (stills ? plan.segments.length : 1)}:v]overlay=0:0:enable='between(t,${seconds(overlay.startMs)},${seconds(Math.max(overlay.endMs - 1, overlay.startMs))})'[cut${index + 1}]`);
31
36
  return { graph: [...parts, concat, ...layers].join(";"), output: `cut${overlays.length}` };
32
37
  }
33
38
  export class FfmpegVideoEditor {
@@ -86,15 +91,23 @@ export class FfmpegVideoEditor {
86
91
  async render(request) {
87
92
  const work = mkdtempSync(join(tmpdir(), "product-lab-demo-"));
88
93
  try {
94
+ if (request.stills && request.stills.length !== request.plan.segments.length)
95
+ throw new Error("A demo needs exactly one still per segment");
96
+ const sources = request.stills ? request.stills.flatMap((png, index) => {
97
+ const file = join(work, `step-${index}.png`);
98
+ writeFileSync(file, png);
99
+ const segment = request.plan.segments[index];
100
+ return ["-loop", "1", "-framerate", String(FRAME_RATE), "-t", seconds(segment.outputEnd - segment.outputStart), "-i", file];
101
+ }) : ["-i", request.source];
89
102
  const inputs = request.overlays.flatMap((overlay, index) => {
90
103
  const file = join(work, `overlay-${index}.png`);
91
104
  writeFileSync(file, overlay.png);
92
105
  return ["-i", file];
93
106
  });
94
- const { graph, output } = demoFilterGraph(request.plan, request.overlays, request.size);
107
+ const { graph, output } = demoFilterGraph(request.plan, request.overlays, request.size, !!request.stills);
95
108
  const encode = request.format === "mp4" ? ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-movflags", "+faststart"] : ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "34", "-row-mt", "1"];
96
109
  const partial = `${request.out}.partial.${request.format}`;
97
- await this.run(this.ffmpeg, ["-v", "error", "-y", "-i", request.source, ...inputs, "-filter_complex", graph, "-map", `[${output}]`, "-r", String(FRAME_RATE), ...encode, partial], { timeoutMs: this.timeoutMs, ...(request.signal ? { signal: request.signal } : {}) });
110
+ await this.run(this.ffmpeg, ["-v", "error", "-y", ...sources, ...inputs, "-filter_complex", graph, "-map", `[${output}]`, "-r", String(FRAME_RATE), ...encode, partial], { timeoutMs: this.timeoutMs, ...(request.signal ? { signal: request.signal } : {}) });
98
111
  renameSync(partial, request.out);
99
112
  return { file: request.out, bytes: statSync(request.out).size, durationMs: await this.durationMs(request.out) };
100
113
  }