@nodaro/shared 3.10.0 → 3.12.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 (72) hide show
  1. package/dist/index.cjs +2242 -86
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1779 -32
  4. package/dist/index.d.ts +1779 -32
  5. package/dist/index.js +2041 -87
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/caption-styles.test.ts +207 -0
  9. package/src/__tests__/edl-multicam.test.ts +304 -0
  10. package/src/__tests__/edl.test.ts +822 -0
  11. package/src/__tests__/fan-out-rows.test.ts +208 -0
  12. package/src/__tests__/instagram-scrape.test.ts +66 -0
  13. package/src/__tests__/llm-models.test.ts +48 -11
  14. package/src/__tests__/meta-ads-scrape.test.ts +284 -0
  15. package/src/__tests__/node-runtime-keys.test.ts +15 -0
  16. package/src/__tests__/parameter-node-value.test.ts +13 -1
  17. package/src/__tests__/presentation-utils.test.ts +67 -0
  18. package/src/__tests__/producer-types.test.ts +19 -0
  19. package/src/__tests__/schedule-rules.test.ts +265 -0
  20. package/src/__tests__/speaker-layouts.test.ts +203 -0
  21. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  22. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  23. package/src/__tests__/trigger-feeds.test.ts +39 -0
  24. package/src/__tests__/video-analysis.test.ts +15 -0
  25. package/src/__tests__/video-duration-auto.test.ts +65 -0
  26. package/src/__tests__/video-duration.test.ts +56 -0
  27. package/src/__tests__/video-frame-fit.test.ts +189 -0
  28. package/src/__tests__/video-link.test.ts +137 -0
  29. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  30. package/src/caption-styles.ts +240 -0
  31. package/src/catalog-projection.ts +3 -0
  32. package/src/character-motion-metadata.ts +19 -0
  33. package/src/credit-identifiers.ts +31 -0
  34. package/src/edit-plan-contract.ts +96 -0
  35. package/src/edl-multicam.ts +185 -0
  36. package/src/edl.ts +747 -0
  37. package/src/entity-image-handle.ts +24 -1
  38. package/src/fan-out-rows.ts +213 -0
  39. package/src/i18n/character-motion.ar.ts +126 -75
  40. package/src/i18n/character-motion.de.ts +126 -75
  41. package/src/i18n/character-motion.es.ts +126 -75
  42. package/src/i18n/character-motion.fr.ts +126 -75
  43. package/src/i18n/character-motion.he.ts +126 -75
  44. package/src/i18n/character-motion.hi.ts +126 -75
  45. package/src/i18n/character-motion.ja.ts +126 -75
  46. package/src/i18n/character-motion.ko.ts +126 -75
  47. package/src/i18n/character-motion.pt-BR.ts +126 -75
  48. package/src/i18n/character-motion.ru.ts +126 -75
  49. package/src/i18n/character-motion.zh-CN.ts +126 -75
  50. package/src/index.ts +211 -3
  51. package/src/instagram-scrape.ts +204 -0
  52. package/src/llm-models.ts +80 -3
  53. package/src/meta-ads-scrape.ts +463 -0
  54. package/src/model-catalog.ts +48 -5
  55. package/src/model-constants.ts +148 -5
  56. package/src/node-mappable-fields.ts +2 -0
  57. package/src/node-runtime-keys.ts +28 -0
  58. package/src/parameter-node-value.ts +31 -5
  59. package/src/presentation-utils.ts +49 -0
  60. package/src/producer-types.ts +20 -0
  61. package/src/schedule-rules.ts +484 -0
  62. package/src/speaker-layouts.ts +220 -0
  63. package/src/transcribe-preflight.ts +101 -0
  64. package/src/trigger-feeds.ts +59 -0
  65. package/src/trigger-node-types.ts +20 -0
  66. package/src/video-analysis.ts +15 -0
  67. package/src/video-duration-auto.ts +18 -0
  68. package/src/video-duration.ts +32 -0
  69. package/src/video-frame-fit.ts +228 -0
  70. package/src/video-link.ts +167 -0
  71. package/src/video-output-canvas.ts +119 -0
  72. package/src/workflow-export.ts +37 -1
