@norskvideo/ctl-test-harness 0.1.21 → 0.1.23

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.
@@ -2,9 +2,19 @@ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
2
2
  export declare const DEFAULT_COMPOSE_NODE = "video_compose";
3
3
  /** The highest mediaIn across every node of that name; 0 when none. */
4
4
  export declare function maxNodeMediaIn(summary: unknown, node: string): number;
5
+ export interface ComposeSample {
6
+ frames: number;
7
+ /** Why the sample is 0 when it is: the visualiser was unreachable, and how. */
8
+ error?: string;
9
+ }
5
10
  /** One sample: the highest mediaIn of `node` across every root workflow under
6
- * `base` (`.../visualiser`). An unreachable visualiser reads as 0 the
7
- * caller's second sample, not a throw, decides. */
11
+ * `base` (`.../visualiser`). An unreachable visualiser reads as 0 with the
12
+ * reason beside it — the caller's second sample, not a throw, decides. */
13
+ export declare function composeSample(opts: {
14
+ fetch: FetchLike;
15
+ base: string;
16
+ node?: string;
17
+ }): Promise<ComposeSample>;
8
18
  export declare function composeFrames(opts: {
9
19
  fetch: FetchLike;
10
20
  base: string;
@@ -15,6 +25,8 @@ export interface ComposeAdvancingResult {
15
25
  before: number;
16
26
  after: number;
17
27
  node: string;
28
+ /** Set when a sample could not read the visualiser at all. */
29
+ error?: string;
18
30
  }
19
31
  /** Two samples `settleMs` apart: advancing when the second is above the first
20
32
  * and above zero — the fallback card and a stalled feed both read flat. */
@@ -28,12 +28,18 @@ async function getJson(fetch, url) {
28
28
  const r = await fetch(url, { signal: AbortSignal.timeout(5000) });
29
29
  if (!r.ok)
30
30
  throw new Error(`GET ${url} -> ${r.status}`);
31
- return r.json();
31
+ const text = await r.text();
32
+ try {
33
+ return JSON.parse(text);
34
+ }
35
+ catch {
36
+ throw new Error(`GET ${url} -> ${r.status}, not JSON (a sign-in page? the route needs no auth): ${text.slice(0, 80)}`);
37
+ }
32
38
  }
33
39
  /** One sample: the highest mediaIn of `node` across every root workflow under
34
- * `base` (`.../visualiser`). An unreachable visualiser reads as 0 the
35
- * caller's second sample, not a throw, decides. */
36
- export async function composeFrames(opts) {
40
+ * `base` (`.../visualiser`). An unreachable visualiser reads as 0 with the
41
+ * reason beside it — the caller's second sample, not a throw, decides. */
42
+ export async function composeSample(opts) {
37
43
  const node = opts.node ?? DEFAULT_COMPOSE_NODE;
38
44
  try {
39
45
  const wf = (await getJson(opts.fetch, `${opts.base}/workflow`));
@@ -44,18 +50,28 @@ export async function composeFrames(opts) {
44
50
  const summary = await getJson(opts.fetch, `${opts.base}/workflow/${encodeURIComponent(root.wfid)}/summary`);
45
51
  max = Math.max(max, maxNodeMediaIn(summary, node));
46
52
  }
47
- return max;
53
+ return { frames: max };
48
54
  }
49
- catch {
50
- return 0;
55
+ catch (e) {
56
+ return { frames: 0, error: e instanceof Error ? e.message : String(e) };
51
57
  }
52
58
  }
59
+ export async function composeFrames(opts) {
60
+ return (await composeSample(opts)).frames;
61
+ }
53
62
  /** Two samples `settleMs` apart: advancing when the second is above the first
54
63
  * and above zero — the fallback card and a stalled feed both read flat. */
55
64
  export async function composeAdvancing(opts) {
56
65
  const node = opts.node ?? DEFAULT_COMPOSE_NODE;
57
- const before = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
66
+ const before = await composeSample({ fetch: opts.fetch, base: opts.base, node });
58
67
  await new Promise((r) => setTimeout(r, opts.settleMs ?? DEFAULT_SETTLE_MS));
59
- const after = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
60
- return { advancing: after > before && after > 0, before, after, node };
68
+ const after = await composeSample({ fetch: opts.fetch, base: opts.base, node });
69
+ const error = after.error ?? before.error;
70
+ return {
71
+ advancing: after.frames > before.frames && after.frames > 0,
72
+ before: before.frames,
73
+ after: after.frames,
74
+ node,
75
+ ...(error !== undefined ? { error } : {}),
76
+ };
61
77
  }
package/demo/cli.d.ts CHANGED
@@ -1,14 +1,22 @@
1
1
  #!/usr/bin/env bun
2
+ import { type DevLoopOptions, type DevLoopResult } from "./dev-loop.js";
3
+ import { type DemoUi, type PaneLayout } from "./panes.js";
2
4
  import { type DemoRunOptions, type DemoRunResult, demoPorts } from "./run.js";
3
5
  import type { DemoDaemonPolicy, DemoMode, DemoSpec } from "./spec.js";
4
6
  export interface DemoArgs {
5
- action: "up" | "check" | "down" | "spec";
7
+ /** `dev-loop <action>`: the standalone tier's verbs. */
8
+ devLoop: boolean;
9
+ action: "up" | "check" | "down" | "spec" | "ui" | "refresh";
6
10
  /** Absent: the spec's `mode`, else dev. */
7
11
  mode?: DemoMode | "standalone";
8
12
  daemon: DemoDaemonPolicy;
9
13
  exportOnly: boolean;
10
14
  json: boolean;
11
15
  spec: string;
16
+ /** `ui` only; zellij unless said. */
17
+ ui?: DemoUi;
18
+ /** Default: PUBLIC_HOST in the environment. */
19
+ publicHost?: string;
12
20
  }
13
21
  export declare function parseDemoArgs(argv: string[]): DemoArgs;
14
22
  export interface ResolvedSpecView {
@@ -58,6 +66,7 @@ export interface ResolvedSpecView {
58
66
  export declare function resolvedSpecView(spec: DemoSpec, opts: {
59
67
  mode: string;
60
68
  slug?: string;
69
+ publicHost?: string;
61
70
  }): ResolvedSpecView;
62
71
  /** Everything `demoMain` touches, injectable so the dispatch is unit-tested. */
63
72
  export interface DemoCliIo {
@@ -70,6 +79,12 @@ export interface DemoCliIo {
70
79
  exportDir: string;
71
80
  }>;
72
81
  demoDown(product: string): Promise<void>;
82
+ runDevLoop(spec: DemoSpec, opts: DevLoopOptions): Promise<DevLoopResult>;
83
+ devLoopDown(product: string): Promise<void>;
84
+ /** Open the multiplexer on the rendered layout; returns its exit code. */
85
+ launchUi(ui: DemoUi, layout: PaneLayout): number;
86
+ /** For the standalone dirs (NORSK_DIR, STUDIO_DIR, TO, HOME). */
87
+ env?: Record<string, string | undefined>;
73
88
  stdout(line: string): void;
74
89
  stderr(line: string): void;
75
90
  abort?: AbortSignal;
package/demo/cli.js CHANGED
@@ -15,13 +15,21 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
15
15
  // demo check --mode standalone --export-only build + export-workdir + every symlink resolves
16
16
  // demo down tear down what `up` recorded, from any shell
17
17
  // demo spec [--json] the resolved spec: ports, template, sources, URLs
18
+ // demo ui [--ui zellij|tmux|none] [...] the same in a multiplexer: a gate pane running `demo up`,
19
+ // the instance's logs, a shell (05-demo s4 front-end 2)
20
+ // demo dev-loop up|refresh|down|ui the full standalone tier (engine + Studio from source);
21
+ // `ctl-dev-loop <verb>` is the same under a product's `bun run dev-loop`
18
22
  //
19
23
  // The spec is `tests/demo.spec.ts` in the cwd (`--spec <path>` otherwise), a
20
24
  // TypeScript module whose default export is `defineDemo({...})`. This runs
21
25
  // under bun only: the spec is TypeScript and so is every product's dev shell.
22
26
  import { resolve } from "node:path";
27
+ import { devLoopDown, runDevLoop } from "./dev-loop.js";
28
+ import { demoPaneList, devLoopPaneList, launchLayout, renderLayout, standaloneDirs, } from "./panes.js";
23
29
  import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
24
- const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json] [--spec <path>]
30
+ const USAGE = `usage: demo <up|check|down|spec|ui> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json]
31
+ [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
32
+ demo dev-loop <up|refresh|down|ui> [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
25
33
 
26
34
  up launch, print the URLs, hold (Ctrl-C tears down)
27
35
  check up, then tear down and exit 0/1 — what CI runs, always on a private daemon
@@ -29,6 +37,14 @@ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [-
29
37
  build the template, export the standalone workdir, assert every symlink resolves
30
38
  down tear down what \`up\` recorded
31
39
  spec [--json] print the resolved spec without launching anything
40
+ ui open a multiplexer: a pane running \`up\`, the instance's logs, a shell
41
+
42
+ dev-loop up the standalone tier: private daemon + dev backend, template built from source,
43
+ workdir exported with the live-source links (NORSK_DIR, STUDIO_DIR, TO name the
44
+ engine and Studio checkouts and the workdir); holds
45
+ dev-loop refresh rebuild + re-export against the held \`up\` (after a component or dashboard edit)
46
+ dev-loop down end the held \`up\` from any shell; the workdir stays
47
+ dev-loop ui open a multiplexer: engine, \`dev-loop up\`, Studio, the sources (on Enter), a shell
32
48
 
33
49
  --mode dev run the product from source (bun run dev) and add it by URL
34
50
  --mode image add the built product image — the customer path
@@ -36,14 +52,37 @@ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [-
36
52
  --daemon reuse your listening daemon and real store; the driver deletes the instance,
37
53
  removes the template and product, then adds again (stored templates
38
54
  are immutable by name)
55
+ --ui zellij|tmux|none the multiplexer (\`none\` prints the pane commands); zellij unless said
56
+ --public-host <host> the name other machines reach this box by: every URL, the daemon's
57
+ publicHost, Studio's MoQ preview (default: PUBLIC_HOST)
39
58
  --spec <path> the demo spec (default tests/demo.spec.ts)`;
40
59
  const DEFAULT_SPEC = "tests/demo.spec.ts";
60
+ const DEMO_ACTIONS = ["up", "check", "down", "spec", "ui"];
61
+ const DEV_LOOP_ACTIONS = ["up", "refresh", "down", "ui"];
41
62
  export function parseDemoArgs(argv) {
42
- const [action, ...rest] = argv;
43
- if (action !== "up" && action !== "check" && action !== "down" && action !== "spec") {
63
+ const devLoop = argv[0] === "dev-loop";
64
+ const [action, ...rest] = devLoop ? argv.slice(1) : argv;
65
+ const actions = devLoop ? DEV_LOOP_ACTIONS : DEMO_ACTIONS;
66
+ if (action === undefined || !actions.includes(action)) {
67
+ if (devLoop)
68
+ throw new Error(`dev-loop takes one of up|refresh|down|ui${action ? `, not '${action}'` : ""}\n${USAGE}`);
69
+ if (action === "refresh")
70
+ throw new Error(`refresh is the dev-loop's: \`dev-loop refresh\`\n${USAGE}`);
44
71
  throw new Error(action ? `unknown action '${action}'\n${USAGE}` : USAGE);
45
72
  }
46
- const args = { action, daemon: "private", exportOnly: false, json: false, spec: DEFAULT_SPEC };
73
+ const args = {
74
+ devLoop,
75
+ action: action,
76
+ daemon: "private",
77
+ exportOnly: false,
78
+ json: false,
79
+ spec: DEFAULT_SPEC,
80
+ };
81
+ if (action === "ui")
82
+ args.ui = "zellij";
83
+ const envHost = process.env.PUBLIC_HOST;
84
+ if (envHost)
85
+ args.publicHost = envHost;
47
86
  for (let i = 0; i < rest.length; i++) {
48
87
  const flag = rest[i];
49
88
  const value = () => {
@@ -76,12 +115,27 @@ export function parseDemoArgs(argv) {
76
115
  case "--spec":
77
116
  args.spec = value();
78
117
  break;
118
+ case "--ui": {
119
+ const u = value();
120
+ if (u !== "zellij" && u !== "tmux" && u !== "none")
121
+ throw new Error(`unknown ui '${u}'\n${USAGE}`);
122
+ args.ui = u;
123
+ break;
124
+ }
125
+ case "--public-host":
126
+ args.publicHost = value();
127
+ break;
79
128
  default:
80
129
  throw new Error(`unknown flag '${flag}'\n${USAGE}`);
81
130
  }
82
131
  }
83
- if (args.mode === "standalone" && !args.exportOnly && (args.action === "up" || args.action === "check")) {
84
- throw new Error("the full standalone tier (engine + Studio panes) is not yet implemented (05-demo s7 step 3); this slice has `check --mode standalone --export-only`");
132
+ if (args.ui !== undefined && args.action !== "ui")
133
+ throw new Error("--ui belongs to ui (or dev-loop ui)");
134
+ if (devLoop && (args.mode !== undefined || args.daemon !== "private" || args.exportOnly || args.json)) {
135
+ throw new Error("dev-loop takes only --ui, --public-host and --spec (no --mode, --daemon, --export-only or --json): it is always the source tier on a private daemon");
136
+ }
137
+ if (args.mode === "standalone" && !args.exportOnly && args.action !== "spec") {
138
+ throw new Error(`the full standalone tier is the dev-loop's: \`dev-loop ${args.action === "ui" ? "ui" : "up"}\` (this mode's headless gate is \`check --mode standalone --export-only\`)`);
85
139
  }
86
140
  if (args.exportOnly && args.mode !== "standalone") {
87
141
  throw new Error("--export-only belongs to --mode standalone");
@@ -98,16 +152,17 @@ export function resolvedSpecView(spec, opts) {
98
152
  const slug = opts.slug ?? demoSlug(spec.product);
99
153
  const instanceId = `demo-${slug}`;
100
154
  const ports = demoPorts(slug);
155
+ const host = opts.publicHost ?? "localhost";
101
156
  const pinned = spec.launch?.params?.STUDIO_HOST_PORT;
102
157
  const studioHostPort = pinned !== undefined ? Number(pinned) : ports.studioHostPort;
103
158
  const url = (ref) => {
104
159
  if ("url" in ref)
105
160
  return ref.url;
106
161
  if ("control" in ref)
107
- return `http://localhost:${ports.backendPort}${ref.control}`;
162
+ return `http://${host}:${ports.backendPort}${ref.control}`;
108
163
  if ("proxy" in ref)
109
- return `http://localhost:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
110
- return `http://localhost:${studioHostPort}${ref.studio}`;
164
+ return `http://${host}:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
165
+ return `http://${host}:${studioHostPort}${ref.studio}`;
111
166
  };
112
167
  const t = spec.template;
113
168
  const template = t && "build" in t
@@ -176,30 +231,59 @@ export async function demoMain(argv, io) {
176
231
  return 1;
177
232
  }
178
233
  const mode = args.mode ?? spec.mode ?? "dev";
234
+ const slug = demoSlug(spec.product);
235
+ const publicHost = args.publicHost !== undefined ? { publicHost: args.publicHost } : {};
179
236
  try {
237
+ if (args.devLoop) {
238
+ const dirs = standaloneDirs(spec, slug, io.env ?? process.env);
239
+ switch (args.action) {
240
+ case "up":
241
+ case "refresh":
242
+ await io.runDevLoop(spec, {
243
+ action: args.action,
244
+ cwd: io.cwd,
245
+ dirs,
246
+ ...publicHost,
247
+ ...(io.abort ? { abort: io.abort } : {}),
248
+ });
249
+ return 0;
250
+ case "down":
251
+ await io.devLoopDown(spec.product);
252
+ return 0;
253
+ default: {
254
+ const panes = devLoopPaneList(spec, { cwd: io.cwd, slug, ...dirs, ...publicHost });
255
+ return io.launchUi(args.ui ?? "zellij", { session: `${slug}-dev-loop`, tab: slug, panes });
256
+ }
257
+ }
258
+ }
180
259
  switch (args.action) {
181
260
  case "spec": {
182
- const view = resolvedSpecView(spec, { mode });
261
+ const view = resolvedSpecView(spec, { mode, ...publicHost });
183
262
  io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
184
263
  return 0;
185
264
  }
186
265
  case "down":
187
266
  await io.demoDown(spec.product);
188
267
  return 0;
189
- case "up":
190
- case "check": {
268
+ case "ui": {
269
+ if (mode === "standalone")
270
+ throw new Error("the standalone tier's multiplexer is `dev-loop ui`");
271
+ const panes = demoPaneList(spec, { cwd: io.cwd, slug, mode, daemon: args.daemon, ...publicHost });
272
+ return io.launchUi(args.ui ?? "zellij", { session: `${slug}-demo`, tab: slug, panes });
273
+ }
274
+ default: {
191
275
  if (args.exportOnly) {
192
276
  await io.runExportCheck(spec, { cwd: io.cwd });
193
277
  return 0;
194
278
  }
195
- if (mode === "standalone") {
196
- throw new Error("the full standalone tier is not yet implemented (05-demo s7 step 3)");
197
- }
279
+ if (mode === "standalone")
280
+ throw new Error("the full standalone tier is `dev-loop up`");
198
281
  await io.runDemo(spec, {
199
282
  action: args.action,
200
283
  mode,
201
284
  daemon: args.daemon,
202
285
  cwd: io.cwd,
286
+ ...publicHost,
203
287
  ...(io.abort ? { abort: io.abort } : {}),
204
288
  });
205
289
  return 0;
@@ -207,7 +291,7 @@ export async function demoMain(argv, io) {
207
291
  }
208
292
  }
209
293
  catch (e) {
210
- io.stderr(`demo ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
294
+ io.stderr(`${args.devLoop ? "dev-loop" : "demo"} ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
211
295
  return 1;
212
296
  }
213
297
  }
@@ -242,12 +326,17 @@ export function defaultDemoCliIo() {
242
326
  const release = () => controller.abort();
243
327
  process.once("SIGINT", release);
244
328
  process.once("SIGTERM", release);
329
+ // A multiplexer session ending sends its panes SIGHUP: that is a release too.
330
+ process.once("SIGHUP", release);
245
331
  return {
246
332
  cwd: process.cwd(),
247
333
  loadSpec: loadSpecModule,
248
334
  runDemo: (spec, opts) => runDemo(spec, opts),
249
335
  runExportCheck: (spec, opts) => runExportCheck(spec, opts),
250
336
  demoDown: (product) => demoDown(product),
337
+ runDevLoop: (spec, opts) => runDevLoop(spec, opts),
338
+ devLoopDown: (product) => devLoopDown(product),
339
+ launchUi: (ui, layout) => launchLayout(ui, renderLayout(layout, ui), layout),
251
340
  stdout: (line) => console.log(line),
252
341
  stderr: (line) => console.error(line),
253
342
  abort: controller.signal,
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+ // `ctl-dev-loop`, the bin behind a product's `bun run dev-loop`: the
3
+ // standalone tier's verbs (`up`, `refresh`, `down`, `ui`) are `ctl-demo
4
+ // dev-loop <verb>` under another name.
5
+ import { defaultDemoCliIo, demoMain } from "./cli.js";
6
+ if (import.meta.main) {
7
+ process.exit(await demoMain(["dev-loop", ...process.argv.slice(2)], defaultDemoCliIo()));
8
+ }
@@ -0,0 +1,33 @@
1
+ import type { SourceAsset } from "../source-pump.js";
2
+ import { type StandaloneDirs } from "./panes.js";
3
+ import { type DemoDeps, type DemoTimeouts } from "./run.js";
4
+ import type { DemoSpec } from "./spec.js";
5
+ export interface DevLoopOptions {
6
+ action: "up" | "refresh";
7
+ cwd: string;
8
+ slug?: string;
9
+ /** Default: `standaloneDirs(spec, slug)` — the environment, the spec, the conventions. */
10
+ dirs?: StandaloneDirs;
11
+ publicHost?: string;
12
+ abort?: AbortSignal;
13
+ timeouts?: DemoTimeouts;
14
+ }
15
+ export interface DevLoopResult {
16
+ workdir: string;
17
+ studioUrl: string;
18
+ }
19
+ export interface StandaloneSource {
20
+ name: string;
21
+ port: number;
22
+ streamId: string;
23
+ asset: SourceAsset;
24
+ }
25
+ /** The pump script the sources pane runs on Enter: every source in
26
+ * parallel with the host's ffmpeg, into the from-source engine. A preset is
27
+ * fetched into the daemon's sample-media cache if it is not there yet; a
28
+ * mediaFile that is absent on this box pumps its fallback. */
29
+ export declare function pumpScript(sources: StandaloneSource[], fileExists: (path: string) => boolean): string;
30
+ export declare function runDevLoop(spec: DemoSpec, opts: DevLoopOptions, deps?: DemoDeps): Promise<DevLoopResult>;
31
+ /** `dev-loop down`: end the recorded run from any shell. The workdir stays —
32
+ * it is the developer's, and Studio may still be looking at it. */
33
+ export declare function devLoopDown(product: string, deps?: DemoDeps): Promise<void>;
@@ -0,0 +1,205 @@
1
+ // The full standalone tier (05-demo s7 step 3): probe's `refresh-standalone`
2
+ // and the sequencing half of `probe-demo-zellij`, for every product. The
3
+ // engine and Studio run from their checkouts (the `dev-loop ui` panes); this
4
+ // driver owns the rest — a private daemon, the dev backend on the driver's
5
+ // port, the template built from source, the workdir exported with the
6
+ // live-source links, the pump script resolved from the template's ports, and
7
+ // the stamp the Studio pane waits for. `up` holds so `refresh` can re-export
8
+ // against the same daemon and backend; `down` tears them down and leaves the
9
+ // developer's workdir where it is.
10
+ import { join } from "node:path";
11
+ import { DEFAULT_STANDALONE_STUDIO_PORT, DEV_LOOP_SOURCES, DEV_LOOP_STAMP, standaloneDirs, } from "./panes.js";
12
+ import { DemoSession, defaultDemoDeps, demoSlug, exportStandaloneWorkdir, resolveIngestPort, } from "./run.js";
13
+ /** The daemon's built-in presets and where it caches them (norsk-ctl
14
+ * backend/src/docker/constants.ts: SOURCE_PRESETS, S3_MEDIA_BASE); the
15
+ * standalone tier pumps from the same files with the host's ffmpeg. */
16
+ const SAMPLE_MEDIA_BASE = "https://s3.eu-west-1.amazonaws.com/norsk.video/media-examples/data";
17
+ const SAMPLE_MEDIA_FILES = { camera1: "action.mp4", camera2: "wildlife.ts" };
18
+ const SAMPLE_MEDIA_DIR = "$HOME/.norsk-ctl/sample-media";
19
+ function generated(pattern) {
20
+ const video = pattern === "bars" ? "smptebars=size=1280x720:rate=25" : "testsrc=size=1280x720:rate=25";
21
+ return `ffmpeg -hide_banner -loglevel warning -re -f lavfi -i "${video}" -f lavfi -i "sine=frequency=440:sample_rate=48000" -c:v libx264 -preset veryfast -tune zerolatency -g 50 -c:a aac`;
22
+ }
23
+ function pumpLines(asset, target) {
24
+ if ("mediaFile" in asset) {
25
+ return [
26
+ `ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 -i "${asset.mediaFile}" -c copy -f mpegts "${target}" &`,
27
+ ];
28
+ }
29
+ if ("generate" in asset)
30
+ return [`${generated(asset.generate)} -f mpegts "${target}" &`];
31
+ const file = SAMPLE_MEDIA_FILES[asset.preset];
32
+ if (!file)
33
+ throw new Error(`unknown preset '${asset.preset}'; the daemon's presets are ${Object.keys(SAMPLE_MEDIA_FILES).join(", ")}`);
34
+ return [
35
+ `f="${SAMPLE_MEDIA_DIR}/${file}"; [ -f "$f" ] || curl -fL --create-dirs -o "$f" "${SAMPLE_MEDIA_BASE}/${file}"`,
36
+ `ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 -i "$f" -c copy -f mpegts "${target}" &`,
37
+ ];
38
+ }
39
+ /** The pump script the sources pane runs on Enter: every source in
40
+ * parallel with the host's ffmpeg, into the from-source engine. A preset is
41
+ * fetched into the daemon's sample-media cache if it is not there yet; a
42
+ * mediaFile that is absent on this box pumps its fallback. */
43
+ export function pumpScript(sources, fileExists) {
44
+ const lines = [
45
+ "#!/usr/bin/env bash",
46
+ "# written by dev-loop up: the demo's sources, pumped from this box into the from-source engine",
47
+ "set -uo pipefail",
48
+ ];
49
+ for (const s of sources) {
50
+ const target = `srt://127.0.0.1:${s.port}?streamid=${s.streamId}`;
51
+ const asset = "mediaFile" in s.asset && !fileExists(s.asset.mediaFile) ? s.asset.fallback : s.asset;
52
+ const via = "mediaFile" in s.asset && !fileExists(s.asset.mediaFile) ? ` (${s.asset.mediaFile} is not on this box)` : "";
53
+ lines.push("", `# ${s.name}: ${JSON.stringify(asset)} -> ${target}${via}`, ...pumpLines(asset, target));
54
+ }
55
+ lines.push("", "wait");
56
+ return `${lines.join("\n")}\n`;
57
+ }
58
+ function standalonePort(ingest, t) {
59
+ if ("label" in ingest) {
60
+ throw new Error(`ingest label '${ingest.label}' — the standalone tier has no instance to resolve a label against; name the template parameter (ingest: { param })`);
61
+ }
62
+ if ("port" in ingest)
63
+ return ingest.port;
64
+ return resolveIngestPort(ingest, [], t);
65
+ }
66
+ /** The standalone tier's template: its own when the spec gives one, else the demo's. */
67
+ function loopTemplate(spec) {
68
+ return spec.standalone?.template ?? spec.template;
69
+ }
70
+ function studioPortOf(envText) {
71
+ const m = /^export PORT='?(\d+)/m.exec(envText ?? "");
72
+ return m ? Number(m[1]) : DEFAULT_STANDALONE_STUDIO_PORT;
73
+ }
74
+ /** Build (or reload) the template, export the workdir, write the pump script
75
+ * and finally the stamp — the order the panes rely on. */
76
+ async function exportLoop(s, templateName, workdir, refresh) {
77
+ const { spec, deps } = s;
78
+ deps.removeFile(join(workdir, DEV_LOOP_STAMP));
79
+ if (refresh) {
80
+ const t = loopTemplate(spec);
81
+ if (t && "build" in t)
82
+ await s.resolveTemplate(t);
83
+ else
84
+ await s.cliOk(["product", "reload", spec.product]);
85
+ }
86
+ await exportStandaloneWorkdir(s, templateName, workdir);
87
+ const declared = await s.declaredParams(templateName);
88
+ const t = { declared, overrides: spec.standalone?.params ?? {} };
89
+ const sources = (spec.sources ?? []).map((src) => {
90
+ const asset = src.asset ?? { preset: "camera1" };
91
+ return { name: src.name, port: standalonePort(src.ingest, t), streamId: src.streamId ?? src.name, asset };
92
+ });
93
+ deps.writeFile(join(workdir, DEV_LOOP_SOURCES), pumpScript(sources, deps.fileExists));
94
+ deps.writeFile(join(workdir, DEV_LOOP_STAMP), `${new Date().toISOString()}\n`);
95
+ return studioPortOf(deps.readFile(join(workdir, "env")));
96
+ }
97
+ function describeLoop(s, dirs, studioPort) {
98
+ const { spec } = s;
99
+ const url = `http://${s.host}:${studioPort}`;
100
+ return [
101
+ `${spec.product} standalone workdir exported to ${dirs.workdir} (template from the dev backend on :${s.ports.backendPort})`,
102
+ ` engine cd ${dirs.norskDir} && nix develop --command make media-shell`,
103
+ ` studio cd ${dirs.studioDir} && (. ${dirs.workdir}/env && npm run server-dev) -> ${url}`,
104
+ ` sources bash ${join(dirs.workdir, DEV_LOOP_SOURCES)} (into the engine, when the workflow is running)`,
105
+ ` after a shared/ edit: restart this (the dev backend does not watch shared/); a dashboard edit needs only a browser refresh`,
106
+ ];
107
+ }
108
+ async function refuseIfUp(spec, deps) {
109
+ const prev = deps.state.read(spec.product, "dev-loop");
110
+ if (!prev)
111
+ return;
112
+ if (await deps.daemonAnswers(prev.daemonPort)) {
113
+ throw new Error(`dev-loop '${spec.product}' is already up (daemon :${prev.daemonPort}, workdir ${prev.workdir}) — \`dev-loop refresh\` re-exports it, \`dev-loop down\` ends it`);
114
+ }
115
+ deps.log(`forgetting a stale dev-loop record of a run on :${prev.daemonPort} (nothing of it is left)`);
116
+ deps.state.remove(spec.product, "dev-loop");
117
+ }
118
+ export async function runDevLoop(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
119
+ const slug = opts.slug ?? demoSlug(spec.product);
120
+ const dirs = opts.dirs ?? standaloneDirs(spec, slug);
121
+ const common = {
122
+ mode: "dev",
123
+ daemon: "private",
124
+ ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
125
+ ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
126
+ };
127
+ if (opts.action === "refresh") {
128
+ const state = deps.state.read(spec.product, "dev-loop");
129
+ if (!state || !(await deps.daemonAnswers(state.daemonPort)) || !state.templateName) {
130
+ throw new Error(`dev-loop '${spec.product}' is not up — run \`dev-loop up\` first (it holds; refresh from another pane)`);
131
+ }
132
+ const s = new DemoSession(spec, slug, opts.cwd, deps, { ...common, storeDir: state.storeDir });
133
+ const workdir = state.workdir ?? dirs.workdir;
134
+ const studioPort = await exportLoop(s, state.templateName, workdir, true);
135
+ for (const line of describeLoop(s, { ...dirs, workdir }, studioPort))
136
+ deps.log(line);
137
+ deps.log("refreshed — reload the workflow in Studio");
138
+ return { workdir, studioUrl: `http://${s.host}:${studioPort}` };
139
+ }
140
+ await refuseIfUp(spec, deps);
141
+ const s = new DemoSession(spec, slug, opts.cwd, deps, common);
142
+ let recorded = false;
143
+ let result;
144
+ let journeyError;
145
+ try {
146
+ await s.attachDaemon();
147
+ await s.startDev();
148
+ await s.register({ licence: false });
149
+ const templateName = await s.resolveTemplate(loopTemplate(spec));
150
+ const studioPort = await exportLoop(s, templateName, dirs.workdir, false);
151
+ for (const line of describeLoop(s, dirs, studioPort))
152
+ deps.log(line);
153
+ result = { workdir: dirs.workdir, studioUrl: `http://${s.host}:${studioPort}` };
154
+ const state = {
155
+ product: spec.product,
156
+ kind: "dev-loop",
157
+ storeDir: s.storeDir,
158
+ daemonPort: s.ports.daemonPort,
159
+ workdir: dirs.workdir,
160
+ templateName,
161
+ ...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
162
+ };
163
+ deps.state.write(state);
164
+ recorded = true;
165
+ deps.log("holding — Ctrl-C (or `dev-loop down` from another shell) ends it; `dev-loop refresh` re-exports");
166
+ await deps.hold(opts.abort);
167
+ }
168
+ catch (e) {
169
+ journeyError = e;
170
+ }
171
+ await s.teardown({ instances: [], handles: [] });
172
+ if (recorded)
173
+ deps.state.remove(spec.product, "dev-loop");
174
+ if (journeyError !== undefined)
175
+ throw journeyError;
176
+ return result;
177
+ }
178
+ /** `dev-loop down`: end the recorded run from any shell. The workdir stays —
179
+ * it is the developer's, and Studio may still be looking at it. */
180
+ export async function devLoopDown(product, deps = defaultDemoDeps(process.cwd())) {
181
+ const state = deps.state.read(product, "dev-loop");
182
+ if (!state) {
183
+ deps.log(`nothing recorded as up for the ${product} dev-loop`);
184
+ return;
185
+ }
186
+ const cli = (argv) => deps.cli(state.storeDir, ["--port", String(state.daemonPort), ...argv]);
187
+ try {
188
+ await deps.cleanup({
189
+ deleteInstance: (id) => cli(["instance", "delete", id, "--purge"]),
190
+ stopDaemon: () => cli(["shutdown"]),
191
+ instances: [],
192
+ daemon: null,
193
+ storeDir: state.storeDir,
194
+ containers: [],
195
+ });
196
+ }
197
+ catch (e) {
198
+ deps.log(`cleanup: ${e instanceof Error ? e.message : String(e)}; nuking the store as root`);
199
+ }
200
+ if (state.devPid !== undefined)
201
+ deps.killPid?.(state.devPid);
202
+ deps.nukeStoreAsRoot(state.storeDir);
203
+ deps.state.remove(product, "dev-loop");
204
+ deps.log(`${product} dev-loop torn down (the workdir ${state.workdir ?? ""} is untouched)`);
205
+ }
package/demo/gates.js CHANGED
@@ -48,7 +48,9 @@ export function composeAdvancingGate(opts = {}) {
48
48
  node,
49
49
  ...(opts.settleMs !== undefined ? { settleMs: opts.settleMs } : {}),
50
50
  });
51
- if (!r.advancing)
51
+ if (r.error)
52
+ ctx.log(`visualiser unreachable at ${base}: ${r.error}`);
53
+ else if (!r.advancing)
52
54
  ctx.log(`${node} not advancing yet (${r.before} -> ${r.after})`);
53
55
  return r.advancing;
54
56
  },
package/demo/index.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  export type { DemoArgs, DemoCliIo, ResolvedSpecView } from "./cli.js";
2
2
  export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
3
+ export type { DevLoopOptions, DevLoopResult, StandaloneSource } from "./dev-loop.js";
4
+ export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
3
5
  export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
4
- export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
5
- export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
6
+ export type { DemoPane, DemoPaneOptions, DemoUi, DevLoopPaneOptions, PaneLayout, StandaloneDirs } from "./panes.js";
7
+ export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
8
+ export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateKind, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
9
+ export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
6
10
  export type { DemoContext, DemoDaemonPolicy, DemoExtra, DemoIngest, DemoLaunchContext, DemoMode, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
7
11
  export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
package/demo/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
2
+ export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
2
3
  export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
3
- export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
4
+ export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
5
+ export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
4
6
  export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";