@norskvideo/ctl-dev-kit 0.2.107 → 0.2.109

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 (34) hide show
  1. package/conventions/DESIGN.family.md +73 -0
  2. package/conventions/check-drift.ts +100 -0
  3. package/conventions/sync-drift.ts +21 -1
  4. package/create-demo/create-demo.ts +191 -0
  5. package/create-demo/template/core/CLAUDE.md.tmpl +158 -0
  6. package/create-demo/template/core/README.md.tmpl +74 -0
  7. package/create-demo/template/core/ctl/install.sh +27 -0
  8. package/create-demo/template/core/ctl/lib.sh +146 -0
  9. package/create-demo/template/core/ctl/mode.sh +19 -0
  10. package/create-demo/template/core/ctl/push.sh +16 -0
  11. package/create-demo/template/core/ctl/site.example.env +30 -0
  12. package/create-demo/template/core/ctl/studio.example.env +12 -0
  13. package/create-demo/template/core/gitignore +18 -0
  14. package/create-demo/template/core/scripts/build.sh +30 -0
  15. package/create-demo/template/core/scripts/test-feed.sh +44 -0
  16. package/create-demo/template/dashboard/dashboard/index.html +13 -0
  17. package/create-demo/template/dashboard/dashboard/package.json +22 -0
  18. package/create-demo/template/dashboard/dashboard/src/App.tsx +61 -0
  19. package/create-demo/template/dashboard/dashboard/src/api/types.ts +38 -0
  20. package/create-demo/template/dashboard/dashboard/src/components/ImagePreview.tsx +23 -0
  21. package/create-demo/template/dashboard/dashboard/src/components/StatusPill.tsx +65 -0
  22. package/create-demo/template/dashboard/dashboard/src/hooks/useAlerts.ts +98 -0
  23. package/create-demo/template/dashboard/dashboard/src/hooks/useDiscovery.ts +88 -0
  24. package/create-demo/template/dashboard/dashboard/src/hooks/useEnv.ts +53 -0
  25. package/create-demo/template/dashboard/dashboard/src/hooks/useLiveComponent.ts +249 -0
  26. package/create-demo/template/dashboard/dashboard/src/lib/workflow.ts +11 -0
  27. package/create-demo/template/dashboard/dashboard/src/main.tsx +12 -0
  28. package/create-demo/template/dashboard/dashboard/src/styles.css +84 -0
  29. package/create-demo/template/dashboard/dashboard/src/vite-env.d.ts +1 -0
  30. package/create-demo/template/dashboard/dashboard/tsconfig.json +18 -0
  31. package/create-demo/template/dashboard/dashboard/vite.config.ts +35 -0
  32. package/create-product/cli.ts +39 -1
  33. package/create-product/features/components.ts +10 -1
  34. package/package.json +1 -1