@@ -1,3 +1,5 @@
1
+ import type { SupportedFontName } from "./supported-fonts.js"
2
+
1
3
  /**
2
4
  * Caption styles for the add-captions node. Static path uses FFmpeg drawtext;
3
5
  * kinetic styles render via Remotion (BurnCaptions composition).
@@ -29,3 +31,241 @@ const KINETIC_SET = new Set<string>(KINETIC_CAPTION_STYLES)
29
31
  export function isKineticCaptionStyle(style: string | undefined | null): style is KineticCaptionStyle {
30
32
  return style !== null && style !== undefined && KINETIC_SET.has(style)
31
33
  }
34
+
35
+ // ── Named "looks" ──────────────────────────────────────────────────────────
36
+ // A look is a bundle of visual levers so a caption reads well with one field
37
+ // instead of eight. Shared because the ids are wire contract (route Zod, MCP
38
+ // schema, SDK type) and the value table is consumed by BOTH the worker (render)
39
+ // and the canvas preview — deliberately given away, not creative doctrine.
40
+ export const CAPTION_LOOK_IDS = ["outline", "clean"] as const
41
+ export type CaptionLookId = (typeof CAPTION_LOOK_IDS)[number]
42
+
43
+ /** What an unset `look` means on a KINETIC style. ONE-LINE FLIP: set to "clean"
44
+ * to make an unset caption render as the pre-look-system lever set (face pinned)
45
+ * instead. */
46
+ export const DEFAULT_CAPTION_LOOK: CaptionLookId = "outline"
47
+
48
+ /** What an unset `look` means on the static `subtitle` style: the plain read —
49
+ * a pinned neutral sans, no outline, no casing. A subtitle must never be left
50
+ * with NO face: the Remotion render would fall back to headless Chrome's default
51
+ * SERIF, so adding e.g. a stroke to a subtitle would silently flip its font away
52
+ * from the sans the plain FFmpeg subtitle draws. */
53
+ export const DEFAULT_SUBTITLE_LOOK: CaptionLookId = "clean"
54
+
55
+ /**
56
+ * The lever field names that are MEANINGLESS on a `subtitle` render and so are
57
+ * rejected on it: `highlightColor` (subtitle has no per-word spoken cursor to
58
+ * colour) and `animate` (subtitle has no motion to switch off). The STYLING
59
+ * levers (look/fontFamily/fontWeight/strokeColor/strokeWidth/uppercase/positionY)
60
+ * are NOT here any more — a `subtitle` carrying any of them now routes to the
61
+ * Remotion renderer (see `captionRoutesToRemotion`), which applies them exactly
62
+ * as it does for the kinetic styles. Single source of truth for the route's
63
+ * reject-on-subtitle guard and the frontend's "don't send a stale lever" strip.
64
+ * `color`/`backgroundColor` are deliberately absent — FFmpeg subtitle honours
65
+ * those too.
66
+ */
67
+ export const KINETIC_ONLY_CAPTION_LEVER_KEYS = [
68
+ "highlightColor",
69
+ "animate",
70
+ ] as const
71
+ export type KineticOnlyCaptionLeverKey = (typeof KINETIC_ONLY_CAPTION_LEVER_KEYS)[number]
72
+
73
+ /**
74
+ * Does an add-captions request need the Remotion renderer, vs the cheap static
75
+ * FFmpeg drawtext path? A caption routes to Remotion when it needs anything the
76
+ * one-fixed-string drawtext pass cannot do:
77
+ * - per-segment treatments (`segments`),
78
+ * - a kinetic style,
79
+ * - any STYLING lever (look/font/weight/stroke/uppercase/position_y/
80
+ * max_words_per_line) — FFmpeg drawtext can't apply a webfont face, weight,
81
+ * outline, casing, a free vertical position, or line grouping,
82
+ * - TIMED captions (a wired `transcript` or an explicit `captions[]` array),
83
+ * - auto-transcription, i.e. no `text` to burn as one static block.
84
+ * Plain-`text` `subtitle` with no lever stays on FFmpeg (unchanged, cheap).
85
+ *
86
+ * SINGLE SOURCE for BOTH the worker dispatch (handleAddCaptions) AND the credit
87
+ * id (buildAddCaptionsCreditId) so the renderer and the price never drift: a
88
+ * Remotion render bills as `add-captions:kinetic`, a plain drawtext burn as
89
+ * `add-captions`.
90
+ */
91
+ export function captionRoutesToRemotion(input: {
92
+ style?: string | null
93
+ text?: string | null
94
+ segments?: readonly unknown[] | null
95
+ transcript?: unknown
96
+ captions?: readonly unknown[] | null
97
+ look?: unknown
98
+ fontFamily?: unknown
99
+ fontWeight?: unknown
100
+ strokeColor?: unknown
101
+ strokeWidth?: unknown
102
+ uppercase?: unknown
103
+ positionY?: unknown
104
+ maxWordsPerLine?: unknown
105
+ }): boolean {
106
+ if (input.segments && input.segments.length > 0) return true
107
+ if (isKineticCaptionStyle(input.style)) return true
108
+ // From here the style is `subtitle` (or unset → the subtitle default).
109
+ // `null` is "not set", exactly like `undefined`: stored node JSON (an agent's
110
+ // write, an import, a cleared field) carries nulls, and a null lever that
111
+ // counted as a lever would route a plain subtitle to Remotion — and its price
112
+ // — for a lever nobody chose.
113
+ const isSet = (v: unknown): boolean => v !== undefined && v !== null
114
+ const hasStylingLever =
115
+ isSet(input.look) ||
116
+ isSet(input.fontFamily) ||
117
+ isSet(input.fontWeight) ||
118
+ isSet(input.strokeColor) ||
119
+ isSet(input.strokeWidth) ||
120
+ isSet(input.uppercase) ||
121
+ isSet(input.positionY) ||
122
+ isSet(input.maxWordsPerLine)
123
+ if (hasStylingLever) return true
124
+ if (input.transcript !== undefined && input.transcript !== null) return true
125
+ if (input.captions && input.captions.length > 0) return true
126
+ // No `text` to burn as one static block → the only caption source is
127
+ // transcription, which produces TIMED captions the drawtext pass can't show.
128
+ if (!input.text) return true
129
+ return false
130
+ }
131
+
132
+ /**
133
+ * `maxWordsPerLine` — caps how many words a caption LINE (or tiktok-words page)
134
+ * may hold, on top of the frame-width budget, sentence ends and pauses that
135
+ * already close a line. 1–2 gives the punchy CapCut read; unset = fit the width.
136
+ * Applies to every line/page-grouped render (word-highlight, karaoke, bouncy,
137
+ * tiktok-words, and a Remotion-rendered subtitle); inert on word-pop (always one
138
+ * word). Bounds single-sourced here for the route Zod, the plan schema, the MCP
139
+ * schema, the CLI and the canvas panel.
140
+ */
141
+ export const CAPTION_MAX_WORDS_PER_LINE_MIN = 1
142
+ export const CAPTION_MAX_WORDS_PER_LINE_MAX = 20
143
+
144
+ /**
145
+ * Numeric caption levers and their wire bounds — the SAME limits the route Zod
146
+ * and the render-plan schema enforce. Single-sourced so the coercion below and
147
+ * those schemas cannot disagree (a guard test pins the route to these).
148
+ */
149
+ export const CAPTION_LEVER_BOUNDS = {
150
+ fontSize: { min: 12, max: 200 },
151
+ strokeWidth: { min: 0, max: 40 },
152
+ positionY: { min: 0, max: 100 },
153
+ fontWeight: { min: 100, max: 900 },
154
+ maxWordsPerLine: { min: CAPTION_MAX_WORDS_PER_LINE_MIN, max: CAPTION_MAX_WORDS_PER_LINE_MAX },
155
+ } as const
156
+
157
+ type CaptionNumericLeverKey = keyof typeof CAPTION_LEVER_BOUNDS
158
+ const CAPTION_NUMERIC_LEVER_KEYS = Object.keys(CAPTION_LEVER_BOUNDS) as CaptionNumericLeverKey[]
159
+
160
+ /**
161
+ * COERCE, never reject: bring the numeric caption levers of node data that
162
+ * never passed a Zod (a workflow written by an agent, an import, a template, a
163
+ * FieldMapping) into the range the render plan accepts. Without this an
164
+ * out-of-range value only surfaces when the plan schema throws — mid-run, after
165
+ * credits are reserved. A `null` / non-finite / non-numeric value is DROPPED (the
166
+ * render default applies); an out-of-range one is clamped; `fontWeight` snaps to the
167
+ * nearest 100 and `maxWordsPerLine` to a whole number. Pure; returns a copy and
168
+ * leaves every other field untouched. Applied by payload-builder to the node's
169
+ * top level and to each `segments[]` entry.
170
+ */
171
+ export function normalizeCaptionNumericLevers<T extends Record<string, unknown>>(input: T): T {
172
+ const out: Record<string, unknown> = { ...input }
173
+ for (const key of CAPTION_NUMERIC_LEVER_KEYS) {
174
+ if (!(key in out) || out[key] === undefined) continue
175
+ // `null` is "not set": drop it rather than carry it — the render plan's
176
+ // numeric schema rejects a null, mid-run, after credits are reserved.
177
+ if (out[key] === null) {
178
+ delete out[key]
179
+ continue
180
+ }
181
+ const raw = typeof out[key] === "string" && (out[key] as string).trim() !== "" ? Number(out[key]) : out[key]
182
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
183
+ delete out[key]
184
+ continue
185
+ }
186
+ const { min, max } = CAPTION_LEVER_BOUNDS[key]
187
+ const shaped = key === "fontWeight" ? Math.round(raw / 100) * 100 : key === "maxWordsPerLine" ? Math.round(raw) : raw
188
+ out[key] = Math.min(max, Math.max(min, shaped))
189
+ }
190
+ return out as T
191
+ }
192
+
193
+ /** The concrete levers a look (and any explicit override) resolves to. */
194
+ export interface CaptionLookLevers {
195
+ fontFamily?: SupportedFontName
196
+ fontWeight?: number
197
+ color?: string
198
+ backgroundColor?: string
199
+ strokeColor?: string
200
+ strokeWidth?: number
201
+ highlightColor?: string
202
+ uppercase?: boolean
203
+ }
204
+
205
+ /** Outline width = 10% of font size (min 2px). `paint-order: stroke fill` puts
206
+ * half the stroke OUTSIDE the glyph, so the visible rim is ~5% of font size. */
207
+ export function autoStrokeWidth(fontSize: number): number {
208
+ return Math.max(2, Math.round(fontSize * 0.1))
209
+ }
210
+
211
+ /** Each look is a function of font size (so the outline tracks the text size). */
212
+ export const CAPTION_LOOKS: Record<CaptionLookId, (fontSize: number) => CaptionLookLevers> = {
213
+ // The TikTok / CapCut read: heavy geometric sans, caps, white on a thick black
214
+ // outline, yellow spoken word.
215
+ outline: (fs) => ({
216
+ fontFamily: "Montserrat",
217
+ fontWeight: 900,
218
+ uppercase: true,
219
+ color: "#ffffff",
220
+ strokeColor: "#000000",
221
+ strokeWidth: autoStrokeWidth(fs),
222
+ highlightColor: "#FFE600",
223
+ }),
224
+ // The pre-look lever set with the face pinned (it never was): per-style weight,
225
+ // soft shadow only, no casing, no outline.
226
+ clean: () => ({ fontFamily: "Inter", color: "#ffffff" }),
227
+ }
228
+
229
+ /**
230
+ * Resolve a look + explicit overrides into concrete levers. An explicit lever
231
+ * always wins over the look; `strokeWidth: 0` explicitly means "no outline".
232
+ * The plan then carries concrete levers only — nothing to resolve at render.
233
+ */
234
+ export function resolveCaptionLook(
235
+ look: CaptionLookId | undefined,
236
+ explicit: CaptionLookLevers,
237
+ fontSize: number,
238
+ ): CaptionLookLevers {
239
+ // COERCE, never throw. The route Zod rejects a bad `look`, but the orchestrator /
240
+ // authored-JSON / import / Copilot paths write `look` straight onto node data with
241
+ // no validation (payload-builder passes `look: data.look` verbatim — the CLAUDE.md
242
+ // pitfall 5b class). An unknown id here would throw AFTER a paid transcription and
243
+ // fail the whole run, so an out-of-vocabulary look falls back to the default preset.
244
+ const preset = CAPTION_LOOKS[look ?? DEFAULT_CAPTION_LOOK] ?? CAPTION_LOOKS[DEFAULT_CAPTION_LOOK]
245
+ const out: CaptionLookLevers = { ...preset(fontSize) }
246
+ for (const k of Object.keys(explicit) as (keyof CaptionLookLevers)[]) {
247
+ if (explicit[k] !== undefined) (out[k] as CaptionLookLevers[typeof k]) = explicit[k]
248
+ }
249
+ return out
250
+ }
251
+
252
+ /**
253
+ * Resolve the concrete render levers for a caption, applying the DEFAULT look the
254
+ * way each STYLE expects: an unset `look` means `outline` on a kinetic style (the
255
+ * TikTok/CapCut read) and `clean` on the static `subtitle` (the plain read — a
256
+ * pinned neutral sans, no outline, no casing). So a subtitle that routes to
257
+ * Remotion never inherits the outline house-style unless asked, AND is never left
258
+ * with no face at all (which renders as headless Chrome's default serif). A named
259
+ * look always wins; explicit levers override either. SINGLE SOURCE for the worker
260
+ * top-level levers, the per-segment resolver, and the frontend config/preview
261
+ * mirror, so the per-style default can't drift between them.
262
+ */
263
+ export function resolveCaptionLevers(
264
+ style: string | undefined | null,
265
+ look: CaptionLookId | undefined,
266
+ explicit: CaptionLookLevers,
267
+ fontSize: number,
268
+ ): CaptionLookLevers {
269
+ const effective = look ?? (isKineticCaptionStyle(style) ? DEFAULT_CAPTION_LOOK : DEFAULT_SUBTITLE_LOOK)
270
+ return resolveCaptionLook(effective, explicit, fontSize)
271
+ }
@@ -1,3 +1,4 @@
1
+ import type { CharacterMotionMetadata } from "./character-motion-metadata.js"
1
2
  /**
2
3
  * Tag-free, policy-free wire shape for the `GET /v1/catalogs` projection — the
3
4
  * server-driven, pack-composed catalog view thin clients render their own
@@ -21,6 +22,8 @@ export interface ProjectedCatalogOption {
21
22
  */
