@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
@@ -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
+ }
@@ -282,8 +282,23 @@ export const entitySlotSchema = z.object({
282
282
  refRejectedReason: z.string().optional(),
283
283
  /** NON-default looks only; present only when at least one exists. */
284
284
  variations: z.array(slotVariationSchema).max(VIDEO_ANALYSIS_MAX_VARIATIONS).optional(),
285
+ /**
286
+ * WHOSE THIS OBJECT IS (2026-09-17). An object slot that is worn, held,
287
+ * carried, driven or ridden by a cast person or creature names that slot
288
+ * here, with the relation as a short passive phrase ending in "by" ("worn
289
+ * by", "held by", "driven by"). The link says the object slot IS the one on
290
+ * that person — not a second one: a recast that saw "Man in Blue Silk
291
+ * Shirt" AND "Blue Silk Shirt" as unrelated slots rendered two shirts.
292
+ * Object slots only; a free-standing prop, the product on a table, a place
293
+ * or a person never carries it. Optional/additive: producers may omit it.
294
+ */
295
+ owner: z.object({
296
+ slotId: z.string().min(1).regex(/^[a-z0-9-]+$/),
297
+ relation: z.string().min(1),
298
+ }).optional(),
285
299
  })
286
300
  export type EntitySlot = z.infer<typeof entitySlotSchema>
301
+ export type EntitySlotOwner = NonNullable<EntitySlot["owner"]>
287
302
 
288
303
  /**
289
304
  * The sound LAYER vocabulary — what kind of thing this layer is.
@@ -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
+ }