@nodaro/shared 3.11.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 (52) hide show
  1. package/dist/index.cjs +2047 -84
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1556 -27
  4. package/dist/index.d.ts +1556 -27
  5. package/dist/index.js +1861 -85
  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__/presentation-utils.test.ts +67 -0
  17. package/src/__tests__/producer-types.test.ts +19 -0
  18. package/src/__tests__/schedule-rules.test.ts +265 -0
  19. package/src/__tests__/speaker-layouts.test.ts +203 -0
  20. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  21. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  22. package/src/__tests__/trigger-feeds.test.ts +39 -0
  23. package/src/__tests__/video-duration-auto.test.ts +65 -0
  24. package/src/__tests__/video-duration.test.ts +56 -0
  25. package/src/__tests__/video-link.test.ts +137 -0
  26. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  27. package/src/caption-styles.ts +240 -0
  28. package/src/credit-identifiers.ts +31 -0
  29. package/src/edit-plan-contract.ts +96 -0
  30. package/src/edl-multicam.ts +185 -0
  31. package/src/edl.ts +747 -0
  32. package/src/entity-image-handle.ts +24 -1
  33. package/src/fan-out-rows.ts +213 -0
  34. package/src/index.ts +206 -3
  35. package/src/instagram-scrape.ts +204 -0
  36. package/src/llm-models.ts +80 -3
  37. package/src/meta-ads-scrape.ts +463 -0
  38. package/src/model-catalog.ts +48 -5
  39. package/src/model-constants.ts +148 -5
  40. package/src/node-mappable-fields.ts +2 -0
  41. package/src/node-runtime-keys.ts +28 -0
  42. package/src/presentation-utils.ts +49 -0
  43. package/src/producer-types.ts +20 -0
  44. package/src/schedule-rules.ts +484 -0
  45. package/src/speaker-layouts.ts +220 -0
  46. package/src/transcribe-preflight.ts +101 -0
  47. package/src/trigger-feeds.ts +59 -0
  48. package/src/trigger-node-types.ts +20 -0
  49. package/src/video-duration-auto.ts +18 -0
  50. package/src/video-duration.ts +32 -0
  51. package/src/video-link.ts +167 -0
  52. package/src/workflow-export.ts +37 -1
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Speaker-view presentation registries — the STRUCTURAL vocabulary behind an
3
+ * EDL segment's `layout` (see `EdlLayout` in `edl.ts`).
4
+ *
5
+ * What lives here is structure only: which layout ids exist, how many slots
6
+ * each takes, which output aspects a renderer exists for, which speaker-switch
7
+ * ids exist and whether they consume output time, and the atomic emphasis
8
+ * styles. Taste — default emphasis, which transitions are allowed or preferred
9
+ * for a show, timing defaults — is deliberately NOT here.
10
+ *
11
+ * Every list is widened ADDITIVELY. That is why a finding judged against one of
12
+ * these registries is a validation WARNING, never an issue: an older validator
13
+ * must never reject an EDL written by a newer producer that knows more ids.
14
+ * An executor that cannot honour an id refuses it itself.
15
+ */
16
+ import type { Edl } from "./edl.js"
17
+ import { COMBINE_TRANSITIONS } from "./combine-transitions.js"
18
+ import { resolveEdlSegmentSlots } from "./edl-multicam.js"
19
+
20
+ export const EDL_TARGET_ASPECTS = ["16:9", "9:16", "1:1", "4:5"] as const
21
+ export type EdlTargetAspect = (typeof EDL_TARGET_ASPECTS)[number]
22
+
23
+ const TARGET_ASPECT_SET: ReadonlySet<string> = new Set(EDL_TARGET_ASPECTS)
24
+
25
+ /** Narrow an open `Edl.meta.targetAspect` (or any value) to a known aspect. */
26
+ export function isEdlTargetAspect(v: unknown): v is EdlTargetAspect {
27
+ return typeof v === "string" && TARGET_ASPECT_SET.has(v)
28
+ }
29
+
30
+ // ─────────────────────────────────────────────────────────────────────────
31
+ // Layouts
32
+ // ─────────────────────────────────────────────────────────────────────────
33
+
34
+ export interface SpeakerLayoutSheet {
35
+ readonly id: string
36
+ /** Inclusive slot-count range when a segment lists `layout.slots`. */
37
+ readonly minSlots: number
38
+ readonly maxSlots: number
39
+ /** The output aspects this layout is DRAWN for (a renderer exists) — not "looks good". Widened additively. */
40
+ readonly aspects: readonly EdlTargetAspect[]
41
+ }
42
+
43
+ export const SPEAKER_LAYOUTS: readonly SpeakerLayoutSheet[] = [
44
+ { id: "single", minSlots: 1, maxSlots: 1, aspects: EDL_TARGET_ASPECTS },
45
+ { id: "side-by-side", minSlots: 2, maxSlots: 2, aspects: ["16:9", "1:1"] },
46
+ { id: "stacked", minSlots: 2, maxSlots: 2, aspects: ["9:16", "4:5", "1:1"] },
47
+ { id: "grid", minSlots: 2, maxSlots: 6, aspects: EDL_TARGET_ASPECTS },
48
+ { id: "pip", minSlots: 2, maxSlots: 2, aspects: EDL_TARGET_ASPECTS },
49
+ ]
50
+
51
+ export const SPEAKER_LAYOUT_IDS: readonly string[] = SPEAKER_LAYOUTS.map((l) => l.id)
52
+
53
+ const LAYOUTS_BY_ID: ReadonlyMap<string, SpeakerLayoutSheet> = new Map(SPEAKER_LAYOUTS.map((l) => [l.id, l]))
54
+
55
+ export function getSpeakerLayout(id: string): SpeakerLayoutSheet | undefined {
56
+ return LAYOUTS_BY_ID.get(id)
57
+ }
58
+
59
+ /** Omitted query fields are unconstrained. */
60
+ export function speakerLayoutAllows(
61
+ sheet: SpeakerLayoutSheet,
62
+ q: { readonly aspect?: EdlTargetAspect; readonly slotCount?: number },
63
+ ): boolean {
64
+ if (q.aspect !== undefined && !sheet.aspects.includes(q.aspect)) return false
65
+ if (q.slotCount !== undefined && !(q.slotCount >= sheet.minSlots && q.slotCount <= sheet.maxSlots)) return false
66
+ return true
67
+ }
68
+
69
+ // ─────────────────────────────────────────────────────────────────────────
70
+ // Speaker switches (EdlLayout.transition.type)
71
+ // ─────────────────────────────────────────────────────────────────────────
72
+
73
+ /** The one prefix of the time-consuming (cross-source blend) switch family. */
74
+ const XFADE_SWITCH_PREFIX = "xfade:"
75
+
76
+ /** D17: a switch consumes output time iff it is in the `xfade:*` family. THE one overlap rule (edl.ts uses it). */
77
+ export function speakerSwitchOverlaps(type: string): boolean {
78
+ return typeof type === "string" && type.startsWith(XFADE_SWITCH_PREFIX)
79
+ }
80
+
81
+ export interface SpeakerSwitchSheet {
82
+ /** "cut" | "pan" | "zoom" | `xfade:${combine id}` */
83
+ readonly id: string
84
+ /** = `speakerSwitchOverlaps(id)`, derived, never hand-set. */
85
+ readonly overlaps: boolean
86
+ /** true only for `pan`: an eased sweep between two regions of ONE picture source. */
87
+ readonly requiresSameSource: boolean
88
+ }
89
+
90
+ const switchSheet = (id: string, requiresSameSource: boolean): SpeakerSwitchSheet => ({
91
+ id,
92
+ overlaps: speakerSwitchOverlaps(id),
93
+ requiresSameSource,
94
+ })
95
+
96
+ /** `cut` / `pan` / `zoom` consume no time (pan: a geometry tween inside ONE
97
+ * source; zoom: a tween inside each segment). The `xfade:*` family is DERIVED
98
+ * from every combine-videos transition that is a real ffmpeg xfade (so never
99
+ * `cut`, which has no xfade) — it is never hand-listed here. */
100
+ export const SPEAKER_SWITCHES: readonly SpeakerSwitchSheet[] = [
101
+ switchSheet("cut", false),
102
+ switchSheet("pan", true),
103
+ switchSheet("zoom", false),
104
+ ...COMBINE_TRANSITIONS.filter((t) => t.xfade !== null).map((t) => switchSheet(XFADE_SWITCH_PREFIX + t.id, false)),
105
+ ]
106
+
107
+ export const SPEAKER_SWITCH_IDS: readonly string[] = SPEAKER_SWITCHES.map((s) => s.id)
108
+
109
+ const SWITCHES_BY_ID: ReadonlyMap<string, SpeakerSwitchSheet> = new Map(SPEAKER_SWITCHES.map((s) => [s.id, s]))
110
+
111
+ /** A plain lookup: `undefined` for an unknown id (never throws). */
112
+ export function getSpeakerSwitch(id: string): SpeakerSwitchSheet | undefined {
113
+ return SWITCHES_BY_ID.get(id)
114
+ }
115
+
116
+ // ─────────────────────────────────────────────────────────────────────────
117
+ // Emphasis (EdlLayout.emphasis.style)
118
+ // ─────────────────────────────────────────────────────────────────────────
119
+
120
+ /** Atomic emphasis styles; `layout.emphasis.style` is a "+"-joined set of
121
+ * them. `none` stands alone (it means "no emphasis"), and an atom appears at
122
+ * most once. */
123
+ export const SPEAKER_EMPHASIS_STYLES = ["none", "scale", "border", "dim"] as const
124
+ export type SpeakerEmphasisStyle = (typeof SPEAKER_EMPHASIS_STYLES)[number]
125
+
126
+ const EMPHASIS_STYLE_SET: ReadonlySet<string> = new Set(SPEAKER_EMPHASIS_STYLES)
127
+
128
+ /** Split a `+`-joined style into its atoms: trimmed, empties dropped. */
129
+ export function parseSpeakerEmphasisStyle(style: string): readonly string[] {
130
+ if (typeof style !== "string") return []
131
+ return style
132
+ .split("+")
133
+ .map((atom) => atom.trim())
134
+ .filter((atom) => atom.length > 0)
135
+ }
136
+
137
+ /** At least one atom, every atom a known `SPEAKER_EMPHASIS_STYLES` id, no
138
+ * atom repeated, and `none` only on its own ("none+scale" contradicts itself). */
139
+ export function isKnownSpeakerEmphasisStyle(style: string): boolean {
140
+ const atoms = parseSpeakerEmphasisStyle(style)
141
+ if (atoms.length === 0 || !atoms.every((atom) => EMPHASIS_STYLE_SET.has(atom))) return false
142
+ if (new Set(atoms).size !== atoms.length) return false
143
+ return !(atoms.includes("none") && atoms.length > 1)
144
+ }
145
+
146
+ // ─────────────────────────────────────────────────────────────────────────
147
+ // Registry-derived findings
148
+ // ─────────────────────────────────────────────────────────────────────────
149
+
150
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object"
151
+
152
+ /** Registry-derived findings — ALL warning-class. Pure; never throws (guard non-array sources/segments the
153
+ * way validateEdl does). `validateEdl` already includes these in its `warnings`; call this directly only
154
+ * when you want the presentation findings alone. */
155
+ export function speakerPresentationWarnings(edl: Edl): readonly string[] {
156
+ const warnings: string[] = []
157
+ if (!isObject(edl)) return warnings
158
+ // Guard a raw object that skipped normalizeEdl, exactly as validateEdl does.
159
+ const safe: Edl = {
160
+ ...edl,
161
+ sources: Array.isArray(edl.sources) ? edl.sources : [],
162
+ segments: Array.isArray(edl.segments) ? edl.segments : [],
163
+ }
164
+
165
+ const rawAspect = isObject(safe.meta) ? safe.meta.targetAspect : undefined
166
+ const aspect = isEdlTargetAspect(rawAspect) ? rawAspect : undefined
167
+ if (rawAspect != null && aspect === undefined) {
168
+ warnings.push(`meta.targetAspect "${String(rawAspect)}" is not a known target aspect (known: ${EDL_TARGET_ASPECTS.join(", ")})`)
169
+ }
170
+
171
+ safe.segments.forEach((seg, i) => {
172
+ if (!isObject(seg) || !isObject(seg.layout)) return
173
+ const at = `segment[${i}] "${seg.id}"`
174
+ const layout = seg.layout
175
+
176
+ // 1. The layout mode, its slot count, and the output aspect.
177
+ const sheet = typeof layout.mode === "string" ? getSpeakerLayout(layout.mode) : undefined
178
+ if (!sheet) {
179
+ warnings.push(`${at}: unknown layout mode "${String(layout.mode)}" (known: ${SPEAKER_LAYOUT_IDS.join(", ")})`)
180
+ } else {
181
+ const slotCount = Array.isArray(layout.slots) ? layout.slots.length : 0
182
+ if (slotCount > 0 && !speakerLayoutAllows(sheet, { slotCount })) {
183
+ const range = sheet.minSlots === sheet.maxSlots ? `${sheet.minSlots}` : `${sheet.minSlots}–${sheet.maxSlots}`
184
+ warnings.push(`${at}: layout "${sheet.id}" takes ${range} slot(s), got ${slotCount}`)
185
+ }
186
+ if (aspect !== undefined && !speakerLayoutAllows(sheet, { aspect })) {
187
+ warnings.push(`${at}: layout "${sheet.id}" is not drawn for targetAspect ${aspect} (drawn for: ${sheet.aspects.join(", ")})`)
188
+ }
189
+ }
190
+
191
+ // 2. The switch into this segment.
192
+ if (isObject(layout.transition)) {
193
+ const type = layout.transition.type
194
+ const sw = typeof type === "string" ? getSpeakerSwitch(type) : undefined
195
+ if (!sw) {
196
+ warnings.push(`${at}: unknown layout transition "${String(type)}" (known: ${SPEAKER_SWITCHES.filter((s) => !s.overlaps).map((s) => s.id).join(", ")}, or ${XFADE_SWITCH_PREFIX}<id> for a combine-videos transition that is a real ffmpeg xfade — never ${XFADE_SWITCH_PREFIX}cut)`)
197
+ } else if (sw.requiresSameSource && i > 0) {
198
+ // Only a one-picture → one-picture boundary has a defined "picture
199
+ // source"; any other shape (multi-slot, no video) is not judged here.
200
+ const prev = safe.segments[i - 1]
201
+ const cur = resolveEdlSegmentSlots(safe, seg)
202
+ const before = isObject(prev) ? resolveEdlSegmentSlots(safe, prev) : []
203
+ if (cur.length === 1 && before.length === 1 && cur[0].source !== before[0].source) {
204
+ warnings.push(`${at}: switch "${sw.id}" moves within ONE picture source, but the previous segment shows "${before[0].source}" and this one "${cur[0].source}"`)
205
+ }
206
+ }
207
+ }
208
+
209
+ // 3. The emphasis style.
210
+ if (isObject(layout.emphasis) && !isKnownSpeakerEmphasisStyle(layout.emphasis.style as string)) {
211
+ const style = String(layout.emphasis.style)
212
+ const atoms = parseSpeakerEmphasisStyle(layout.emphasis.style as string)
213
+ warnings.push(atoms.length > 0 && atoms.every((a) => EMPHASIS_STYLE_SET.has(a))
214
+ ? `${at}: emphasis style "${style}" — "none" must stand alone and no style may repeat`
215
+ : `${at}: unknown emphasis style "${style}" (a "+"-joined set of: ${SPEAKER_EMPHASIS_STYLES.join(", ")})`)
216
+ }
217
+ })
218
+
219
+ return warnings
220
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ DEFAULT_TRANSCRIBE_NODE_PROVIDER,
3
+ transcribeLaneSupportsWordTimestamps,
4
+ transcribeProvidersWithWordTimestamps,
5
+ } from "./model-constants.js"
6
+
7
+ /**
8
+ * Pre-run checks for the transcribe → captions chain.
9
+ *
10
+ * A transcription lane that cannot return per-word timings (`whisper`) still
11
+ * RUNS and BILLS — it just hands back phrase segments with `words: []`. Anything
12
+ * downstream that needs words then fails AFTER the transcription was paid for.
13
+ * These helpers let every run surface (the editor's DAG, the backend
14
+ * orchestrator, single-node Run) refuse BEFORE any spend, with one message.
15
+ * Pure: no I/O, no framework types — callers pass plain nodes/edges.
16
+ */
17
+
18
+ /** The refusal for a lane that can't return word timings; `null` when it can.
19
+ * An absent provider resolves to the transcribe NODE default. */
20
+ export function transcribeWordTimestampsRefusal(provider: string | null | undefined): string | null {
21
+ const lane = provider || DEFAULT_TRANSCRIBE_NODE_PROVIDER
22
+ if (transcribeLaneSupportsWordTimestamps(lane)) return null
23
+ return `the "${lane}" engine does not return word timings — pick ${transcribeProvidersWithWordTimestamps().join(" or ")}`
24
+ }
25
+
26
+ export interface PreflightGraphNode {
27
+ readonly id: string
28
+ readonly type?: string | null
29
+ readonly data?: Record<string, unknown> | null
30
+ }
31
+ export interface PreflightGraphEdge {
32
+ readonly source: string
33
+ readonly target: string
34
+ readonly sourceHandle?: string | null
35
+ readonly targetHandle?: string | null
36
+ }
37
+
38
+ export interface WordlessTranscriptFeed {
39
+ readonly transcribeNodeId: string
40
+ /** The add-captions node that would receive a transcript with no words. */
41
+ readonly consumerNodeId: string
42
+ readonly provider: string
43
+ readonly message: string
44
+ }
45
+
46
+ // Handle ids (generated map: backend/src/lib/mcp/generated/node-handles.ts).
47
+ const TRANSCRIBE_JSON_OUT = "json"
48
+ const TRANSCRIPT_IN = "transcript"
49
+ const APPLY_EDL_JSON_OUT = "json"
50
+
51
+ /**
52
+ * Every transcribe node on a word-INCAPABLE lane whose `json` output reaches an
53
+ * add-captions `transcript` input — directly, or through apply-edl, which remaps
54
+ * the transcript and re-emits it on its own `json` handle. add-captions rejects a
55
+ * transcript with no words, so such a run can only fail, after paying for the
56
+ * transcription. Skipped nodes are ignored on both ends.
57
+ *
58
+ * The engine is read from node data and nothing else can change it: `provider`
59
+ * is not a mappable field on transcribe, so what this check sees IS what runs.
60
+ * add-captions' own "transcript has no words" guard stays as defence in depth
61
+ * for a transcript that arrives from anywhere other than a transcribe node.
62
+ */
63
+ export function findWordlessTranscriptFeeds(
64
+ nodes: readonly PreflightGraphNode[],
65
+ edges: readonly PreflightGraphEdge[],
66
+ ): WordlessTranscriptFeed[] {
67
+ const byId = new Map(nodes.map((n) => [n.id, n]))
68
+ const isSkipped = (n: PreflightGraphNode | undefined): boolean => !n || n.data?.skipped === true
69
+ const out: WordlessTranscriptFeed[] = []
70
+
71
+ for (const node of nodes) {
72
+ if (node.type !== "transcribe" || isSkipped(node)) continue
73
+ const provider = (typeof node.data?.provider === "string" && node.data.provider) || DEFAULT_TRANSCRIBE_NODE_PROVIDER
74
+ const refusal = transcribeWordTimestampsRefusal(provider)
75
+ if (!refusal) continue
76
+
77
+ // Walk the transcript's path: transcribe.json → [apply-edl.transcript → apply-edl.json]* → add-captions.transcript
78
+ const seen = new Set<string>()
79
+ const frontier: Array<{ id: string; outHandle: string }> = [{ id: node.id, outHandle: TRANSCRIBE_JSON_OUT }]
80
+ while (frontier.length > 0) {
81
+ const { id, outHandle } = frontier.pop()!
82
+ for (const e of edges) {
83
+ if (e.source !== id || (e.sourceHandle ?? null) !== outHandle || e.targetHandle !== TRANSCRIPT_IN) continue
84
+ const target = byId.get(e.target)
85
+ if (isSkipped(target)) continue
86
+ if (target!.type === "add-captions") {
87
+ out.push({
88
+ transcribeNodeId: node.id,
89
+ consumerNodeId: target!.id,
90
+ provider,
91
+ message: `Captions need word timings, but ${refusal}.`,
92
+ })
93
+ } else if (target!.type === "apply-edl" && !seen.has(target!.id)) {
94
+ seen.add(target!.id)
95
+ frontier.push({ id: target!.id, outHandle: APPLY_EDL_JSON_OUT })
96
+ }
97
+ }
98
+ }
99
+ }
100
+ return out
101
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Every way one node FEEDS another — the ONE definition the server's run
3
+ * scope (`triggerRunScope`) and the editor's "is this trigger wired?" read
4
+ * from, so the card can never say "the branch" while the server runs the
5
+ * whole workflow, or the other way round:
6
+ *
7
+ * - a drawn edge whose two ends are both on the graph (a delta that deleted a
8
+ * node leaves its edges behind; those feed nothing);
9
+ * - Group membership: a node inside a Group feeds the group (`parentId`),
10
+ * the way the engine orders a group after its members;
11
+ * - a field mapping: `data.fieldMappings[field].sourceNodeId` feeds the node
12
+ * that carries the mapping, even after the edge it was made from is gone.
13
+ */
14
+
15
+ export interface FeedNode {
16
+ readonly id: string
17
+ readonly parentId?: string | null
18
+ readonly data?: unknown
19
+ }
20
+
21
+ export interface FeedEdge {
22
+ readonly source: string
23
+ readonly target: string
24
+ }
25
+
26
+ export interface FeedMaps {
27
+ /** node id → the ids it feeds */
28
+ readonly children: ReadonlyMap<string, ReadonlyArray<string>>
29
+ /** node id → the ids that feed it */
30
+ readonly parents: ReadonlyMap<string, ReadonlyArray<string>>
31
+ }
32
+
33
+ export function buildFeedMaps(nodes: ReadonlyArray<FeedNode>, edges: ReadonlyArray<FeedEdge>): FeedMaps {
34
+ const live = new Set(nodes.map((n) => n.id))
35
+ const children = new Map<string, string[]>()
36
+ const parents = new Map<string, string[]>()
37
+ const feeds = (source: string, target: string) => {
38
+ if (!live.has(source) || !live.has(target) || source === target) return
39
+ children.set(source, [...(children.get(source) ?? []), target])
40
+ parents.set(target, [...(parents.get(target) ?? []), source])
41
+ }
42
+ for (const edge of edges) feeds(edge.source, edge.target)
43
+ for (const n of nodes) {
44
+ if (typeof n.parentId === "string" && n.parentId) feeds(n.id, n.parentId)
45
+ const mappings = (n.data as { fieldMappings?: unknown } | null | undefined)?.fieldMappings
46
+ if (mappings && typeof mappings === "object") {
47
+ for (const mapping of Object.values(mappings as Record<string, unknown>)) {
48
+ const sourceNodeId = (mapping as { sourceNodeId?: unknown } | null)?.sourceNodeId
49
+ if (typeof sourceNodeId === "string" && sourceNodeId) feeds(sourceNodeId, n.id)
50
+ }
51
+ }
52
+ }
53
+ return { children, parents }
54
+ }
55
+
56
+ /** Does this node feed anything? A trigger that does not runs the whole workflow. */
57
+ export function nodeFeedsAnything(nodes: ReadonlyArray<FeedNode>, edges: ReadonlyArray<FeedEdge>, nodeId: string): boolean {
58
+ return (buildFeedMaps(nodes, edges).children.get(nodeId) ?? []).length > 0
59
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The node types the server projects onto `workflow_triggers` rows when a
3
+ * workflow is saved (backend `lib/workflow-trigger-sync.ts`), and that the
4
+ * editor therefore asks the server to re-project after its own saves. One
5
+ * vocabulary for both sides: a projected type added here reaches the
6
+ * editor's "does this save need a sync?" question by construction.
7
+ */
8
+ export const SCHEDULE_TRIGGER_NODE_TYPE = "schedule-trigger"
9
+ export const WEBHOOK_TRIGGER_NODE_TYPE = "webhook-trigger"
10
+ export const TELEGRAM_TRIGGER_NODE_TYPE = "telegram-trigger"
11
+
12
+ export const PROJECTED_TRIGGER_NODE_TYPES: ReadonlySet<string> = new Set([
13
+ SCHEDULE_TRIGGER_NODE_TYPE,
14
+ WEBHOOK_TRIGGER_NODE_TYPE,
15
+ TELEGRAM_TRIGGER_NODE_TYPE,
16
+ ])
17
+
18
+ export function isProjectedTriggerNodeType(type: unknown): type is string {
19
+ return typeof type === "string" && PROJECTED_TRIGGER_NODE_TYPES.has(type)
20
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * "Auto" video duration — the model decides the clip length. The wire value is
3
+ * KIE's own (`duration: -1`): on a video EDIT the output takes the source
4
+ * clip's length, on any other run the model picks within its valid range.
5
+ * Stored in the ordinary numeric `duration` field (node data, routes, presets,
6
+ * MCP) so every surface that can carry a duration can carry Auto. Which models
7
+ * accept it is a catalog capability (`MODEL_CATALOG[id].autoDuration`), read
8
+ * through `supportsAutoVideoDuration`.
9
+ *
10
+ * Import-free on purpose: the catalog and the constants module both need it,
11
+ * and they already depend on each other.
12
+ */
13
+ export const VIDEO_DURATION_AUTO = -1
14
+
15
+ export function isAutoVideoDuration(duration: unknown): boolean {
16
+ const n = typeof duration === "string" ? parseInt(duration, 10) : duration
17
+ return n === VIDEO_DURATION_AUTO
18
+ }
@@ -23,3 +23,35 @@ export function extractVideoDurationFromNode(
23
23
  }
24
24
  return undefined
25
25
  }
26
+
27
+ /** Duration (seconds) of an edit-plan SOURCE node's media, for the reserve
28
+ * bucket. Extends {@link extractVideoDurationFromNode} with the AUDIO lane:
29
+ * `upload-audio` (and URL-imported audio) write their length to
30
+ * `metadata.durationSeconds` ONLY — never `generatedResults[].duration` /
31
+ * `data.duration` — so a podcast's audio master would otherwise resolve to
32
+ * undefined and reserve the 180-minute ceiling (a ~6× overbill). A
33
+ * `reference-audio` node records its extracted file's length in the same field,
34
+ * stamped with `metadata.mediaUrl` (see the binding check below).
35
+ *
36
+ * Deliberately a NEW function, not a change to `extractVideoDurationFromNode`,
37
+ * so no other node's duration read shifts — this fallback is edit-plan-scoped. */
38
+ export function editPlanSourceDurationSec(
39
+ data: Record<string, unknown> | undefined,
40
+ ): number | undefined {
41
+ const fromVideo = extractVideoDurationFromNode(data)
42
+ if (fromVideo !== undefined) return fromVideo
43
+ const meta = data?.metadata as { durationSeconds?: unknown; mediaUrl?: unknown } | undefined
44
+ // A length stamped with the media it was measured from is trusted ONLY while
45
+ // that media is still the node's. This is the read-side invariant that makes a
46
+ // stale length impossible whoever changed the url — a copilot patch, an MCP
47
+ // workflow-JSON write, an import, a run-time input override, or code not yet
48
+ // written: a mismatch reads as "unknown", and every caller then falls to its
49
+ // safe side (the transcript clock, the reserve-time probe, the ceiling bucket)
50
+ // instead of under-bucketing a longer file. An UNSTAMPED length (upload-audio,
51
+ // nodes saved before the stamp existed) is trusted as it always was.
52
+ if (typeof meta?.mediaUrl === "string" && meta.mediaUrl !== data?.extractedAudioUrl && meta.mediaUrl !== data?.url) {
53
+ return undefined
54
+ }
55
+ const d = meta?.durationSeconds
56
+ return typeof d === "number" && Number.isFinite(d) && d > 0 ? d : undefined
57
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The Video URL node (`youtube-video`) and the social-video import path —
3
+ * structural vocabulary shared by the canvas, the orchestrator and the
4
+ * download routes. Host names and a node-output rule only; no prompt content.
5
+ *
6
+ * ONE list, three readers:
7
+ * - the backend's yt-dlp routes (`lib/url-validator.ts` re-exports these),
8
+ * where the list IS the SSRF gate — yt-dlp does its own DNS + HTTP, so
9
+ * nothing but this exact-suffix match stands between a pasted link and an
10
+ * internal address;
11
+ * - the editor, which decides from the same list whether a pasted link is
12
+ * one it should download (it must never offer a host the server refuses,
13
+ * nor sit on one the server accepts);
14
+ * - both workflow engines, which read a node's output through
15
+ * `resolveVideoLinkOutput`.
16
+ *
17
+ * ⚠️ Adding a host here ADMITS it to a server-side fetch. It is a security
18
+ * decision, not a UI one — only fixed, reputable domains whose DNS an attacker
19
+ * cannot control.
20
+ */
21
+ export const SOCIAL_VIDEO_HOSTS = [
22
+ "youtube.com", "youtu.be",
23
+ "tiktok.com",
24
+ "instagram.com",
25
+ "twitter.com", "x.com",
26
+ "facebook.com", "fb.watch", "fb.com",
27
+ ] as const
28
+
29
+ /** YouTube-only subset (the metadata probe and the client ladder are YouTube-only). */
30
+ export const YOUTUBE_HOSTS = ["youtube.com", "youtu.be"] as const
31
+
32
+ /** Instagram-only subset (the download path's proxy failover is Instagram-scoped). */
33
+ export const INSTAGRAM_HOSTS = ["instagram.com"] as const
34
+
35
+ const TIKTOK_HOSTS = ["tiktok.com"] as const
36
+ const TWITTER_HOSTS = ["twitter.com", "x.com"] as const
37
+ const FACEBOOK_HOSTS = ["facebook.com", "fb.watch", "fb.com"] as const
38
+
39
+ /**
40
+ * Exact registrable-domain match against an allowlist: the domain itself or a
41
+ * true subdomain (`www.youtube.com`, `m.youtu.be`). A host that merely
42
+ * CONTAINS an allowlisted name (`evilyoutube.com`, `youtube.com.attacker.example`,
43
+ * or `netflix.com` for `x.com`) does not match.
44
+ */
45
+ export function hostnameMatchesAllowlist(hostname: string, domains: readonly string[]): boolean {
46
+ const h = hostname.toLowerCase().replace(/\.$/, "") // strip FQDN trailing dot
47
+ return domains.some((d) => {
48
+ const dom = d.toLowerCase()
49
+ return h === dom || h.endsWith("." + dom)
50
+ })
51
+ }
52
+
53
+ /**
54
+ * True when the RAW link carries a character that URL parsers read differently:
55
+ * a backslash or an ASCII control character.
56
+ *
57
+ * Every check in this file parses the WHATWG way (Node, the browser), where a
58
+ * backslash in an http(s) URL is a slash — `https://tiktok.com\@10.0.0.1/x` has
59
+ * host `tiktok.com`. A parser that ends the authority at `/` alone reads the
60
+ * same string as a user name at host `10.0.0.1`. The download tools are handed
61
+ * the raw string and do their own parsing, DNS and HTTP, so a link the two
62
+ * readings can disagree on is refused outright rather than reasoned about. Tabs
63
+ * and newlines are dropped silently by one parser and kept by another — same
64
+ * answer. No real video link contains any of these.
65
+ */
66
+ export function hasUrlParserHazard(url: string): boolean {
67
+ for (let i = 0; i < url.length; i++) {
68
+ const code = url.charCodeAt(i)
69
+ if (code === 0x5c || code <= 0x1f || code === 0x7f) return true
70
+ }
71
+ return false
72
+ }
73
+
74
+ /** True for an http(s) URL whose host is on the allowlist. Never throws. */
75
+ export function isSocialVideoUrl(url: string, domains: readonly string[] = SOCIAL_VIDEO_HOSTS): boolean {
76
+ if (hasUrlParserHazard(url)) return false
77
+ try {
78
+ const parsed = new URL(url)
79
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false
80
+ return hostnameMatchesAllowlist(parsed.hostname, domains)
81
+ } catch {
82
+ return false
83
+ }
84
+ }
85
+
86
+ export type VideoLinkPlatform = "youtube" | "facebook" | "tiktok" | "instagram" | "twitter" | "unknown"
87
+
88
+ /** Which supported platform a link belongs to — by exact host, never by substring. */
89
+ export function detectVideoLinkPlatform(url: string): VideoLinkPlatform {
90
+ if (isSocialVideoUrl(url, YOUTUBE_HOSTS)) return "youtube"
91
+ if (isSocialVideoUrl(url, FACEBOOK_HOSTS)) return "facebook"
92
+ if (isSocialVideoUrl(url, TIKTOK_HOSTS)) return "tiktok"
93
+ if (isSocialVideoUrl(url, INSTAGRAM_HOSTS)) return "instagram"
94
+ if (isSocialVideoUrl(url, TWITTER_HOSTS)) return "twitter"
95
+ return "unknown"
96
+ }
97
+
98
+ /**
99
+ * Node types that can use a Video URL node WITHOUT its downloaded file, because
100
+ * they never read the video: `suno-cover` and `transcribe` take the node's
101
+ * separately-fetched audio track (`downloadedAudioUrl`), and `dubbing` hands the
102
+ * page link to a provider that fetches it itself. A run whose only consumers of
103
+ * a link are these must not be made to download — or to choose a part of — a
104
+ * video nobody will look at. Structural vocabulary: it mirrors those three
105
+ * server-side readers; add a type here only together with its reader.
106
+ */
107
+ export const VIDEO_LINK_TOLERANT_CONSUMER_TYPES: ReadonlySet<string> = new Set([
108
+ "suno-cover",
109
+ "transcribe",
110
+ "dubbing",
111
+ ])
112
+
113
+ /**
114
+ * The fields of a Video URL node's data that decide what it emits. Open-ended
115
+ * on purpose: both engines hand over the node's whole `data` bag, and a closed
116
+ * shape with only optional members would refuse it as having nothing in common.
117
+ */
118
+ export interface VideoLinkNodeFields {
119
+ readonly youtubeUrl?: unknown
120
+ readonly downloadedVideoUrl?: unknown
121
+ readonly downloadedFromUrl?: unknown
122
+ readonly [key: string]: unknown
123
+ }
124
+
125
+ function trimmed(value: unknown): string | undefined {
126
+ if (typeof value !== "string") return undefined
127
+ const t = value.trim()
128
+ return t === "" ? undefined : t
129
+ }
130
+
131
+ /**
132
+ * The stored file that belongs to the node's CURRENT link, or undefined.
133
+ *
134
+ * `downloadedFromUrl` binds a file to the link it came from. The editor clears
135
+ * the file whenever the link is edited, but a link can also change where no
136
+ * editor is looking — an agent or an import rewriting the workflow JSON — and
137
+ * without the binding the node would go on emitting the PREVIOUS video, which
138
+ * is worse than emitting none. A node saved before the field existed has no
139
+ * binding and is trusted as it always was.
140
+ */
141
+ export function videoLinkDownloadedFile(data: VideoLinkNodeFields): string | undefined {
142
+ const file = trimmed(data.downloadedVideoUrl)
143
+ if (!file) return undefined
144
+ const from = trimmed(data.downloadedFromUrl)
145
+ if (from && from !== trimmed(data.youtubeUrl)) return undefined
146
+ return file
147
+ }
148
+
149
+ /**
150
+ * What a Video URL node emits on its `video` handle: the downloaded file when
151
+ * one matches the link, else the link itself. The fallback is load-bearing — a
152
+ * DIRECT file link (`https://cdn…/clip.mp4`) is a legitimate value of the URL
153
+ * field and is never downloaded, so it must pass through.
154
+ */
155
+ export function resolveVideoLinkOutput(data: VideoLinkNodeFields): string | undefined {
156
+ return videoLinkDownloadedFile(data) ?? trimmed(data.youtubeUrl)
157
+ }
158
+
159
+ /**
160
+ * True when the node holds a social link with no file for it yet — the state
161
+ * in which its output is a web PAGE, which no video consumer can read.
162
+ */
163
+ export function videoLinkNeedsDownload(data: VideoLinkNodeFields): boolean {
164
+ const url = trimmed(data.youtubeUrl)
165
+ if (!url || !isSocialVideoUrl(url)) return false
166
+ return videoLinkDownloadedFile(data) === undefined
167
+ }