@nodaro/shared 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/index.cjs +2242 -86
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1779 -32
  4. package/dist/index.d.ts +1779 -32
  5. package/dist/index.js +2041 -87
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/caption-styles.test.ts +207 -0
  9. package/src/__tests__/edl-multicam.test.ts +304 -0
  10. package/src/__tests__/edl.test.ts +822 -0
  11. package/src/__tests__/fan-out-rows.test.ts +208 -0
  12. package/src/__tests__/instagram-scrape.test.ts +66 -0
  13. package/src/__tests__/llm-models.test.ts +48 -11
  14. package/src/__tests__/meta-ads-scrape.test.ts +284 -0
  15. package/src/__tests__/node-runtime-keys.test.ts +15 -0
  16. package/src/__tests__/parameter-node-value.test.ts +13 -1
  17. package/src/__tests__/presentation-utils.test.ts +67 -0
  18. package/src/__tests__/producer-types.test.ts +19 -0
  19. package/src/__tests__/schedule-rules.test.ts +265 -0
  20. package/src/__tests__/speaker-layouts.test.ts +203 -0
  21. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  22. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  23. package/src/__tests__/trigger-feeds.test.ts +39 -0
  24. package/src/__tests__/video-analysis.test.ts +15 -0
  25. package/src/__tests__/video-duration-auto.test.ts +65 -0
  26. package/src/__tests__/video-duration.test.ts +56 -0
  27. package/src/__tests__/video-frame-fit.test.ts +189 -0
  28. package/src/__tests__/video-link.test.ts +137 -0
  29. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  30. package/src/caption-styles.ts +240 -0
  31. package/src/catalog-projection.ts +3 -0
  32. package/src/character-motion-metadata.ts +19 -0
  33. package/src/credit-identifiers.ts +31 -0
  34. package/src/edit-plan-contract.ts +96 -0
  35. package/src/edl-multicam.ts +185 -0
  36. package/src/edl.ts +747 -0
  37. package/src/entity-image-handle.ts +24 -1
  38. package/src/fan-out-rows.ts +213 -0
  39. package/src/i18n/character-motion.ar.ts +126 -75
  40. package/src/i18n/character-motion.de.ts +126 -75
  41. package/src/i18n/character-motion.es.ts +126 -75
  42. package/src/i18n/character-motion.fr.ts +126 -75
  43. package/src/i18n/character-motion.he.ts +126 -75
  44. package/src/i18n/character-motion.hi.ts +126 -75
  45. package/src/i18n/character-motion.ja.ts +126 -75
  46. package/src/i18n/character-motion.ko.ts +126 -75
  47. package/src/i18n/character-motion.pt-BR.ts +126 -75
  48. package/src/i18n/character-motion.ru.ts +126 -75
  49. package/src/i18n/character-motion.zh-CN.ts +126 -75
  50. package/src/index.ts +211 -3
  51. package/src/instagram-scrape.ts +204 -0
  52. package/src/llm-models.ts +80 -3
  53. package/src/meta-ads-scrape.ts +463 -0
  54. package/src/model-catalog.ts +48 -5
  55. package/src/model-constants.ts +148 -5
  56. package/src/node-mappable-fields.ts +2 -0
  57. package/src/node-runtime-keys.ts +28 -0
  58. package/src/parameter-node-value.ts +31 -5
  59. package/src/presentation-utils.ts +49 -0
  60. package/src/producer-types.ts +20 -0
  61. package/src/schedule-rules.ts +484 -0
  62. package/src/speaker-layouts.ts +220 -0
  63. package/src/transcribe-preflight.ts +101 -0
  64. package/src/trigger-feeds.ts +59 -0
  65. package/src/trigger-node-types.ts +20 -0
  66. package/src/video-analysis.ts +15 -0
  67. package/src/video-duration-auto.ts +18 -0
  68. package/src/video-duration.ts +32 -0
  69. package/src/video-frame-fit.ts +228 -0
  70. package/src/video-link.ts +167 -0
  71. package/src/video-output-canvas.ts +119 -0
  72. package/src/workflow-export.ts +37 -1
@@ -0,0 +1,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
+ }
@@ -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] ?? []