@@ -0,0 +1,73 @@
1
+ <!-- BEGIN ctl-shared-design v1 -->
2
+ <!--
3
+ This block is the SHARED family design core for Norsk operator consoles,
4
+ single-sourced in @norskvideo/ctl-dev-kit (conventions/DESIGN.family.md). It
5
+ is copied verbatim into every product dashboard's DESIGN.md
6
+ (dashboards/<name>/DESIGN.md) between these fenced markers, and the drift
7
+ gate fails CI if a copy diverges. Do NOT hand-edit a copy: edit the dev-kit
8
+ source and re-sync. The console's own north star, accent, components and
9
+ product rules live OUTSIDE the markers and are yours to own.
10
+ -->
11
+
12
+ ## Family rules (shared by every Norsk operator console — do not edit in place)
13
+
14
+ An operator moving between consoles (a commentary gallery, a playout desk) must
15
+ read on-air, health and interaction identically. These rules and the shared
16
+ palette below hold on every console. The operator-UI principles behind them live
17
+ in norsk-studio `docs/operator-ui-principles.md`; change a principle there first,
18
+ then here.
19
+
20
+ ### Shared palette
21
+
22
+ Status and neutral tokens are the same on every console. Each console's
23
+ frontmatter `colors:` must carry exactly these values (the drift gate checks);
24
+ its `accent` tokens are its own.
25
+
26
+ | Token | Value | Role |
27
+ | --------- | --------- | ----------------------------------------------------------- |
28
+ | `live` | `#ff4557` | On-Air Red: live / on air. The only colour allowed to glow. |
29
+ | `ok` | `#35d29a` | Healthy. |
30
+ | `warn` | `#f4b63f` | At risk. |
31
+ | `crit` | `#ff7a4d` | Failed. |
32
+ | `bg` | `#0d1014` | Background. |
33
+ | `panel` | `#141a21` | Panel tone 1. |
34
+ | `panel-2` | `#1a2129` | Panel tone 2. |
35
+ | `panel-3` | `#202932` | Panel tone 3. |
36
+ | `line` | `#262f3b` | Hairline border. |
37
+ | `ink` | `#e8edf3` | Primary text. |
38
+ | `ink-mut` | `#9aa6b5` | Secondary text. |
39
+ | `ink-dim` | `#8794a3` | Tertiary text; still clears 4.5:1. |
40
+
41
+ ### Family named rules
42
+
43
+ **The Reserved Accent Rule.** The console's accent means _interactive_. Status
44
+ never borrows it: health, metering and channel state live entirely in the
45
+ red/green/amber/coral system. If a swatch of the accent is not clickable or a
46
+ focus state, it is wrong.
47
+
48
+ **The One Glow Rule.** Only On-Air Red is allowed a glow (`box-shadow` bloom),
49
+ and it sits on the console's on-air programme view. Everything else is flat. A
50
+ glow anywhere else dilutes the one signal that must never be missed.
51
+
52
+ **The Tabular Rule.** Any column or readout of numbers (timecode, durations,
53
+ countdowns, meter scales, dB, ports) is `font-variant-numeric: tabular-nums`, so
54
+ digits never jitter as they tick.
55
+
56
+ **The Flat-By-Default Rule.** New surfaces get a tone step and a 1px border.
57
+ Reach for `box-shadow` only for the On-Air glow or the focus ring; never to lift
58
+ a card.
59
+
60
+ **The Immediate-PGM, Honest-OUT Rule.** Show an operator's decision the moment
61
+ it is decided, and keep a separate, true view of what is going out. A desk that
62
+ cuts or takes programme carries two labelled views: **PGM**, which changes the
63
+ instant a cut or take is committed, using a preview the desk already has; and
64
+ **OUT**, the real encoded output with graphics and everything else applied
65
+ downstream, whose transport lag is labelled as expected rather than styled as a
66
+ fault. Only a committed decision is immediate: a guarded action changes nothing
67
+ on PGM until it is confirmed, and a load or a source still arriving shows its
68
+ pending state. Never simulate downstream processing in PGM; OUT is the authority
69
+ on what aired. When both views are present, the ON AIR pill and the One Glow sit
70
+ on PGM, following the gallery tally convention. A desk with only one programme
71
+ view shows the true output and labels its lag.
72
+
73
+ <!-- END ctl-shared-design v1 -->
@@ -5,6 +5,9 @@
5
5
  // tsconfig.base.json, the .gitignore core block, scripts/demo (only a
6
6
  // product with tests/demo.spec.ts is asked for it), publish-docs-site.yml
7
7
  // (only a repo with a docs site may carry it, and never a turnkey)
8
+ // - verbatim marker block + palette: each dashboards/*/DESIGN.md embeds the
9
+ // DESIGN.family.md block, and its frontmatter colors: carry that block's
10
+ // shared palette (no DESIGN.md, no requirement)
8
11
  // - masked per-repo line: checks.yml, bump.yml, publish-docs.yml and the
9
12
  // other dispatching workflows (`product:` key)
10
13
  // - retired: upgrade-latest.yml, sync-dev-kit.yml, sync-ctl-packages.yml,
@@ -255,6 +258,97 @@ function coreProblem(claude: string, canonicalCore: string): string | undefined
255
258
  return `CLAUDE.md fenced core has drifted from @norskvideo/ctl-dev-kit conventions/CLAUDE.core.md (${firstDiffLine(region, canonicalCore.trimEnd())}). ${RESYNC}`;
256
259
  }
257
260
 
