@norskvideo/ctl-sdk 0.1.16 → 0.1.18

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.
@@ -18,6 +18,16 @@ export interface ShotBarProps {
18
18
  graphicBaseUrl?: string;
19
19
  /** Default transition applied to takes. */
20
20
  transition?: Transition;
21
+ /**
22
+ * Compose output resolution the shot `config` rects are expressed in — the
23
+ * denominator for the thumbnail geometry. Pass the live reference so a 720p
24
+ * compose draws the L-bar/PiP box where the graphic frames it; defaults to
25
+ * 1920x1080.
26
+ */
27
+ reference?: {
28
+ width: number;
29
+ height: number;
30
+ };
21
31
  /** Show the editable graphic-URL field on graphic-bearing cards (default true). */
22
32
  editableGraphics?: boolean;
23
33
  /** Send the assembled commands to air. The consumer owns the WebSocket. */
@@ -43,4 +53,4 @@ export interface ShotBarProps {
43
53
  */
44
54
  renderPreviewMonitor?: (selected: ResolvedShot | null) => ReactNode;
45
55
  }
46
- export declare function ShotBar({ shots, pgmShotId, pvwShotId, graphicBaseUrl, transition, editableGraphics, onTake, onPreview, previewing, onCancelPreview, renderProgramMonitor, renderPreviewMonitor, }: ShotBarProps): import("react").JSX.Element;
56
+ export declare function ShotBar({ shots, pgmShotId, pvwShotId, graphicBaseUrl, transition, reference, editableGraphics, onTake, onPreview, previewing, onCancelPreview, renderProgramMonitor, renderPreviewMonitor, }: ShotBarProps): import("react").JSX.Element;
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from "react";
3
- import { graphicResolvable, hideReasonLabel, prepareTake } from "./shot-bar-view-model.js";
3
+ import { graphicResolvable, hideReasonLabel, prepareTake, shotThumbnailBox } from "./shot-bar-view-model.js";
4
4
  import { useUIPrimitives } from "./ui-primitives.js";
5
5
  const FAMILY_BADGE = { geometry: "Geometry", graphic: "Graphic" };
6
- export function ShotBar({ shots, pgmShotId, pvwShotId, graphicBaseUrl, transition, editableGraphics = true, onTake, onPreview, previewing = false, onCancelPreview, renderProgramMonitor, renderPreviewMonitor, }) {
6
+ export function ShotBar({ shots, pgmShotId, pvwShotId, graphicBaseUrl, transition, reference = { width: 1920, height: 1080 }, editableGraphics = true, onTake, onPreview, previewing = false, onCancelPreview, renderProgramMonitor, renderPreviewMonitor, }) {
7
7
  const { Button, Input } = useUIPrimitives();
8
8
  const [selectedId, setSelectedId] = useState(null);
9
9
  const [editedUrls, setEditedUrls] = useState({});
@@ -28,7 +28,7 @@ export function ShotBar({ shots, pgmShotId, pvwShotId, graphicBaseUrl, transitio
28
28
  // preview take handler or a preview monitor renderer). Otherwise it's a dead
29
29
  // pane — a direct-take product has no PVW feed — so give On Air the full row.
30
30
  const showPreview = Boolean(onPreview || renderPreviewMonitor);
31
- return (_jsxs("div", { className: "flex flex-col gap-4 text-zinc-100", children: [_jsxs("div", { className: "grid gap-3", style: { gridTemplateColumns: showPreview ? "minmax(0, 1fr) minmax(0, 1fr)" : "minmax(0, 1fr)" }, children: [showPreview ? (_jsx(Monitor, { label: "Preview", tone: "pvw", shot: findLabel(shots, pvwShotId), render: renderPreviewMonitor ? () => renderPreviewMonitor(selected ?? null) : undefined })) : null, _jsx(Monitor, { label: "Live", tone: "pgm", shot: findLabel(shots, pgmShotId), render: renderProgramMonitor })] }), _jsx("div", { className: "flex gap-3 overflow-x-auto pb-1", children: shots.map((shot) => (_jsx(ShotCard, { shot: shot, selected: shot.id === selectedId, onAir: shot.id === pgmShotId, config: shot.status === "ready" ? shot.config : undefined, onSelect: () => shot.status === "ready" && setSelectedId(shot.id) }, shot.id))) }), selected ? (_jsxs("div", { className: "flex flex-col gap-3 rounded-lg border border-zinc-800 bg-zinc-900/60 p-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [selected.label, previewing ? (_jsx("span", { className: "rounded bg-emerald-600/20 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400", children: "Previewing" })) : null] }), _jsxs("div", { className: "flex gap-2", children: [previewing ? (onCancelPreview ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: onCancelPreview, children: "Cancel" })) : null) : onPreview ? (_jsx(Button, { variant: "ghost", size: "sm", disabled: !takeableGraphic, onClick: () => onPreview(commands, selected), children: "Preview" })) : null, _jsx(Button, { variant: "primary", size: "sm", disabled: !takeableGraphic, onClick: () => onTake(commands, selected), children: "Take" })] })] }), !takeableGraphic ? (_jsx("p", { className: "text-xs text-amber-500/90", children: "This shot's graphic page is a relative path but no engine-reachable graphic base is configured, so it can't be loaded into the overlay. Set an absolute page URL, or configure the overlay base." })) : null, editableGraphics && selected.graphicUrl !== undefined ? (_jsxs("div", { className: "flex flex-col gap-1 text-xs text-zinc-400", children: [_jsx("label", { htmlFor: "shotbar-graphic-url", children: "Graphic page" }), _jsx(Input, { id: "shotbar-graphic-url", type: "text", value: editedUrls[selected.id] ?? selected.graphicUrl, placeholder: selected.graphicUrl, onChange: (e) => setEditedUrls((prev) => ({ ...prev, [selected.id]: e.target.value })) })] })) : null] })) : (_jsx("div", { className: "rounded-lg border border-dashed border-zinc-800 p-4 text-center text-sm text-zinc-500", children: "No shot selected \u2014 pick an available shot above." }))] }));
31
+ return (_jsxs("div", { className: "flex flex-col gap-4 text-zinc-100", children: [_jsxs("div", { className: "grid gap-3", style: { gridTemplateColumns: showPreview ? "minmax(0, 1fr) minmax(0, 1fr)" : "minmax(0, 1fr)" }, children: [showPreview ? (_jsx(Monitor, { label: "Preview", tone: "pvw", shot: findLabel(shots, pvwShotId), render: renderPreviewMonitor ? () => renderPreviewMonitor(selected ?? null) : undefined })) : null, _jsx(Monitor, { label: "Live", tone: "pgm", shot: findLabel(shots, pgmShotId), render: renderProgramMonitor })] }), _jsx("div", { className: "flex gap-3 overflow-x-auto pb-1", children: shots.map((shot) => (_jsx(ShotCard, { shot: shot, selected: shot.id === selectedId, onAir: shot.id === pgmShotId, config: shot.status === "ready" ? shot.config : undefined, reference: reference, onSelect: () => shot.status === "ready" && setSelectedId(shot.id) }, shot.id))) }), selected ? (_jsxs("div", { className: "flex flex-col gap-3 rounded-lg border border-zinc-800 bg-zinc-900/60 p-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [selected.label, previewing ? (_jsx("span", { className: "rounded bg-emerald-600/20 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400", children: "Previewing" })) : null] }), _jsxs("div", { className: "flex gap-2", children: [previewing ? (onCancelPreview ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: onCancelPreview, children: "Cancel" })) : null) : onPreview ? (_jsx(Button, { variant: "ghost", size: "sm", disabled: !takeableGraphic, onClick: () => onPreview(commands, selected), children: "Preview" })) : null, _jsx(Button, { variant: "primary", size: "sm", disabled: !takeableGraphic, onClick: () => onTake(commands, selected), children: "Take" })] })] }), !takeableGraphic ? (_jsx("p", { className: "text-xs text-amber-500/90", children: "This shot's graphic page is a relative path but no engine-reachable graphic base is configured, so it can't be loaded into the overlay. Set an absolute page URL, or configure the overlay base." })) : null, editableGraphics && selected.graphicUrl !== undefined ? (_jsxs("div", { className: "flex flex-col gap-1 text-xs text-zinc-400", children: [_jsx("label", { htmlFor: "shotbar-graphic-url", children: "Graphic page" }), _jsx(Input, { id: "shotbar-graphic-url", type: "text", value: editedUrls[selected.id] ?? selected.graphicUrl, placeholder: selected.graphicUrl, onChange: (e) => setEditedUrls((prev) => ({ ...prev, [selected.id]: e.target.value })) })] })) : null] })) : (_jsx("div", { className: "rounded-lg border border-dashed border-zinc-800 p-4 text-center text-sm text-zinc-500", children: "No shot selected \u2014 pick an available shot above." }))] }));
32
32
  }
33
33
  function findLabel(shots, id) {
34
34
  if (!id)
@@ -40,28 +40,25 @@ function Monitor({ label, tone, shot, render, }) {
40
40
  const chip = tone === "pgm" ? "bg-red-600 text-white" : "bg-emerald-600 text-white";
41
41
  return (_jsxs("div", { className: `relative overflow-hidden rounded-lg border-2 ${ring} bg-black`, children: [_jsx("span", { className: `absolute left-2 top-2 z-10 rounded px-1.5 py-0.5 text-[10px] font-semibold ${chip}`, children: label }), _jsx("div", { className: "flex aspect-video items-center justify-center", children: render ? render() : _jsx("span", { className: "text-xs text-zinc-500", children: shot ?? "—" }) })] }));
42
42
  }
43
- function ShotCard({ shot, selected, onAir, config, onSelect, }) {
43
+ function ShotCard({ shot, selected, onAir, config, reference, onSelect, }) {
44
44
  const hidden = shot.status === "hidden";
45
45
  const border = onAir ? "border-red-600" : selected ? "border-sky-500" : "border-zinc-800 hover:border-zinc-600";
46
- return (_jsxs("button", { type: "button", onClick: onSelect, disabled: hidden, title: hidden ? hideReasonLabel(shot.reason) : shot.label, className: `flex w-40 shrink-0 flex-col gap-2 rounded-lg border ${border} bg-zinc-900 p-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40`, children: [_jsx(ShotThumbnail, { preset: shot.status === "ready" ? shot.preset : "fullscreen", config: config }), _jsxs("div", { className: "flex items-center justify-between gap-1", children: [_jsx("span", { className: "truncate text-xs font-medium text-zinc-100", children: shot.label }), shot.status === "ready" ? (_jsx("span", { className: "shrink-0 rounded bg-zinc-800 px-1 text-[9px] uppercase tracking-wide text-zinc-400", children: FAMILY_BADGE[shot.family] })) : null] }), hidden ? (_jsx("span", { className: "text-[10px] leading-tight text-amber-500/80", children: hideReasonLabel(shot.reason) })) : null] }));
46
+ return (_jsxs("button", { type: "button", onClick: onSelect, disabled: hidden, title: hidden ? hideReasonLabel(shot.reason) : shot.label, className: `flex w-40 shrink-0 flex-col gap-2 rounded-lg border ${border} bg-zinc-900 p-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40`, children: [_jsx(ShotThumbnail, { preset: shot.status === "ready" ? shot.preset : "fullscreen", config: config, reference: reference }), _jsxs("div", { className: "flex items-center justify-between gap-1", children: [_jsx("span", { className: "truncate text-xs font-medium text-zinc-100", children: shot.label }), shot.status === "ready" ? (_jsx("span", { className: "shrink-0 rounded bg-zinc-800 px-1 text-[9px] uppercase tracking-wide text-zinc-400", children: FAMILY_BADGE[shot.family] })) : null] }), hidden ? (_jsx("span", { className: "text-[10px] leading-tight text-amber-500/80", children: hideReasonLabel(shot.reason) })) : null] }));
47
47
  }
48
48
  // A truthful 16:9 schematic of what the preset does — programme fill plus the
49
- // preset's overlay/box geometry, read from config where it's positional.
50
- function ShotThumbnail({ preset, config }) {
51
- const c = (config ?? {});
52
- const pct = (v, of) => `${Math.max(0, Math.min(100, (v / of) * 100))}%`;
49
+ // preset's overlay/box geometry, read from config where it's positional. The
50
+ // positional box is scaled against `reference` (the compose output space the
51
+ // config px live in), not a fixed canvas — see shotThumbnailBox.
52
+ function ShotThumbnail({ preset, config, reference, }) {
53
+ const box = shotThumbnailBox(preset, config, reference);
54
+ const p = (n) => (n == null ? undefined : `${n}%`);
53
55
  const prog = _jsx("div", { className: "absolute inset-0 bg-sky-700/50" });
54
56
  let overlay = null;
55
- if (preset === "pip") {
56
- overlay = (_jsx("div", { className: "absolute rounded-sm bg-amber-400/80", style: { right: "8%", bottom: "10%", width: pct(c.width ?? 384, 1920), height: pct(c.height ?? 270, 1080) } }));
57
+ if (preset === "pip" && box) {
58
+ overlay = (_jsx("div", { className: "absolute rounded-sm bg-amber-400/80", style: { right: p(box.right), bottom: p(box.bottom), width: p(box.width), height: p(box.height) } }));
57
59
  }
58
- else if (preset === "lbar") {
59
- overlay = (_jsx("div", { className: "absolute rounded-sm bg-sky-700/70 ring-1 ring-sky-300/40", style: {
60
- left: pct(c.videoX ?? 480, 1920),
61
- top: pct(c.videoY ?? 0, 1080),
62
- width: pct(c.videoWidth ?? 1440, 1920),
63
- height: pct(c.videoHeight ?? 810, 1080),
64
- } }));
60
+ else if (preset === "lbar" && box) {
61
+ overlay = (_jsx("div", { className: "absolute rounded-sm bg-sky-700/70 ring-1 ring-sky-300/40", style: { left: p(box.left), top: p(box.top), width: p(box.width), height: p(box.height) } }));
65
62
  }
66
63
  else if (preset === "side-by-side") {
67
64
  return (_jsxs("div", { className: "relative aspect-video w-full overflow-hidden rounded bg-zinc-950", children: [_jsx("div", { className: "absolute inset-y-0 left-0 w-1/2 bg-sky-700/50" }), _jsx("div", { className: "absolute inset-y-0 right-0 w-1/2 bg-emerald-700/50" })] }));
@@ -27,6 +27,33 @@ export declare function resolveGraphicUrl(url: string, base: string | undefined)
27
27
  * rather than fire a `change-url` the media container can't fetch.
28
28
  */
29
29
  export declare function graphicResolvable(graphicUrl: string | undefined, base: string | undefined): boolean;
30
+ /** The overlay box a positional preset draws in the ShotCard thumbnail, as
31
+ * percentages (0..100) of the 16:9 card. Corner-anchored presets use
32
+ * right/bottom; rect presets use left/top. */
33
+ export interface ThumbBox {
34
+ left?: number;
35
+ top?: number;
36
+ right?: number;
37
+ bottom?: number;
38
+ width: number;
39
+ height: number;
40
+ }
41
+ /**
42
+ * The overlay box for a shot's thumbnail schematic, or `null` for presets whose
43
+ * thumbnail is a fixed schematic with no config-driven geometry (side-by-side,
44
+ * quarters, overlay, lower-third, fullscreen).
45
+ *
46
+ * A shot's `config` rect is absolute px in the compose OUTPUT space, so the box
47
+ * MUST be divided by that same `reference` resolution — NOT a fixed 1080p canvas.
48
+ * A 720p compose (config scaled to 1280x720) drew the L-bar window at 16.7%/50%
49
+ * against a hardcoded 1920x1080, landing it off the top-left of where the
50
+ * graphic frames it. Absent config fields fall back to the shipped demo geometry
51
+ * as fixed percentages, so a bare deployment is unchanged at any resolution.
52
+ */
53
+ export declare function shotThumbnailBox(preset: string, config: Record<string, unknown> | undefined, reference: {
54
+ width: number;
55
+ height: number;
56
+ }): ThumbBox | null;
30
57
  export interface PrepareTakeOptions {
31
58
  /**
32
59
  * Engine-reachable base to resolve a relative graphic path against — the
@@ -69,6 +69,46 @@ export function graphicResolvable(graphicUrl, base) {
69
69
  return true;
70
70
  return resolveGraphicUrl(graphicUrl, base) !== null;
71
71
  }
72
+ /**
73
+ * The overlay box for a shot's thumbnail schematic, or `null` for presets whose
74
+ * thumbnail is a fixed schematic with no config-driven geometry (side-by-side,
75
+ * quarters, overlay, lower-third, fullscreen).
76
+ *
77
+ * A shot's `config` rect is absolute px in the compose OUTPUT space, so the box
78
+ * MUST be divided by that same `reference` resolution — NOT a fixed 1080p canvas.
79
+ * A 720p compose (config scaled to 1280x720) drew the L-bar window at 16.7%/50%
80
+ * against a hardcoded 1920x1080, landing it off the top-left of where the
81
+ * graphic frames it. Absent config fields fall back to the shipped demo geometry
82
+ * as fixed percentages, so a bare deployment is unchanged at any resolution.
83
+ */
84
+ export function shotThumbnailBox(preset, config, reference) {
85
+ const c = config ?? {};
86
+ const num = (k) => (typeof c[k] === "number" ? c[k] : undefined);
87
+ const pct = (v, of) => Math.max(0, Math.min(100, (v / of) * 100));
88
+ if (preset === "lbar") {
89
+ const vx = num("videoX");
90
+ const vy = num("videoY");
91
+ const vw = num("videoWidth");
92
+ const vh = num("videoHeight");
93
+ return {
94
+ left: vx != null ? pct(vx, reference.width) : 25,
95
+ top: vy != null ? pct(vy, reference.height) : 0,
96
+ width: vw != null ? pct(vw, reference.width) : 75,
97
+ height: vh != null ? pct(vh, reference.height) : 75,
98
+ };
99
+ }
100
+ if (preset === "pip") {
101
+ const w = num("width");
102
+ const h = num("height");
103
+ return {
104
+ right: 8,
105
+ bottom: 10,
106
+ width: w != null ? pct(w, reference.width) : 20,
107
+ height: h != null ? pct(h, reference.height) : 25,
108
+ };
109
+ }
110
+ return null;
111
+ }
72
112
  /**
73
113
  * Assemble the exact `TakeCommand[]` for a shot: pick the graphic URL (edited
74
114
  * over manifest), resolve it against the engine-reachable base, and hand
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -49,6 +49,10 @@ export function parseProductTemplatesFile(raw, path) {
49
49
  rec.sha256 = entry.sha256;
50
50
  if (isRecord(entry.launchDefaults))
51
51
  rec.launchDefaults = entry.launchDefaults;
52
+ if (isRecord(entry.parameterDefaults) &&
53
+ Object.values(entry.parameterDefaults).every((v) => typeof v === "string")) {
54
+ rec.parameterDefaults = entry.parameterDefaults;
55
+ }
52
56
  if (Array.isArray(entry.suggestedSecurityGroups) &&
53
57
  entry.suggestedSecurityGroups.every((l) => typeof l === "string")) {
54
58
  rec.suggestedSecurityGroups = entry.suggestedSecurityGroups;
@@ -49,6 +49,14 @@ export interface ProductTemplateRecord {
49
49
  * authoritatively by the worker at launch. Rides launches as
50
50
  * template_launch_defaults_json. */
51
51
  launchDefaults?: Record<string, unknown>;
52
+ /** Template-author parameter tier (env-var authoring): name → default
53
+ * value. A name the product declared overrides the manifest default;
54
+ * an undeclared name ADDS a template-defined variable. Fused into every
55
+ * launch's parameter map beneath the operator's instance values —
56
+ * instance wins, template default wins over manifest default. Names
57
+ * must be env-var shaped and never `NORSK_`-prefixed (that namespace is
58
+ * infra-injected: NORSK_RUNTIME_ROLE, NORSK_MATCHED_CAPABILITIES, …). */
59
+ parameterDefaults?: Record<string, string>;
52
60
  /** Template-author security-group suggestions, by discovery-tag LABEL
53
61
  * (portable across deployments; ids are not). The launch UI pre-ticks
54
62
  * discovered groups whose label matches — a suggestion the operator