@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.
- package/dist/index.cjs +2047 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1556 -27
- package/dist/index.d.ts +1556 -27
- package/dist/index.js +1861 -85
- 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__/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-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -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/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/index.ts +206 -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/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-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-link.ts +167 -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|