261
+ // Each operator console's dashboards/<name>/DESIGN.md embeds the family design
262
+ // block verbatim between these markers, and its frontmatter `colors:` must carry
263
+ // the shared palette that block's table defines. The table is the one source of
264
+ // the expected values, so editing DESIGN.family.md is the only change needed.
265
+ export const DESIGN_BEGIN = "<!-- BEGIN ctl-shared-design v1 -->";
266
+ export const DESIGN_END = "<!-- END ctl-shared-design v1 -->";
267
+ const DESIGN_SOURCE = "@norskvideo/ctl-dev-kit conventions/DESIGN.family.md";
268
+
269
+ export function designDocs(repoRoot: string): string[] {
270
+ const dir = join(repoRoot, "dashboards");
271
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
272
+ return readdirSync(dir, { withFileTypes: true })
273
+ .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, "DESIGN.md")))
274
+ .map((d) => `dashboards/${d.name}/DESIGN.md`)
275
+ .sort();
276
+ }
277
+
278
+ const PALETTE_ROW = /^\|\s*`([a-z0-9-]+)`\s*\|\s*`([^`]+)`\s*\|/gm;
279
+
280
+ export function sharedPalette(canonicalDesign: string): Map<string, string> {
281
+ return new Map([...canonicalDesign.matchAll(PALETTE_ROW)].map((m) => [m[1] as string, m[2] as string]));
282
+ }
283
+
284
+ const unquote = (v: string): string => {
285
+ const m = /^(["'])(.*)\1(\s+#.*)?$/.exec(v);
286
+ return m ? (m[2] as string) : v;
287
+ };
288
+
289
+ // Just enough YAML for a flat `colors:` map in the leading frontmatter: no
290
+ // dependency, since this script ships to products and runs from node_modules.
291
+ export function frontmatterColors(text: string): Map<string, string> | undefined {
292
+ const lines = text.split("\n");
293
+ if (lines[0]?.trim() !== "---") return undefined;
294
+ const close = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
295
+ if (close === -1) return undefined;
296
+ const start = lines.findIndex((l, i) => i < close && /^colors:\s*$/.test(l));
297
+ if (start === -1) return undefined;
298
+ const colors = new Map<string, string>();
299
+ for (const line of lines.slice(start + 1, close)) {
300
+ if (line.trim() === "") continue;
301
+ if (!/^\s/.test(line)) break;
302
+ const m = /^\s+([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line);
303
+ if (m) colors.set(m[1] as string, unquote(m[2] as string));
304
+ }
305
+ return colors;
306
+ }
307
+
308
+ function designBlockProblem(rel: string, text: string, canonicalDesign: string): string | undefined {
309
+ if (text.includes(canonicalDesign.trimEnd())) return undefined;
310
+ const begin = text.indexOf(DESIGN_BEGIN);
311
+ const end = text.indexOf(DESIGN_END);
312
+ if (begin === -1 || end === -1 || end < begin) {
313
+ return `${rel} is missing the '${DESIGN_BEGIN}' / '${DESIGN_END}' marker block. Copy conventions/DESIGN.family.md from @norskvideo/ctl-dev-kit verbatim between the markers.`;
314
+ }
315
+ const region = text.slice(begin, end + DESIGN_END.length);
316
+ return `${rel} fenced family design block has drifted from ${DESIGN_SOURCE} (${firstDiffLine(region, canonicalDesign.trimEnd())}). ${RESYNC}`;
317
+ }
318
+
319
+ function paletteProblems(rel: string, text: string, canonicalDesign: string): string[] {
320
+ const expected = sharedPalette(canonicalDesign);
321
+ if (expected.size === 0) throw new Error("canonical DESIGN.family.md has no shared palette table");
322
+ const colors = frontmatterColors(text);
323
+ if (colors === undefined) {
324
+ return [
325
+ `${rel} has no frontmatter colors: map; it must carry the shared palette tokens (${[...expected.keys()].join(", ")}) from ${DESIGN_SOURCE}.`,
326
+ ];
327
+ }
328
+ const problems: string[] = [];
329
+ for (const [token, want] of expected) {
330
+ const got = colors.get(token);
331
+ if (got === undefined || got === "") {
332
+ problems.push(
333
+ `${rel} frontmatter colors: has no \`${token}\` — the shared palette in ${DESIGN_SOURCE} sets it to ${JSON.stringify(want)}.`,
334
+ );
335
+ } else if (got.toLowerCase() !== want.toLowerCase()) {
336
+ problems.push(
337
+ `${rel} frontmatter colors.${token} is ${JSON.stringify(got)}, but the shared palette in ${DESIGN_SOURCE} sets it to ${JSON.stringify(want)}. Status and neutral tokens are the family's; only accent tokens are the console's own.`,
338
+ );
339
+ }
340
+ }
341
+ return problems;
342
+ }
343
+
344
+ export function designProblems(repoRoot: string, canonicalDesign: string): string[] {
345
+ return designDocs(repoRoot).flatMap((rel) => {
346
+ const text = readFileSync(join(repoRoot, rel), "utf8");
347
+ const block = designBlockProblem(rel, text, canonicalDesign);
348
+ return [...(block ? [block] : []), ...paletteProblems(rel, text, canonicalDesign)];
349
+ });
350
+ }
351
+
258
352
  export interface CanonicalBytes {
259
353
  core: string;
260
354
  /** build/flake.nix — the starter flake a Studio product begins from, and what
@@ -316,6 +410,10 @@ export interface CanonicalBytes {
316
410
  * pins step has no Studio pair or manifest.seed.json, and bumps the norsk
317
411
  * family off the SDK nightly. Optional for the same reason as flakeSdkApp. */
318
412
  bumpSdkApp?: string;
413
+ /** conventions/DESIGN.family.md -- the family design block each
414
+ * dashboards/<name>/DESIGN.md embeds between DESIGN_BEGIN/DESIGN_END, whose
415
+ * palette table its frontmatter colors: must match. */
416
+ design: string;
319
417
  }
320
418
 