22
23
  term?: string
23
24
  icon?: string
25
+ /** Authored Character Motion prerequisites and sequence state. Missing means unknown. */
26
+ motion?: CharacterMotionMetadata
24
27
  }
25
28
 
26
29
  export interface ProjectedCatalogDimension {
@@ -0,0 +1,19 @@
1
+ /** Structural public catalog API metadata. Authored values remain in @nodaro/prompts. */
2
+ export interface CharacterMotionMetadata {
3
+ /** Search terms, including previous display names. IDs remain stable. */
4
+ readonly aliases?: readonly string[]
5
+ /** Hidden from new choices; saved workflows still resolve this entry. */
6
+ readonly deprecated?: true
7
+ readonly replacementId?: string
8
+ /** Authored prerequisites; omission is unknown, never a compatibility claim. */
9
+ readonly requires?: readonly string[]
10
+ readonly startPose?: "standing" | "seated" | "floor" | "any"
11
+ readonly endPose?: "standing" | "seated" | "floor" | "any"
12
+ readonly endVisibility?: "in-frame" | "out-of-frame"
13
+ readonly handsAfter?: "free" | "occupied" | "holding-partner"
14
+ readonly needsFreeHands?: true
15
+ readonly kind?: "single" | "compound"
16
+ readonly fixedPace?: true
17
+ /** Non-human/dependent recipient substituted through the Partner handle. */
18
+ readonly counterpart?: string
19
+ }
@@ -28,6 +28,8 @@ import {
28
28
  getVideoAudioCapability,
29
29
  } from "./model-constants.js"
30
30
  import { isFlux2Model, FLUX2_RES_MP, type Flux2Model } from "./flux2-pricing.js"
31
+ import { VIDEO_DURATION_AUTO } from "./video-duration-auto.js"
32
+ import { uiResolutionFill } from "./video-ui-defaults.js"
31
33
  import { MODEL_CATALOG, normalizeModelInput, defaultResolutionFor, type ModelInputAdjustment } from "./model-catalog.js"
32
34
 
33
35
  /**
@@ -461,6 +463,35 @@ export function buildVideoCreditModelIdentifier(
461
463
  return identifier
462
464
  }
463
465
 
466
+ /**
467
+ * The credit identifier a Video to Video node's Seedance EDIT lane reserves
468
+ * under. Seedance has no v2v endpoint — the lane is a text-to-video job in edit
469
+ * shape with the source clip as reference video 1 — so it prices on the
470
+ * REFERENCE-VIDEO ladder at the model's LONGEST clip (Auto duration), and the
471
+ * measured settlement refunds down to what was actually delivered.
472
+ *
473
+ * It exists because that is a 7-positional-argument call with four
474
+ * easy-to-transpose slots, and FOUR surfaces must agree on it exactly: the
475
+ * orchestrator's reservation (payload-builder.ts), the backend pre-run
476
+ * estimator (ee/billing/credits.ts), the node's cost pill, and the frontend
477
+ * run-level estimate (config-panels/helpers.ts). A quote that disagrees with
478
+ * the reserve is the documented `price_not_configured` / blank-pill trap.
479
+ *
480
+ * `resolution` is the node's one `v2vResolution` field; when unset the model's
481
+ * own UI fill is priced, which is what the lane will send.
482
+ */
483
+ export function seedanceVideoEditCreditId(provider: string, resolution?: string): string {
484
+ return buildVideoCreditModelIdentifier(
485
+ provider,
486
+ VIDEO_DURATION_AUTO,
487
+ undefined,
488
+ "text-to-video",
489
+ undefined,
490
+ resolution ?? uiResolutionFill(provider),
491
+ /* hasVideoRef */ true,
492
+ )
493
+ }
494
+
464
495
  /** What the video credit identifier PRICES for a request, for the levers whose
465
496
  * priced value must also be the value we SEND. */
466
497
  export interface PricedVideoSelection {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The edit-plan node's credit-id scheme and output unwrap — split out of
3
+ * `edl.ts` (which keeps the EDL shape itself) so that module stays within the
4
+ * file-size cap. Re-exported from the package index, so importers of
5
+ * `@nodaro/shared` are unaffected.
6
+ */
7
+ import { EDL_VERSION } from "./edl.js"
8
+
9
+ // ─────────────────────────────────────────────────────────────────────────
10
+ // edit-plan credit-id scheme (STRUCTURE only — the per-mode/tier/bucket credit
11
+ // VALUES live app-side in backend/ee/billing/credits.ts + migration 432, since
12
+ // they are probe-set placeholders and the DB row wins at runtime). Lives HERE
13
+ // so BOTH core payload-builder and ee credits.ts read one id builder — core
14
+ // may not import ee (check-ee-imports), the same reason buildVideoAnalysisCreditId
15
+ // is shared. Mirrors the plugin's own (D13-local) pricing.ts scheme.
16
+ // ─────────────────────────────────────────────────────────────────────────
17
+
18
+ export type EditPlanMode = "tighten" | "clips" | "chapters"
19
+ export type EditPlanTier = "economy" | "standard" | "premium"
20
+ export const EDIT_PLAN_MODES: readonly EditPlanMode[] = ["tighten", "clips", "chapters"]
21
+ export const EDIT_PLAN_TIERS: readonly EditPlanTier[] = ["economy", "standard", "premium"]
22
+ /** The coarse duration ladder (MINUTES) a probed source duration rounds UP to;
23
+ * the composite credit id carries the bucket. 3 modes × 3 tiers × 6 buckets =
24
+ * 54 composites (+ the bare `edit-plan`). */
25
+ export const EDIT_PLAN_BUCKET_MINUTES: readonly number[] = [15, 30, 60, 90, 120, 180]
26
+ /** Hard duration cap (design §7.4). */
27
+ export const EDIT_PLAN_MAX_MINUTES = 180
28
+ /** `clips` mode: how many clips a plan returns when the caller names no count,
29
+ * and the most it may be asked for. One source for the credit estimate, the
30
+ * orchestrated payload clamp and the request schema. */
31
+ export const EDIT_PLAN_DEFAULT_CLIP_COUNT = 8
32
+ export const EDIT_PLAN_MAX_CLIP_COUNT = 50
33
+
34
+ /** Clamp a requested clip count into `[1, EDIT_PLAN_MAX_CLIP_COUNT]`; `undefined`
35
+ * for anything that is not a positive number (the planner then uses its default). */
36
+ export function clampEditPlanClipCount(count: unknown): number | undefined {
37
+ if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return undefined
38
+ return Math.min(EDIT_PLAN_MAX_CLIP_COUNT, Math.max(1, Math.floor(count)))
39
+ }
40
+ /** The bare estimator / DB-down fallback id. */
41
+ export const EDIT_PLAN_BASE_CREDIT_ID = "edit-plan"
42
+
43
+ /** Round a source duration (seconds) UP to the smallest covering ladder bucket
44
+ * (capped at the max), in minutes. `undefined` / non-finite → the ceiling
45
+ * bucket (the safe over-reserve direction). */
46
+ export function editPlanBucketMinutes(durationSec: number | undefined): number {
47
+ const secs = typeof durationSec === "number" && Number.isFinite(durationSec) ? durationSec : EDIT_PLAN_MAX_MINUTES * 60
48
+ const mins = Math.max(1, Math.ceil(secs / 60))
49
+ const capped = Math.min(mins, EDIT_PLAN_MAX_MINUTES)
50
+ for (const b of EDIT_PLAN_BUCKET_MINUTES) if (capped <= b) return b
51
+ return EDIT_PLAN_BUCKET_MINUTES[EDIT_PLAN_BUCKET_MINUTES.length - 1]!
52
+ }
53
+
54
+ /** `edit-plan:<mode>:<tier>:<bucket>m`. `durationSec` undefined → the ceiling
55
+ * bucket. Single source of truth for the composite id shape. */
56
+ export function buildEditPlanCreditId(mode: EditPlanMode, tier: EditPlanTier, durationSec?: number): string {
57
+ return `edit-plan:${mode}:${tier}:${editPlanBucketMinutes(durationSec)}m`
58
+ }
59
+
60
+ /** Narrow an arbitrary value to a known edit-plan mode, defaulting to "tighten". */
61
+ export function asEditPlanMode(v: unknown): EditPlanMode {
62
+ return v === "clips" || v === "chapters" ? v : "tighten"
63
+ }
64
+
65
+ /** Narrow an arbitrary value to a known edit-plan tier, defaulting to "standard". */
66
+ export function asEditPlanTier(v: unknown): EditPlanTier {
67
+ return v === "economy" || v === "premium" ? v : "standard"
68
+ }
69
+
70
+ /**
71
+ * Unwrap an `edit-plan` job's `output_data` into the value stored on the node's
72
+ * `data.generatedJson`, which every output extractor then reads. This is the ONE
73
+ * place the three modes are normalized (the same rule on both engines and every
74
+ * result-application site, so audit-dag parity can't drift):
75
+ * - `clips` → the BARE `Edl[]` (T5: the `list` fan-out reads `Array.isArray`
76
+ * on `generatedJson`; each element becomes one JSON-stringified
77
+ * item a downstream `edl` input `normalizeEdl`-parses).
78
+ * - `chapters` → the `{ version, chapters }` object.
79
+ * - `tighten` → the `Edl` object at top level.
80
+ *
81
+ * The cloud relay object-spreads `output_data` and adds `viaNodaroCloud: true`;
82
+ * that key (and any other bookkeeping) is stripped here. The unwrap lives HERE —
83
+ * NEVER in `output_data` — because a bare array written into `output_data` would
84
+ * be corrupted into numeric keys by the relay's object-spread (see `EdlClipSet`).
85
+ */
86
+ export function unwrapEditPlanOutput(outputData: unknown): unknown {
87
+ if (!outputData || typeof outputData !== "object") return outputData
88
+ const o = outputData as Record<string, unknown>
89
+ // clips: EdlClipSet { version, clips: Edl[] } → the bare Edl[].
90
+ if (Array.isArray(o.clips)) return o.clips
91
+ // chapters: { version, chapters: [...] } → the object, minus bookkeeping.
92
+ if (Array.isArray(o.chapters)) return { version: EDL_VERSION, chapters: o.chapters }
93
+ // tighten: the Edl object at top level → drop the relay's viaNodaroCloud.
94
+ const { viaNodaroCloud: _viaNodaroCloud, ...rest } = o
95
+ return rest
96
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Multicam helpers over the EDL contract (`edl.ts`): folding measured source
3
+ * offsets into an EDL (D19) and resolving what each on-screen slot of a
4
+ * segment shows (D20). Pure — no I/O. Type-only imports from `edl.ts` keep the
5
+ * module graph free of runtime cycles.
6
+ */
7
+ import type { Edl, EdlRegion, EdlSegment, EdlSource } from "./edl.js"
8
+
9
+ // ─────────────────────────────────────────────────────────────────────────
10
+ // D19 — source offsets
11
+ // ─────────────────────────────────────────────────────────────────────────
12
+
13
+ const hasOwn = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k)
14
+ const isFiniteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
15
+
16
+ /** audio-sync → EDL source offsets (D19: masterMs = sourceMs + offsetMs).
17
+ * `offsets` are measured against ONE common reference (audio-sync's `reference`, which need not be the master).
18
+ * ANCHORED SET: anchor = opts.anchor ?? the unique role:"master-audio" source ?? none.
19
+ * - The anchor's own offsetMs is NEVER changed (the segments are on its clock).
20
+ * - Every other provided source s: offsetMs = round((anchor.offsetMs ?? 0) + offsets[s] − (offsets[anchor] ?? 0)).
21
+ * - No anchor: offsetMs = round(offsets[s]) verbatim (offsets already relative to the master clock).
22
+ * SET, never ADD (re-running is idempotent). Returns a new Edl; never mutates; never throws.
23
+ * `ignored[].reason` is a documented open string: "unknown-source" | "not-finite" | "anchor-unknown" | "anchor-not-finite".
24
+ * opts.anchor not in edl.sources → NOTHING applied, ignored = [{ sourceId: anchor, reason: "anchor-unknown" }].
25
+ * offsets[anchor] present but non-finite → NOTHING applied, ignored = [{ sourceId: anchor, reason: "anchor-not-finite" }]. */
26
+ export function mergeEdlSourceOffsets(
27
+ edl: Edl,
28
+ offsets: Readonly<Record<string, number>>,
29
+ opts?: { readonly anchor?: string },
30
+ ): {
31
+ readonly edl: Edl
32
+ /** The source the offsets were anchored to — present whenever one resolved (it exists in `edl.sources`). */
33
+ readonly anchor?: string
34
+ /** Source ids whose `offsetMs` was set, in `offsets` key order. */
35
+ readonly applied: readonly string[]
36
+ readonly ignored: ReadonlyArray<{ readonly sourceId: string; readonly reason: string }>
37
+ } {
38
+ const sources: readonly EdlSource[] = Array.isArray(edl?.sources) ? edl.sources : []
39
+ const rows: Readonly<Record<string, unknown>> = offsets && typeof offsets === "object" ? offsets : {}
40
+ const byId = new Map<string, EdlSource>()
41
+ for (const s of sources) if (s && typeof s === "object" && !byId.has(s.id)) byId.set(s.id, s)
42
+
43
+ let anchor: string | undefined
44
+ if (opts?.anchor !== undefined) {
45
+ if (!byId.has(opts.anchor)) {
46
+ return { edl: { ...edl }, applied: [], ignored: [{ sourceId: opts.anchor, reason: "anchor-unknown" }] }
47
+ }
48
+ anchor = opts.anchor
49
+ } else {
50
+ const masters = sources.filter((s) => s && typeof s === "object" && s.role === "master-audio")
51
+ if (masters.length === 1) anchor = masters[0].id
52
+ }
53
+
54
+ // The anchor's own measurement is the rebase point; an absent one reads as 0.
55
+ let reference = 0
56
+ if (anchor !== undefined && hasOwn(rows, anchor)) {
57
+ const a = rows[anchor]
58
+ if (!isFiniteNumber(a)) {
59
+ return { edl: { ...edl }, anchor, applied: [], ignored: [{ sourceId: anchor, reason: "anchor-not-finite" }] }
60
+ }
61
+ reference = a
62
+ }
63
+ const anchorOffsetMs = anchor !== undefined ? (byId.get(anchor)?.offsetMs ?? 0) : 0
64
+
65
+ const next = new Map<string, number>()
66
+ const applied: string[] = []
67
+ const ignored: Array<{ sourceId: string; reason: string }> = []
68
+ for (const sourceId of Object.keys(rows)) {
69
+ if (sourceId === anchor) continue // never changed: the segments are on its clock
70
+ if (!byId.has(sourceId)) {
71
+ ignored.push({ sourceId, reason: "unknown-source" })
72
+ continue
73
+ }
74
+ const measured = rows[sourceId]
75
+ const offsetMs = isFiniteNumber(measured)
76
+ ? Math.round(anchor !== undefined ? anchorOffsetMs + measured - reference : measured)
77
+ : Number.NaN
78
+ // A non-finite result (the measurement, or a non-finite anchor offsetMs it
79
+ // is rebased onto) is never written.
80
+ if (!Number.isFinite(offsetMs)) {
81
+ ignored.push({ sourceId, reason: "not-finite" })
82
+ continue
83
+ }
84
+ next.set(sourceId, offsetMs)
85
+ applied.push(sourceId)
86
+ }
87
+
88
+ const merged: Edl = Array.isArray(edl?.sources)
89
+ ? { ...edl, sources: edl.sources.map((s) => (s && typeof s === "object" && next.has(s.id) ? { ...s, offsetMs: next.get(s.id)! } : s)) }
90
+ : { ...edl }
91
+ return { edl: merged, ...(anchor !== undefined ? { anchor } : {}), applied, ignored }
92
+ }
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────
95
+ // D20 — slot resolution
96
+ // ─────────────────────────────────────────────────────────────────────────
97
+
98
+ export const EDL_FULL_FRAME: EdlRegion = Object.freeze({ x: 0, y: 0, w: 1, h: 1 })
99
+
100
+ export interface EdlResolvedSlot {
101
+ readonly source: string
102
+ readonly region: EdlRegion
103
+ /** "resolver" = the caller's `regionFor` (v3 per-segment tracks). */
104
+ readonly regionFrom: "slot" | "segment" | "resolver" | "speaker" | "source" | "full"
105
+ readonly speaker?: string
106
+ readonly weight?: number
107
+ }
108
+
109
+ export interface ResolveEdlSlotsOptions {
110
+ /** speaker-view's per-speaker framing table (a node SETTING, not an EDL field), keyed by (source, speaker) so a
111
+ * wide-shot region never lands on a close-up camera framing the same person. */
112
+ readonly speakerRegions?: ReadonlyArray<{ readonly source: string; readonly speaker: string; readonly region: EdlRegion }>
113
+ /** v3 hook: a per-(segment, slot) region, e.g. from a face track. Undefined = no opinion. */
114
+ readonly regionFor?: (q: { readonly segment: EdlSegment; readonly source: string; readonly speaker?: string }) => EdlRegion | undefined
115
+ }
116
+
117
+ /** A valid in-frame box: finite, 0..1, w/h > 0, x+w ≤ 1, y+h ≤ 1 — with the
118
+ * same edge tolerance `validateEdl` accepts, so the two never disagree. */
119
+ function isInFrameRegion(r: unknown): r is EdlRegion {
120
+ if (!r || typeof r !== "object") return false
121
+ const { x, y, w, h } = r as Record<string, unknown>
122
+ for (const v of [x, y, w, h]) if (!isFiniteNumber(v) || v < 0 || v > 1) return false
123
+ const box = r as EdlRegion
124
+ return box.w > 0 && box.h > 0 && box.x + box.w <= 1 + 1e-9 && box.y + box.h <= 1 + 1e-9
125
+ }
126
+
127
+ type Rung = EdlResolvedSlot["regionFrom"]
128
+
129
+ /** D20 — the ONE region-precedence implementation:
130
+ * slot.region ▷ segment.region (single-slot only) ▷ regionFor ▷ speakerRegions[(source, speaker)] ▷ source.region ▷ full frame.
131
+ * Slots = layout.slots when non-empty, else ONE implicit slot from segment.video (no video → []).
132
+ * A slot's speaker = slot.speaker ?? (single slot ? segment.speaker : undefined); no speaker → the speaker rung is skipped.
133
+ * A rung whose region is not a valid in-frame box (finite, 0..1, w/h > 0, x+w ≤ 1, y+h ≤ 1) falls through to the next.
134
+ * Unknown source id → the source rung is skipped. Pure; never throws on its own. */
135
+ export function resolveEdlSegmentSlots(edl: Edl, segment: EdlSegment, opts?: ResolveEdlSlotsOptions): readonly EdlResolvedSlot[] {
136
+ if (!segment || typeof segment !== "object") return []
137
+ const sources: readonly EdlSource[] = Array.isArray(edl?.sources) ? edl.sources : []
138
+ const layoutSlots = Array.isArray(segment.layout?.slots) ? segment.layout!.slots! : []
139
+ const slots: NonNullable<NonNullable<EdlSegment["layout"]>["slots"]> =
140
+ layoutSlots.length > 0
141
+ ? layoutSlots
142
+ : typeof segment.video === "string" && segment.video
143
+ ? [{ source: segment.video }]
144
+ : []
145
+ const single = slots.length === 1
146
+
147
+ const out: EdlResolvedSlot[] = []
148
+ for (const slot of slots) {
149
+ if (!slot || typeof slot !== "object") continue
150
+ const source = slot.source
151
+ const speaker = slot.speaker ?? (single ? segment.speaker : undefined)
152
+
153
+ // Each rung is evaluated lazily, top-down; the first valid box wins.
154
+ const rungs: ReadonlyArray<readonly [Rung, () => unknown]> = [
155
+ ["slot", () => slot.region],
156
+ ["segment", () => (single ? segment.region : undefined)],
157
+ ["resolver", () => opts?.regionFor?.({ segment, source, ...(speaker !== undefined ? { speaker } : {}) })],
158
+ ["speaker", () =>
159
+ speaker === undefined
160
+ ? undefined
161
+ : Array.isArray(opts?.speakerRegions)
162
+ ? opts.speakerRegions.find((row) => row && row.source === source && row.speaker === speaker)?.region
163
+ : undefined],
164
+ ["source", () => sources.find((s) => s && typeof s === "object" && s.id === source)?.region],
165
+ ]
166
+ let region: EdlRegion = EDL_FULL_FRAME
167
+ let regionFrom: Rung = "full"
168
+ for (const [from, read] of rungs) {
169
+ const candidate = read()
170
+ if (isInFrameRegion(candidate)) {
171
+ region = candidate
172
+ regionFrom = from
173
+ break
174
+ }
175
+ }
176
+ out.push({
177
+ source,
178
+ region,
179
+ regionFrom,
180
+ ...(speaker !== undefined ? { speaker } : {}),
181
+ ...(slot.weight !== undefined ? { weight: slot.weight } : {}),
182
+ })
183
+ }
184
+ return out
185
+ }