@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.
- package/dist/index.cjs +2242 -86
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1779 -32
- package/dist/index.d.ts +1779 -32
- package/dist/index.js +2041 -87
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/caption-styles.test.ts +207 -0
- package/src/__tests__/edl-multicam.test.ts +304 -0
- package/src/__tests__/edl.test.ts +822 -0
- package/src/__tests__/fan-out-rows.test.ts +208 -0
- package/src/__tests__/instagram-scrape.test.ts +66 -0
- package/src/__tests__/llm-models.test.ts +48 -11
- package/src/__tests__/meta-ads-scrape.test.ts +284 -0
- package/src/__tests__/node-runtime-keys.test.ts +15 -0
- package/src/__tests__/parameter-node-value.test.ts +13 -1
- package/src/__tests__/presentation-utils.test.ts +67 -0
- package/src/__tests__/producer-types.test.ts +19 -0
- package/src/__tests__/schedule-rules.test.ts +265 -0
- package/src/__tests__/speaker-layouts.test.ts +203 -0
- package/src/__tests__/transcribe-capabilities.test.ts +104 -0
- package/src/__tests__/transcribe-preflight.test.ts +60 -0
- package/src/__tests__/trigger-feeds.test.ts +39 -0
- package/src/__tests__/video-analysis.test.ts +15 -0
- package/src/__tests__/video-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -0
- package/src/__tests__/video-frame-fit.test.ts +189 -0
- package/src/__tests__/video-link.test.ts +137 -0
- package/src/__tests__/workflow-export-strip.test.ts +59 -1
- package/src/caption-styles.ts +240 -0
- package/src/catalog-projection.ts +3 -0
- package/src/character-motion-metadata.ts +19 -0
- package/src/credit-identifiers.ts +31 -0
- package/src/edit-plan-contract.ts +96 -0
- package/src/edl-multicam.ts +185 -0
- package/src/edl.ts +747 -0
- package/src/entity-image-handle.ts +24 -1
- package/src/fan-out-rows.ts +213 -0
- package/src/i18n/character-motion.ar.ts +126 -75
- package/src/i18n/character-motion.de.ts +126 -75
- package/src/i18n/character-motion.es.ts +126 -75
- package/src/i18n/character-motion.fr.ts +126 -75
- package/src/i18n/character-motion.he.ts +126 -75
- package/src/i18n/character-motion.hi.ts +126 -75
- package/src/i18n/character-motion.ja.ts +126 -75
- package/src/i18n/character-motion.ko.ts +126 -75
- package/src/i18n/character-motion.pt-BR.ts +126 -75
- package/src/i18n/character-motion.ru.ts +126 -75
- package/src/i18n/character-motion.zh-CN.ts +126 -75
- package/src/index.ts +211 -3
- package/src/instagram-scrape.ts +204 -0
- package/src/llm-models.ts +80 -3
- package/src/meta-ads-scrape.ts +463 -0
- package/src/model-catalog.ts +48 -5
- package/src/model-constants.ts +148 -5
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-runtime-keys.ts +28 -0
- package/src/parameter-node-value.ts +31 -5
- package/src/presentation-utils.ts +49 -0
- package/src/producer-types.ts +20 -0
- package/src/schedule-rules.ts +484 -0
- package/src/speaker-layouts.ts +220 -0
- package/src/transcribe-preflight.ts +101 -0
- package/src/trigger-feeds.ts +59 -0
- package/src/trigger-node-types.ts +20 -0
- package/src/video-analysis.ts +15 -0
- package/src/video-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-frame-fit.ts +228 -0
- package/src/video-link.ts +167 -0
- package/src/video-output-canvas.ts +119 -0
- 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
|
+
}
|
package/src/video-analysis.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/video-duration.ts
CHANGED
|
@@ -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
|
+
}
|