321
419
  export const SERVER_KIT_COPIES = [
@@ -927,6 +1025,7 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
927
1025
  for (const p of productLogsProblems(repoRoot)) push(p);
928
1026
  for (const p of bunVersionProblems(repoRoot, canonical.ctlShell)) push(p);
929
1027
  for (const p of ctlPinProblems(repoRoot)) push(p);
1028
+ for (const p of designProblems(repoRoot, canonical.design)) push(p);
930
1029
 
931
1030
  return { ok: problems.length === 0, problems };
932
1031
  }
@@ -958,6 +1057,7 @@ if (import.meta.main) {
958
1057
  flakeSdkApp: readFileSync(join(import.meta.dir, "..", "build", "flake.sdk-app.nix"), "utf8"),
959
1058
  checksSdkApp: readFileSync(join(import.meta.dir, "checks.sdk-app.yml"), "utf8"),
960
1059
  bumpSdkApp: readFileSync(join(import.meta.dir, "bump.sdk-app.yml"), "utf8"),
1060
+ design: readFileSync(join(import.meta.dir, "DESIGN.family.md"), "utf8"),
961
1061
  };
962
1062
  const report = checkDrift(repoRoot, canonical, {
963
1063
  repoName: resolveRepoName(repoRoot),
@@ -15,7 +15,8 @@
15
15
  // sync-ctl-packages) are DELETED
16
16
  // - nix/ctl-shell.nix -> overwrite with canonical (created if absent)
17
17
  // - flake.nix (legacy verbatim copy only) -> the starter, repo's floating ctl pin kept
18
- // - marker-block (CLAUDE core, dprint, .gitignore) -> replace the shared block, keep the rest
18
+ // - marker-block (CLAUDE core, dprint, .gitignore, -> replace the shared block, keep the rest
19
+ // dashboards/*/DESIGN.md family block) (DESIGN.md: appended where absent)
19
20
  // - publish-docs-site.yml -> verbatim; CREATED where the repo has a
20
21
  // docs site and is not a turnkey
21
22
  // Structural per-repo files (root tsconfig.json, deployment/build-image.sh,
@@ -37,8 +38,11 @@ import {
37
38
  CTL_SHELL_PATH,
38
39
  CTL_VERSION_LINE,
39
40
  carriesIntegrationMarker,
41
+ DESIGN_BEGIN,
42
+ DESIGN_END,
40
43
  DOCS_SITE_DIR,
41
44
  DOCS_SITE_WORKFLOW,
45
+ designDocs,
42
46
  END,
43
47
  GITIGNORE_BEGIN,
44
48
  GITIGNORE_END,
@@ -285,6 +289,20 @@ function syncClaude(repoRoot: string, core: string, r: SyncReport): void {
285
289
  writeIfChanged(path, out, "CLAUDE.md", r.written);
286
290
  }
287
291
 
292
+ // dashboards/<name>/DESIGN.md: the family design block is fenced like the CLAUDE
293
+ // core; a console that has none yet gets it appended. The frontmatter palette is
294
+ // the product's to fix (the gate names each token), so it is never rewritten.
295
+ function syncDesign(repoRoot: string, design: string, r: SyncReport): void {
296
+ for (const rel of designDocs(repoRoot)) {
297
+ const path = join(repoRoot, rel);
298
+ const existing = readFileSync(path, "utf8");
299
+ const out =
300
+ replaceMarkerBlock(existing, DESIGN_BEGIN, DESIGN_END, design) ??
301
+ `${existing.replace(/\n*$/, "\n\n")}${design.trimEnd()}\n`;
302
+ writeIfChanged(path, out, rel, r.written);
303
+ }
304
+ }
305
+
288
306
  // .gitignore: the shared block is marker-delimited; repo-specific ignores live
289
307
  // outside it. Replace the block, or prepend it if the repo has none yet (the
290
308
  // one-time migration case).
@@ -489,6 +507,7 @@ export function syncDrift(
489
507
  syncPackageManager(repoRoot, canonical.ctlShell, r);
490
508
  syncFlake(repoRoot, flakeCanon, r);
491
509
  syncClaude(repoRoot, canonical.core, r);
510
+ syncDesign(repoRoot, canonical.design, r);
492
511
  syncGitignore(repoRoot, canonical.gitignoreCore, r);
493
512
  syncDprint(repoRoot, canonical.dprint, r);
494
513
  syncDemoShim(repoRoot, canonical.demoShim, r);
@@ -528,6 +547,7 @@ if (import.meta.main) {
528
547
  flakeSdkApp: readFileSync(join(dir, "..", "build", "flake.sdk-app.nix"), "utf8"),
529
548
  checksSdkApp: readFileSync(join(dir, "checks.sdk-app.yml"), "utf8"),
530
549
  bumpSdkApp: readFileSync(join(dir, "bump.sdk-app.yml"), "utf8"),
550
+ design: readFileSync(join(dir, "DESIGN.family.md"), "utf8"),
531
551
  };
532
552
  const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot), { repoName: resolveRepoName(repoRoot) });
533
553
  if (report.written.length === 0) {
@@ -0,0 +1,191 @@
1
+ // `ctl-dev-kit create-demo` — renders a new customer demo repo: a Studio
2
+ // working directory, the scripts that install it into norsk-ctl, and the docs
3
+ // that make every demo look the same. Unlike create-product, the output is
4
+ // copied once and then owned by the customer: there is no drift gate, no CI and
5
+ // no canonical bytes to stay in step with.
6
+ //
7
+ // The files live under template/ as real files, with `__DEMO__` where the demo's
8
+ // name goes, so they can be read and edited as what they are:
9
+ // template/core/ every demo
10
+ // template/<feature>/ added by --with <feature>
11
+ // Markdown is kept as `<name>.md.tmpl` and emitted as `<name>.md`: as a .md the
12
+ // repo's formatter would rewrite it (`__DEMO__` becomes bold, and the feature
13
+ // markers split its tables). It may carry `<!-- if:<feature> -->` ...
14
+ // `<!-- end:<feature> -->` blocks, kept when that feature is on and dropped
15
+ // when it is off. `gitignore` is emitted as `.gitignore`: npm drops a
16
+ // .gitignore from a published package.
17
+ //
18
+ // The components feature is not a template: it is create-product's components/
19
+ // workspace (its build chain and example node), without the product glue.
20
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
21
+ import { dirname, join, relative } from "node:path";
22
+ import { type GeneratedFile, shapeContext } from "../create-product/create-product.ts";
23
+ import { componentsWorkspaceFiles, EXAMPLE_IDENTIFIER } from "../create-product/features/components.ts";
24
+
25
+ // - dashboard an operator dashboard, built into the workflow's dashboards/
26
+ // and served by Studio at /dashboard/<name>/
27
+ // - components this demo's own Studio nodes, built into the working dir's
28
+ // components/, where norsk-ctl finds and mounts them
29
+ export type DemoFeature = "dashboard" | "components";
30
+
31
+ const ALL_FEATURES: readonly DemoFeature[] = ["dashboard", "components"];
32
+
33
+ export interface CreateDemoOptions {
34
+ /** The demo's name: its workflow, its norsk-ctl instance and its library scope. */
35
+ name: string;
36
+ /** Whose `.ai/<user>/tasks/` the seeded task file goes in. */
37
+ user: string;
38
+ withFeatures?: DemoFeature[];
39
+ }
40
+
41
+ const TOKEN = "__DEMO__";
42
+ const TEMPLATE_DIR = join(import.meta.dir, "template");
43
+
44
+ function resolveFeatures(names: DemoFeature[] | undefined): Set<DemoFeature> {
45
+ for (const name of names ?? []) {
46
+ if (!ALL_FEATURES.includes(name)) {
47
+ throw new Error(`unknown feature ${JSON.stringify(name)} in --with — features: ${ALL_FEATURES.join(", ")}.`);
48
+ }
49
+ }
50
+ return new Set(names ?? []);
51
+ }
52
+
53
+ function* walk(dir: string): Generator<string> {
54
+ for (const entry of readdirSync(dir).sort()) {
55
+ const full = join(dir, entry);
56
+ if (statSync(full).isDirectory()) yield* walk(full);
57
+ else yield full;
58
+ }
59
+ }
60
+
61
+ function featureBlocks(text: string, features: ReadonlySet<DemoFeature>): string {
62
+ return text.replace(/^<!-- if:(\w+) -->\n([\s\S]*?)^<!-- end:\1 -->\n/gm, (_, feature: DemoFeature, body: string) =>
63
+ features.has(feature) ? body : "",
64
+ );
65
+ }
66
+
67
+ function templateFiles(layer: string, name: string, features: ReadonlySet<DemoFeature>): GeneratedFile[] {
68
+ const root = join(TEMPLATE_DIR, layer);
69
+ return [...walk(root)].map((full) => {
70
+ const rel = relative(root, full);
71
+ const markdown = rel.endsWith(".md.tmpl");
72
+ const emitted = rel === "gitignore" ? ".gitignore" : markdown ? rel.slice(0, -".tmpl".length) : rel;
73
+ const raw = readFileSync(full, "utf8");
74
+ const path = emitted.replaceAll(TOKEN, name);
75
+ const content = (markdown ? featureBlocks(raw, features) : raw).replaceAll(TOKEN, name);
76
+ return { path, content, executable: /^(ctl|scripts)\/.*\.sh$/.test(path) };
77
+ });
78
+ }
79
+
80
+ function workflowYaml(name: string, features: ReadonlySet<DemoFeature>): string {
81
+ const example = features.has("components")
82
+ ? `
83
+ # The placeholder from components/: it carries no media, publishes a heartbeat
84
+ # and serves /status. Delete it, and components/src/library/example/, once a
85
+ # real component exists.
86
+ - type: ${EXAMPLE_IDENTIFIER}
87
+ config:
88
+ id: example
89
+ displayName: Example
90
+ heartbeatMs: 1000
91
+ subscriptions: []
92
+ `
93
+ : "";
94
+ const exampleLayout = features.has("components") ? " - { id: example, x: 480, y: 300 }\n" : "";
95
+ return `# ${name}: the starter workflow.
96
+ #
97
+ # srt_in ─► preview
98
+ #
99
+ # srt_in is an SRT listener for a camera or an encoder to send to
100
+ # (scripts/test-feed.sh sends a test picture). preview is a still of its
101
+ # picture, which Studio's editor and the dashboard show. Replace this with the
102
+ # demo's real graph, keeping the comments and the __layout block in step.
103
+ #
104
+ # Site settings (ports, passphrases, destinations) are not set here: they come
105
+ # from ctl/studio.env as Studio environment overrides, <ID>_<FIELD>.
106
+ components:
107
+ - type: input.srt-listener
108
+ config:
109
+ id: srt_in
110
+ displayName: SRT In
111
+ # Published on the host by HOST_PORTS in ctl/site.env; change both together.
112
+ port: 5001
113
+ host: 0.0.0.0
114
+ sourceNames: permissive
115
+ streamIds: [camera]
116
+ subscriptions: []
117
+ - type: output.preview
118
+ config:
119
+ id: preview
120
+ displayName: Preview
121
+ # A JPEG, refreshed: it needs no media ports, so it works in norsk-ctl's
122
+ # docker network mode. moq mode gives live video the same way.
123
+ previewMode: image
124
+ showPreview: true
125
+ subscriptions:
126
+ - source: srt_in
127
+ streams: { type: take-first-stream, filter: [{ media: video }] }
128
+ ${example}__layout:
129
+ - { id: srt_in, x: 0, y: 0 }
130
+ - { id: preview, x: 480, y: 0 }
131
+ ${exampleLayout}`;
132
+ }
133
+
134
+ function taskFile(name: string, features: ReadonlySet<DemoFeature>): string {
135
+ const withs = features.size > 0 ? ` --with ${[...features].join(",")}` : "";
136
+ return `# ${name} demo
137
+
138
+ **Started:** generated by \`ctl-dev-kit create-demo --name ${name}${withs}\`.
139
+
140
+ ## STATUS (current)
141
+
142
+ Scaffold only: the starter workflow (SRT in, image preview) installs and runs.
143
+ Nothing demo-specific is built yet.
144
+
145
+ ### Open / not done
146
+ - TODO: what the customer asked to see.
147
+
148
+ ## Brief
149
+
150
+ TODO: who the customer is, what the demo must show, when and where it is shown,
151
+ and what they take away afterwards.
152
+
153
+ ## Decisions
154
+
155
+ | Decision | Status | Outcome |
156
+ |---|---|---|
157
+
158
+ ## Log
159
+ `;
160
+ }
161
+
162
+ export function planDemo(opts: CreateDemoOptions): GeneratedFile[] {
163
+ // An instance id, a workflow file name and an npm scope all at once.
164
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(opts.name)) {
165
+ throw new Error(
166
+ `invalid demo name ${JSON.stringify(opts.name)} — a lowercase letter, then lowercase letters, digits and hyphens; 1-40 chars (it names the norsk-ctl instance, the workflow and the components' npm scope).`,
167
+ );
168
+ }
169
+ const features = resolveFeatures(opts.withFeatures);
170
+ const { name } = opts;
171
+ const files = [
172
+ ...templateFiles("core", name, features),
173
+ { path: `workdir/studio-save-files/${name}/${name}.yaml`, content: workflowYaml(name, features) },
174
+ { path: `.ai/${opts.user}/tasks/${name}-demo.md`, content: taskFile(name, features) },
175
+ ];
176
+ if (features.has("dashboard")) files.push(...templateFiles("dashboard", name, features));
177
+ if (features.has("components")) {
178
+ files.push(...componentsWorkspaceFiles(shapeContext({ dir: "", name, shape: "backend-turnkey" })));
179
+ }
180
+ return files;
181
+ }
182
+
183
+ export function createDemo(opts: CreateDemoOptions & { dir: string }): { files: string[] } {
184
+ const plan = planDemo(opts);
185
+ for (const file of plan) {
186
+ const path = join(opts.dir, file.path);
187
+ mkdirSync(dirname(path), { recursive: true });
188
+ writeFileSync(path, file.content, { mode: file.executable ? 0o755 : 0o644 });
189
+ }
190
+ return { files: plan.map((f) => f.path) };
191
+ }
@@ -0,0 +1,158 @@
1
+ # __DEMO__: a Norsk Studio demo
2
+
3
+ This repo is a customer demo: a Studio workflow, plus what it needs around it,
4
+ built by us, shown to the customer, then handed over for them to develop. It is
5
+ not a product: there is no CI, no drift gate and no test tiers, and after
6
+ handover the customer owns every file in it. Keep it small enough that someone
7
+ new to Norsk can read the whole thing.
8
+
9
+ It was generated by `ctl-dev-kit create-demo`, so its layout matches every other
10
+ demo. Keep to it: anyone in the team should be able to pick this up and find
11
+ things where they expect.
12
+
13
+ ## Layout
14
+
15
+ | Path | What it is |
16
+ | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
17
+ | `workdir/` | The Studio working directory, staged as-is on the box and mounted at `/data` |
18
+ | `workdir/studio-save-files/__DEMO__/__DEMO__.yaml` | The workflow. Hand-edited and commented; the `__layout` block places nodes in the editor |
19
+ | `ctl/` | Installing into norsk-ctl: `install.sh`, `push.sh`, `mode.sh`, the shared `lib.sh` and the site's settings |
20
+ | `scripts/build.sh` | Builds what the demo adds to Studio into `workdir/` |
21
+ | `scripts/test-feed.sh` | `up`/`down` a test picture into the SRT input |
22
+ <!-- if:dashboard -->
23
+ | `dashboard/` | The operator dashboard, a vite app Studio serves at `/dashboard/__DEMO__/` |
24
+ <!-- end:dashboard -->
25
+ <!-- if:components -->
26
+ | `components/` | This demo's own Studio nodes, the `@__DEMO__/library` library |
27
+ <!-- end:components -->
28
+ | `README.md` | The customer's handover document. Keep it true as the demo changes |
29
+ | `.ai/<user>/tasks/` | Task files: what was asked, what was decided, what is left |
30
+
31
+ ## How it runs
32
+
33
+ norsk-ctl is the only run path, here and on the customer's box. ctl provides
34
+ TLS, ports and Studio's config, so this repo has no compose file, proxy or certs.
35
+ The demo is one instance, `__DEMO__`, launched from the `studio-dev` product
36
+ template with `workdir/` as its working directory.
37
+
38
+ 1. `cp ctl/site.example.env ctl/site.env` and `cp ctl/studio.example.env ctl/studio.env`, then fill them in.
39
+ `site.env` says where the demo runs (here, or `DEMO_HOST=user@box`); `studio.env` holds the workflow's
40
+ site settings.
41
+ 2. `ctl/install.sh` the first time. It builds, stages `workdir/` at `WORKDIR` owned by the norsk-ctl
42
+ user, and launches the instance.
43
+ 3. `ctl/push.sh` after every change. It builds, syncs, relaunches and waits until Studio is up.
44
+ 4. `ctl/mode.sh editor` to change the workflow in Studio's editor; `ctl/mode.sh document` to put it back
45
+ to running on start.
46
+ 5. `scripts/test-feed.sh up` for a picture without a camera.
47
+
48
+ The scripts print the Studio URL. The same scripts serve a local norsk-ctl and
49
+ the customer's box, so what is shown is what was tested.
50
+
51
+ A customer who wants the demo out of norsk-ctl uses
52
+ `norsk-ctl instance export __DEMO__ --to <dir>`, which writes a compose bundle
53
+ with its own `HANDOVER.md`. It refuses a different compose project name (`-p`),
54
+ and it has no proxy: the deployer supplies `NORSK_PUBLIC_BASE_URL` and a
55
+ reverse proxy for `/instance/__DEMO__/studio`.
56
+
57
+ ## Working rules
58
+
59
+ - **The YAML is the source.** Edit `__DEMO__.yaml` by hand, keep its comments,
60
+ and keep `__layout` in step when nodes are added. Saving from Studio's editor
61
+ strips comments and `__layout`; if a change was made there, copy the shape
62
+ back by hand rather than committing the editor's file.
63
+ - **Site settings are not in the YAML.** Ports, passphrases and destinations go
64
+ in `ctl/studio.env` as `<COMPONENT_ID>_<FIELD>=value`, for fields the
65
+ component declares overridable. A `${VAR}` in a YAML value fails validation.
66
+ - **Component ids are an interface.** The dashboard, `studio.env` and the README
67
+ all name them. Rename one everywhere or not at all.
68
+ - **Ports:** let norsk-ctl allocate them. Pin one in `HOST_PORTS` only when a
69
+ sender or a firewall outside the box must agree on it. On a shared box, never
70
+ stop another instance's containers.
71
+ - **Push, then check.** After `ctl/push.sh`, open the URL it prints and look:
72
+ a relaunch that came up is not a workflow that works.
73
+ - **The README is the handover.** When something changes what the customer
74
+ sees or does, change the README in the same commit.
75
+
76
+ ## Gotchas
77
+
78
+ - Under norsk-ctl the working dir must be owned by the norsk-ctl user, and on a
79
+ path it can reach: `/srv/__DEMO__`, not a home directory.
80
+ - `STUDIO_DOCUMENT` does nothing under norsk-ctl, which writes Studio's config
81
+ itself. The document comes from `NODE_CONFIG` in the overrides, which is what
82
+ `ctl/mode.sh` switches.
83
+ - `NODE_CONFIG` replaces the library list norsk-ctl writes, so the overrides
84
+ name every library. `ctl/lib.sh` does this; keep it if you edit it.
85
+ - A fixed document hides the editor's Stop button. Use `ctl/mode.sh editor`.
86
+ - Studio's `/api/files` refuses to overwrite: a write is DELETE then POST.
87
+ - Put a `processor.smartSourceSwitch` with a fallback after inputs, or a source
88
+ dropping resets HLS. Silence-fill audio that comes and goes, so nothing
89
+ renegotiates.
90
+ - A WHEP return carries one audio stream; WHIP clients should DELETE the
91
+ session on pagehide.
92
+ - WebRTC media (WHEP, WHIP) to a viewer needs `NETWORK_MODE=hybrid`. MoQ and
93
+ image previews work in docker mode.
94
+ - The microphone and MoQ/WebTransport need HTTPS. norsk-ctl serves HTTPS.
95
+ - Studio's COOP header stops a Studio-served page painting in a
96
+ `processor.browserOverlay`: use `--disable-features=CrossOriginOpenerPolicy`
97
+ in `customCefArgs`, or a `file:///data/...` URL.
98
+ - Headless browser tests need Debian's chromium: Playwright's own build has no
99
+ H.264.
100
+
101
+ <!-- if:dashboard -->
102
+
103
+ ## Dashboard
104
+
105
+ `dashboard/` is a vite + React app with one page per HTML entry. `scripts/build.sh`
106
+ builds it into `workdir/studio-save-files/__DEMO__/dashboards/`, and Studio serves
107
+ that at `/dashboard/__DEMO__/` beside the workflow. A top-level
108
+ `workdir/dashboards/` is not served.
109
+
110
+ - `src/lib/workflow.ts` is the one place the dashboard names component ids.
111
+ Keep it in step with the YAML.
112
+ - Each page fetches `./env` for `apiBasePath` and `wsBasePath`, so it works
113
+ under any URL prefix. Never hard-code `/live` or a host.
114
+ - The hooks are the dashboard's whole Studio API:
115
+ - `useEnv`: the env.
116
+ - `useDiscovery`: the running components, polled until the workflow is up.
117
+ - `useLiveComponent`: a component's state over its websocket, with
118
+ reconnect, and `sendCommand`.
119
+ - `useAlerts`: a component's alerts, polled.
120
+ - The starter preview is `output.preview` in image mode: a JPEG URL in the
121
+ component's state. For live video, switch the preview to `moq` mode and use a
122
+ MoQ player; livemode and eurovision have one (`moq-player.tsx`).
123
+ - `bun run dev` serves the pages with Studio's routes proxied to `STUDIO_URL`,
124
+ which must be a Studio reachable without the sign-in proxy in front.
125
+ <!-- end:dashboard -->
126
+ <!-- if:components -->
127
+
128
+ ## Custom components
129
+
130
+ `components/` is this demo's own Studio library, `@__DEMO__/library`, with one
131
+ placeholder node, `output.example`. `scripts/build.sh` builds it and copies
132
+ `lib/library/` to `workdir/components/@__DEMO__/library/`. norsk-ctl finds
133
+ packages under the working dir's `components/` and mounts them, and the
134
+ overrides name the library, so Studio loads it.
135
+
136
+ - A component is a directory under `src/library/` with `info.ts` (design time:
137
+ config form, subscriptions), `runtime.ts` (the media nodes and routes) and
138
+ `types.source.yaml` (the schema; `_gen/types.ts` is generated from it). Adding
139
+ one is adding a directory: `index.ts` registers every one it finds.
140
+ - The build order is load-bearing: codegen, then tsc to CommonJS, then
141
+ `copy-yamls.ts`, which writes `types.yaml` beside each runtime and a browser
142
+ bundle of `info.ts`. That bundle must not import react or `@norskvideo/*` as
143
+ bare specifiers; `info-client-externals.test.ts` checks it.
144
+ - Publish a component's state from its constructor: Studio refuses websocket
145
+ connections while a state is undefined.
146
+ - Keep the pure logic in sibling modules, testable with `bun test` and no Studio.
147
+ - Delete `example/` (and its node in the YAML) once a real component exists.
148
+ <!-- end:components -->
149
+
150
+ ## Handover
151
+
152
+ Before the customer takes it:
153
+
154
+ - The README is complete: install, URLs, the workflow block by block, how to
155
+ change it, what it does not do, versions, and how to take it out of norsk-ctl.
156
+ - `ctl/site.example.env` and `ctl/studio.example.env` describe every setting.
157
+ - The task file says what was shown and what is left.
158
+ - Nothing in the repo is a site's secret.