@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,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* START/END FRAME FIT — the pure geometry half.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (measured on ~40 production renders, 2026-09-16):
|
|
5
|
+
* a start frame whose pixel size is not the model's own output canvas gets
|
|
6
|
+
* reshaped by the provider, and Seedance 2.5 does it VISIBLY — frame 0 is the
|
|
7
|
+
* user's image verbatim, then from frame 1 the generated frames are rescaled
|
|
8
|
+
* ~2% on ONE axis (x1.000 y1.02). Five of ten runs with a 940x1672 image
|
|
9
|
+
* snapped; three of three with the same image resized to 720x1280 did not.
|
|
10
|
+
*
|
|
11
|
+
* So: reshape the frame ourselves, to the size the model was going to render
|
|
12
|
+
* anyway. The canvas comes from measurement (`video-output-canvas.ts`), never
|
|
13
|
+
* from arithmetic, and when a combination has never been measured the fit
|
|
14
|
+
* DEGRADES rather than guesses.
|
|
15
|
+
*
|
|
16
|
+
* This module is pure — no I/O, no sharp, no network. `prepareVideoFrames` in
|
|
17
|
+
* the backend does the pixels and the upload.
|
|
18
|
+
*/
|
|
19
|
+
import { getModel } from "./model-catalog.js"
|
|
20
|
+
import { resolveOutputCanvas, type VideoOutputCanvas } from "./video-output-canvas.js"
|
|
21
|
+
|
|
22
|
+
/** How much of the frame we are allowed to reshape. */
|
|
23
|
+
export const FRAME_FITS = ["original", "ratio", "resolution"] as const
|
|
24
|
+
export type FrameFit = (typeof FRAME_FITS)[number]
|
|
25
|
+
|
|
26
|
+
/** `resolution` — the measured canvas — is the default everywhere. */
|
|
27
|
+
export const DEFAULT_FRAME_FIT: FrameFit = "resolution"
|
|
28
|
+
|
|
29
|
+
/** How the frame is handed to the model. */
|
|
30
|
+
export const FRAME_DELIVERIES = ["auto", "frame", "reference"] as const
|
|
31
|
+
export type FrameDelivery = (typeof FRAME_DELIVERIES)[number]
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_FRAME_DELIVERY: FrameDelivery = "auto"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Stretching an image to a ratio it is nowhere near would squash the subject,
|
|
37
|
+
* so past this gap the frame is centre-cropped to the target ratio FIRST and
|
|
38
|
+
* only then resized. 5% covers every "1K image into a 720p canvas" case we
|
|
39
|
+
* measured (940x1672 into 9:16 is a 0.05% gap) while refusing to squash a
|
|
40
|
+
* square photo into 9:16 (a 78% gap).
|
|
41
|
+
*/
|
|
42
|
+
export const FRAME_FIT_STRETCH_TOLERANCE = 0.05
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Models whose frame mode is measurably worse than reference delivery.
|
|
46
|
+
*
|
|
47
|
+
* The Seedance 2.0 family crop-zooms the frame 2% and drifts 11-26% darker
|
|
48
|
+
* within six frames in frame mode; delivered as a reference with the opening-
|
|
49
|
+
* frame sentence, the same models hold a flat look from frame 0 and keep the
|
|
50
|
+
* image's true geometry. Every OTHER model measured (Seedance 2.5, Gemini Omni
|
|
51
|
+
* video + flash, Veo 3.1, Wan 3.0, Minimax H3) reproduces the opening frame
|
|
52
|
+
* better in frame mode, so the default stays `frame` for anything absent here.
|
|
53
|
+
*/
|
|
54
|
+
export const FRAME_DELIVERY_BY_PROVIDER: Readonly<Record<string, Exclude<FrameDelivery, "auto">>> = {
|
|
55
|
+
"seedance-2": "reference",
|
|
56
|
+
"seedance-2-fast": "reference",
|
|
57
|
+
"seedance-2-mini": "reference",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Aspect tokens that name no concrete shape — the model picks. */
|
|
61
|
+
const OPEN_ASPECT_TOKENS = new Set(["adaptive", "auto"])
|
|
62
|
+
|
|
63
|
+
/** `"16:9"` → 1.777…; anything unparseable → `undefined`. */
|
|
64
|
+
export function parseAspectToken(token: string | undefined): number | undefined {
|
|
65
|
+
if (!token) return undefined
|
|
66
|
+
const m = /^(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)$/.exec(token.trim())
|
|
67
|
+
if (!m) return undefined
|
|
68
|
+
const w = Number(m[1]); const h = Number(m[2])
|
|
69
|
+
if (!(w > 0) || !(h > 0)) return undefined
|
|
70
|
+
return w / h
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The aspect the fit should target.
|
|
75
|
+
*
|
|
76
|
+
* An explicit ratio is used as-is. `adaptive` / `Auto` (and an absent value,
|
|
77
|
+
* which the seedance family sends as adaptive whenever a frame is present) has
|
|
78
|
+
* no shape of its own: the provider will follow the IMAGE, so we snap the
|
|
79
|
+
* image's own ratio to the nearest one the model lists and target that. A model
|
|
80
|
+
* with no declared ratio list and an open token gives `undefined` — no fit.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveFrameFitAspect(args: {
|
|
83
|
+
provider: string | undefined
|
|
84
|
+
requestedAspect: string | undefined
|
|
85
|
+
sourceWidth: number
|
|
86
|
+
sourceHeight: number
|
|
87
|
+
}): string | undefined {
|
|
88
|
+
const requested = args.requestedAspect?.trim()
|
|
89
|
+
if (requested && !OPEN_ASPECT_TOKENS.has(requested.toLowerCase())) return requested
|
|
90
|
+
const ratios = args.provider ? getModel(args.provider)?.aspectRatios : undefined
|
|
91
|
+
if (!ratios?.length || !(args.sourceWidth > 0) || !(args.sourceHeight > 0)) return undefined
|
|
92
|
+
const source = args.sourceWidth / args.sourceHeight
|
|
93
|
+
let best: { token: string; gap: number } | undefined
|
|
94
|
+
for (const token of ratios) {
|
|
95
|
+
const value = parseAspectToken(token)
|
|
96
|
+
if (value === undefined) continue // skips "adaptive"/"Auto" members
|
|
97
|
+
const gap = Math.abs(Math.log(value / source))
|
|
98
|
+
if (!best || gap < best.gap) best = { token, gap }
|
|
99
|
+
}
|
|
100
|
+
return best?.token
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What `prepareVideoFrames` should do to one frame. `null` = nothing. */
|
|
104
|
+
export interface FrameFitPlan {
|
|
105
|
+
/** Final pixel size to produce. */
|
|
106
|
+
readonly width: number
|
|
107
|
+
readonly height: number
|
|
108
|
+
/** Centre-crop applied BEFORE the resize (only past the stretch tolerance). */
|
|
109
|
+
readonly crop?: { readonly left: number; readonly top: number; readonly width: number; readonly height: number }
|
|
110
|
+
/** Why the plan exists — carried into logs and job output for traceability. */
|
|
111
|
+
readonly reason: "resolution" | "ratio"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Round to an even number ≥ 2 — odd dimensions break yuv420p encoders. */
|
|
115
|
+
function even(value: number): number {
|
|
116
|
+
return Math.max(2, Math.round(value / 2) * 2)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The smallest change that makes `width x height` exactly `aspect`: keep the
|
|
121
|
+
* longer side, move the shorter one. Rounding to even can leave a sub-pixel
|
|
122
|
+
* residue, which is why the caller compares ratios with a tolerance rather than
|
|
123
|
+
* for equality.
|
|
124
|
+
*/
|
|
125
|
+
export function minimalRatioDimensions(width: number, height: number, aspect: number): { width: number; height: number } {
|
|
126
|
+
const current = width / height
|
|
127
|
+
if (current > aspect) {
|
|
128
|
+
// too wide → bring the height up (keep the long side, the width)
|
|
129
|
+
return { width: even(width), height: even(width / aspect) }
|
|
130
|
+
}
|
|
131
|
+
return { width: even(height * aspect), height: even(height) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The centre-crop that turns `width x height` into exactly `aspect`, dropping
|
|
136
|
+
* the overhang on the long axis.
|
|
137
|
+
*/
|
|
138
|
+
export function centreCropToAspect(width: number, height: number, aspect: number): { left: number; top: number; width: number; height: number } {
|
|
139
|
+
const current = width / height
|
|
140
|
+
if (current > aspect) {
|
|
141
|
+
const w = even(height * aspect)
|
|
142
|
+
return { left: Math.max(0, Math.round((width - w) / 2)), top: 0, width: Math.min(width, w), height }
|
|
143
|
+
}
|
|
144
|
+
const h = even(width / aspect)
|
|
145
|
+
return { left: 0, top: Math.max(0, Math.round((height - h) / 2)), width, height: Math.min(height, h) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Turn a request into a concrete plan for ONE frame, or `null` when the frame
|
|
150
|
+
* should be sent untouched.
|
|
151
|
+
*
|
|
152
|
+
* Degradation ladder — a missing measurement must never invent geometry:
|
|
153
|
+
* `resolution` with no measured canvas → behaves as `ratio`
|
|
154
|
+
* `ratio` with no resolvable aspect → no fit
|
|
155
|
+
* any fit whose target equals the source (within a pixel) → no fit
|
|
156
|
+
*/
|
|
157
|
+
export function computeFrameFitPlan(args: {
|
|
158
|
+
fit: FrameFit
|
|
159
|
+
provider: string | undefined
|
|
160
|
+
resolution: string | undefined
|
|
161
|
+
/** The aspect as the request carries it: a ratio, `adaptive`/`Auto`, or absent. */
|
|
162
|
+
aspect: string | undefined
|
|
163
|
+
sourceWidth: number
|
|
164
|
+
sourceHeight: number
|
|
165
|
+
/** Overrides the measured table (tests, and a caller that already looked up). */
|
|
166
|
+
canvas?: VideoOutputCanvas
|
|
167
|
+
tolerance?: number
|
|
168
|
+
}): FrameFitPlan | null {
|
|
169
|
+
const { fit, sourceWidth, sourceHeight } = args
|
|
170
|
+
if (fit === "original") return null
|
|
171
|
+
if (!(sourceWidth > 0) || !(sourceHeight > 0)) return null
|
|
172
|
+
|
|
173
|
+
const aspectToken = resolveFrameFitAspect({
|
|
174
|
+
provider: args.provider,
|
|
175
|
+
requestedAspect: args.aspect,
|
|
176
|
+
sourceWidth,
|
|
177
|
+
sourceHeight,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const canvas = fit === "resolution"
|
|
181
|
+
? args.canvas ?? resolveOutputCanvas(args.provider, args.resolution, aspectToken)
|
|
182
|
+
: undefined
|
|
183
|
+
|
|
184
|
+
const targetAspect = canvas ? canvas[0] / canvas[1] : parseAspectToken(aspectToken)
|
|
185
|
+
if (targetAspect === undefined || !(targetAspect > 0)) return null
|
|
186
|
+
|
|
187
|
+
const sourceAspect = sourceWidth / sourceHeight
|
|
188
|
+
const gap = Math.abs(sourceAspect - targetAspect) / targetAspect
|
|
189
|
+
const tolerance = args.tolerance ?? FRAME_FIT_STRETCH_TOLERANCE
|
|
190
|
+
const crop = gap > tolerance ? centreCropToAspect(sourceWidth, sourceHeight, targetAspect) : undefined
|
|
191
|
+
|
|
192
|
+
const target = canvas
|
|
193
|
+
? { width: canvas[0], height: canvas[1] }
|
|
194
|
+
: minimalRatioDimensions(
|
|
195
|
+
crop ? crop.width : sourceWidth,
|
|
196
|
+
crop ? crop.height : sourceHeight,
|
|
197
|
+
targetAspect,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
const unchanged = target.width === sourceWidth && target.height === sourceHeight && !crop
|
|
201
|
+
if (unchanged) return null
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
width: target.width,
|
|
205
|
+
height: target.height,
|
|
206
|
+
...(crop ? { crop } : {}),
|
|
207
|
+
reason: canvas ? "resolution" : "ratio",
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Frame or reference? `auto` reads the per-provider default measured above.
|
|
213
|
+
* Reference delivery is only possible on models that accept reference images —
|
|
214
|
+
* on the others `reference` collapses back to `frame` rather than dropping the
|
|
215
|
+
* frame on the floor.
|
|
216
|
+
*/
|
|
217
|
+
export function resolveFrameDelivery(args: {
|
|
218
|
+
provider: string | undefined
|
|
219
|
+
requested: FrameDelivery | undefined
|
|
220
|
+
supportsReferenceImages: boolean
|
|
221
|
+
}): Exclude<FrameDelivery, "auto"> {
|
|
222
|
+
const requested = args.requested ?? DEFAULT_FRAME_DELIVERY
|
|
223
|
+
const wanted = requested === "auto"
|
|
224
|
+
? (args.provider ? FRAME_DELIVERY_BY_PROVIDER[args.provider] ?? "frame" : "frame")
|
|
225
|
+
: requested
|
|
226
|
+
if (wanted === "reference" && !args.supportsReferenceImages) return "frame"
|
|
227
|
+
return wanted
|
|
228
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real output canvases per (video model, resolution, aspect ratio).
|
|
3
|
+
*
|
|
4
|
+
* Every entry is a pixel size read off a finished render with ffprobe — the
|
|
5
|
+
* geometry a model hands back, never a rate or a price. None of it is
|
|
6
|
+
* arithmetic either, because the providers do not follow arithmetic:
|
|
7
|
+
*
|
|
8
|
+
* - minimax-h3 at 768P returns 768x1344 (0.5714) for a 9:16 request,
|
|
9
|
+
* - seedance-2 at 480p 16:9 returns 864x496 (1.742) while seedance-2-5
|
|
10
|
+
* returns 854x480 (1.778) for the same request,
|
|
11
|
+
* - grok returns 736x400 (1.84), seedance 1.0 at 1080p 1:1 returns 1440x1440.
|
|
12
|
+
*
|
|
13
|
+
* A formula would get all four wrong, which is why `resolveOutputCanvas`
|
|
14
|
+
* answers `undefined` for anything not measured: callers must then leave the
|
|
15
|
+
* frame alone rather than invent geometry (see `computeFrameFitPlan`).
|
|
16
|
+
*
|
|
17
|
+
* HOW TO EXTEND: run `node tools/harvest-output-canvas.mjs` (it pages the admin
|
|
18
|
+
* jobs API, groups completed video jobs by provider/resolution/aspect and probes
|
|
19
|
+
* real outputs) and paste new rows here. Take rows from REFERENCE or
|
|
20
|
+
* TEXT-TO-VIDEO jobs only: in frame mode the adaptive models (the seedance and
|
|
21
|
+
* wan families) size the output from the INPUT image, so those rows describe the
|
|
22
|
+
* input that was sent, not the canvas the model would choose on its own.
|
|
23
|
+
*
|
|
24
|
+
* Seeded 2026-09-16 from 3000 completed production jobs.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** `[width, height]` in pixels. */
|
|
28
|
+
export type VideoOutputCanvas = readonly [number, number]
|
|
29
|
+
|
|
30
|
+
/** provider → resolution (lower-cased) → aspect token → canvas. */
|
|
31
|
+
export const VIDEO_OUTPUT_CANVAS: Readonly<
|
|
32
|
+
Record<string, Readonly<Record<string, Readonly<Record<string, VideoOutputCanvas>>>>>
|
|
33
|
+
> = {
|
|
34
|
+
"seedance-2-5": {
|
|
35
|
+
"480p": { "16:9": [854, 480], "9:16": [480, 854] },
|
|
36
|
+
"720p": { "16:9": [1280, 720], "9:16": [720, 1280], "1:1": [960, 960] },
|
|
37
|
+
"1080p": { "16:9": [1920, 1080] },
|
|
38
|
+
},
|
|
39
|
+
// The 2.0 family is NOT the same as 2.5 at 480p — 864x496 vs 854x480.
|
|
40
|
+
"seedance-2": {
|
|
41
|
+
"480p": { "16:9": [864, 496], "9:16": [496, 864] },
|
|
42
|
+
"720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
|
|
43
|
+
},
|
|
44
|
+
"seedance-2-fast": {
|
|
45
|
+
"480p": { "16:9": [864, 496], "9:16": [496, 864] },
|
|
46
|
+
"720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
|
|
47
|
+
},
|
|
48
|
+
"seedance-2-mini": {
|
|
49
|
+
"480p": { "16:9": [864, 496], "9:16": [496, 864] },
|
|
50
|
+
"720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
|
|
51
|
+
},
|
|
52
|
+
"gemini-omni-video": {
|
|
53
|
+
"720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
|
|
54
|
+
"1080p": { "16:9": [1920, 1080] },
|
|
55
|
+
},
|
|
56
|
+
"gemini-omni-flash": {
|
|
57
|
+
"720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
|
|
58
|
+
},
|
|
59
|
+
"veo3.1": {
|
|
60
|
+
"720p": { "9:16": [720, 1280] },
|
|
61
|
+
"1080p": { "16:9": [1920, 1080] },
|
|
62
|
+
},
|
|
63
|
+
"wan-3": {
|
|
64
|
+
"480p": { "16:9": [832, 480] },
|
|
65
|
+
"720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
|
|
66
|
+
},
|
|
67
|
+
"wan-3-prime": {
|
|
68
|
+
"720p": { "16:9": [1280, 720] },
|
|
69
|
+
},
|
|
70
|
+
// 768P's "9:16" is 0.5714, not 0.5625 — the single clearest reason this table
|
|
71
|
+
// exists.
|
|
72
|
+
"minimax-h3": {
|
|
73
|
+
"768p": { "9:16": [768, 1344] },
|
|
74
|
+
"2k": { "16:9": [2560, 1440], "9:16": [1440, 2560] },
|
|
75
|
+
},
|
|
76
|
+
seedance: {
|
|
77
|
+
"1080p": {
|
|
78
|
+
"16:9": [1920, 1080],
|
|
79
|
+
"9:16": [1080, 1920],
|
|
80
|
+
"1:1": [1440, 1440],
|
|
81
|
+
"3:4": [1248, 1664],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The canvas a model renders for this request, or `undefined` when we have never
|
|
88
|
+
* measured that combination. `undefined` means "leave the frame alone".
|
|
89
|
+
*
|
|
90
|
+
* Resolution matching is case-insensitive (`768P`, `2K`, `720p` all appear in
|
|
91
|
+
* the wild). An aspect of `adaptive` / `Auto` has no canvas of its own — the
|
|
92
|
+
* caller resolves it to a concrete ratio first (`resolveFrameFitAspect`).
|
|
93
|
+
*/
|
|
94
|
+
export function resolveOutputCanvas(
|
|
95
|
+
provider: string | undefined,
|
|
96
|
+
resolution: string | undefined,
|
|
97
|
+
aspect: string | undefined,
|
|
98
|
+
): VideoOutputCanvas | undefined {
|
|
99
|
+
if (!provider || !resolution || !aspect) return undefined
|
|
100
|
+
return VIDEO_OUTPUT_CANVAS[provider]?.[resolution.toLowerCase()]?.[aspect]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every measured combination, for tests and for the harvest script's diff. */
|
|
104
|
+
export function measuredCanvasCombinations(): Array<{
|
|
105
|
+
provider: string
|
|
106
|
+
resolution: string
|
|
107
|
+
aspect: string
|
|
108
|
+
canvas: VideoOutputCanvas
|
|
109
|
+
}> {
|
|
110
|
+
const out: Array<{ provider: string; resolution: string; aspect: string; canvas: VideoOutputCanvas }> = []
|
|
111
|
+
for (const [provider, byResolution] of Object.entries(VIDEO_OUTPUT_CANVAS)) {
|
|
112
|
+
for (const [resolution, byAspect] of Object.entries(byResolution)) {
|
|
113
|
+
for (const [aspect, canvas] of Object.entries(byAspect)) {
|
|
114
|
+
out.push({ provider, resolution, aspect, canvas })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
package/src/workflow-export.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GenericNode, GenericEdge } from "./types.js"
|
|
2
2
|
import { EXECUTION_DATA_KEYS } from "./node-runtime-keys.js"
|
|
3
|
+
import { SOCIAL_POST_NODE_TYPES } from "./social-post.js"
|
|
3
4
|
|
|
4
5
|
/** A named media variant (expression, pose, angle, etc.) produced during entity generation. */
|
|
5
6
|
interface AssetVariant {
|
|
@@ -192,6 +193,10 @@ const GENERATED_FIELDS: readonly string[] = [
|
|
|
192
193
|
|
|
193
194
|
/** Per-node-type extra generated fields beyond GENERATED_FIELDS. Unknown types get no extras ([] default). */
|
|
194
195
|
const NODE_EXTRA_FIELDS: Record<string, string[]> = {
|
|
196
|
+
// A template must never import ARMED: the switch is the importer's to flip
|
|
197
|
+
// (a schedule starts paused), and the rules themselves are config that
|
|
198
|
+
// travels.
|
|
199
|
+
"schedule-trigger": ["active"],
|
|
195
200
|
character: ["expressions", "poses", "lightingVariations", "angles", "customVariations"],
|
|
196
201
|
object: ["angles", "materials", "variations", "customVariations"],
|
|
197
202
|
creature: ["angles", "poses", "variations", "customVariations"],
|
|
@@ -216,9 +221,40 @@ const NODE_EXTRA_FIELDS: Record<string, string[]> = {
|
|
|
216
221
|
"sub-workflow": ["referencedWorkflowId"],
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Node fields that point at a row the IMPORTER does not own — a stored HTTP
|
|
226
|
+
* credential behind a Webhook Output, a connected social account behind a
|
|
227
|
+
* publisher. The id alone is useless without owning the row (resolution is
|
|
228
|
+
* owner-scoped), so nothing leaks either way; the point is that the imported
|
|
229
|
+
* node lands UNBOUND and the importer picks their own, instead of carrying a
|
|
230
|
+
* dangling pointer at the exporter's account. Applied by every export shape,
|
|
231
|
+
* including the asset bundle that otherwise keeps node data verbatim.
|
|
232
|
+
*/
|
|
233
|
+
const UNOWNED_REF_FIELDS: Record<string, readonly string[]> = {
|
|
234
|
+
"webhook-output": ["credentialId"],
|
|
235
|
+
...Object.fromEntries([...SOCIAL_POST_NODE_TYPES].map((type) => [type, ["connectionId"]])),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Clear owner-bound references (credential / connection ids). Returns new node objects; inputs are not mutated. */
|
|
239
|
+
export function stripUnownedRefs(nodes: GenericNode[]): GenericNode[] {
|
|
240
|
+
return nodes.map((node) => {
|
|
241
|
+
const fields = UNOWNED_REF_FIELDS[node.type]
|
|
242
|
+
if (!fields || !node.data) return node
|
|
243
|
+
const data = { ...(node.data as Record<string, unknown>) }
|
|
244
|
+
let changed = false
|
|
245
|
+
for (const field of fields) {
|
|
246
|
+
if (field in data) {
|
|
247
|
+
delete data[field]
|
|
248
|
+
changed = true
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return changed ? { ...node, data } : node
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
219
255
|
/** Strip generated/transient content from nodes for template export. Returns new node objects; inputs are not mutated. */
|
|
220
256
|
export function stripExportContent(nodes: GenericNode[]): GenericNode[] {
|
|
221
|
-
return nodes.map((node) => {
|
|
257
|
+
return stripUnownedRefs(nodes).map((node) => {
|
|
222
258
|
const data = { ...(node.data as Record<string, unknown>) }
|
|
223
259
|
for (const field of GENERATED_FIELDS) delete data[field]
|
|
224
260
|
const extras = NODE_EXTRA_FIELDS[node.type] ?? []
|