@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,39 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildFeedMaps, nodeFeedsAnything } from "../trigger-feeds.js"
3
+
4
+ const node = (id: string, extra: Record<string, unknown> = {}) => ({ id, ...extra })
5
+ const edge = (source: string, target: string) => ({ source, target })
6
+
7
+ describe("buildFeedMaps — every way one node feeds another", () => {
8
+ it("a drawn edge feeds; an edge whose end left the graph feeds nothing; a self-loop feeds nothing", () => {
9
+ const { children, parents } = buildFeedMaps([node("a"), node("b")], [edge("a", "b"), edge("a", "gone"), edge("gone", "b"), edge("b", "b")])
10
+ expect(children.get("a")).toEqual(["b"])
11
+ expect(children.get("b")).toBeUndefined()
12
+ expect(parents.get("b")).toEqual(["a"])
13
+ expect(children.has("gone")).toBe(false)
14
+ })
15
+
16
+ it("a node inside a Group feeds the group", () => {
17
+ const { children } = buildFeedMaps([node("img", { parentId: "G" }), node("G")], [])
18
+ expect(children.get("img")).toEqual(["G"])
19
+ })
20
+
21
+ it("a field mapping feeds its node by sourceNodeId, even with no edge", () => {
22
+ const { children, parents } = buildFeedMaps(
23
+ [node("style"), node("img", { data: { fieldMappings: { prompt: { sourceNodeId: "style" }, seed: { sourceNodeId: "gone" } } } })],
24
+ [],
25
+ )
26
+ expect(children.get("style")).toEqual(["img"])
27
+ expect(parents.get("img")).toEqual(["style"])
28
+ })
29
+ })
30
+
31
+ describe("nodeFeedsAnything — the editor's 'is this trigger wired?'", () => {
32
+ it("agrees with the maps in both directions", () => {
33
+ const nodes = [node("sched"), node("img"), node("G"), node("member", { parentId: "G" })]
34
+ expect(nodeFeedsAnything(nodes, [edge("sched", "img")], "sched")).toBe(true)
35
+ expect(nodeFeedsAnything(nodes, [edge("sched", "gone")], "sched")).toBe(false)
36
+ expect(nodeFeedsAnything(nodes, [], "member")).toBe(true)
37
+ expect(nodeFeedsAnything(nodes, [], "sched")).toBe(false)
38
+ })
39
+ })
@@ -62,6 +62,21 @@ const dreamVariation = {
62
62
  refImageUrl: "https://cdn.example/frames/hero-dream.jpg",
63
63
  }
64
64
 
65
+ describe("owned objects (2026-09-17)", () => {
66
+ it("entitySlotSchema round-trips owner { slotId, relation }", () => {
67
+ const owner = { slotId: "man-blue", relation: "worn by" }
68
+ const parsed = entitySlotSchema.parse({ ...slot, owner })
69
+ expect(parsed.owner).toEqual(owner)
70
+ })
71
+ it("absent owner stays absent", () => {
72
+ expect("owner" in entitySlotSchema.parse(slot)).toBe(false)
73
+ })
74
+ it("rejects an owner without a relation or with a malformed slot id", () => {
75
+ expect(entitySlotSchema.safeParse({ ...slot, owner: { slotId: "man-blue" } }).success).toBe(false)
76
+ expect(entitySlotSchema.safeParse({ ...slot, owner: { slotId: "Man Blue", relation: "worn by" } }).success).toBe(false)
77
+ })
78
+ })
79
+
65
80
  describe("appearance variations (cast-variations spec §4)", () => {
66
81
  it("entitySlotSchema round-trips variations[] including refImageUrl", () => {
67
82
  const parsed = entitySlotSchema.parse({ ...slot, variations: [dreamVariation] })
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ VIDEO_DURATION_AUTO,
4
+ isAutoVideoDuration,
5
+ supportsAutoVideoDuration,
6
+ maxVideoDurationSec,
7
+ pricedOutputDurationSec,
8
+ buildVideoCreditModelIdentifier,
9
+ normalizeModelInput,
10
+ validateModelInput,
11
+ MODEL_CATALOG,
12
+ SEEDANCE_2_PROVIDERS,
13
+ } from "../index.js"
14
+
15
+ /**
16
+ * Auto duration (`duration: -1`) — the model picks the clip length. The risk is
17
+ * entirely on the billing side: `commit_credits` refunds a surplus but never
18
+ * collects a deficit, so an Auto run must be PRICED at the longest clip the
19
+ * model can render, on every lane, by default.
20
+ */
21
+ describe("auto video duration", () => {
22
+ it("is KIE's own sentinel, from a number or a string", () => {
23
+ expect(VIDEO_DURATION_AUTO).toBe(-1)
24
+ expect(isAutoVideoDuration(-1)).toBe(true)
25
+ expect(isAutoVideoDuration("-1")).toBe(true)
26
+ for (const v of [undefined, null, 0, 1, 8, "8", "auto", NaN]) expect(isAutoVideoDuration(v), String(v)).toBe(false)
27
+ })
28
+
29
+ it("is a catalog capability of exactly the Seedance 2 family", () => {
30
+ const declared = Object.keys(MODEL_CATALOG).filter((id) => MODEL_CATALOG[id]!.autoDuration === true).sort()
31
+ expect(declared).toEqual([...SEEDANCE_2_PROVIDERS].sort())
32
+ for (const id of declared) expect(supportsAutoVideoDuration(id)).toBe(true)
33
+ expect(supportsAutoVideoDuration("kling-3.0")).toBe(false)
34
+ expect(supportsAutoVideoDuration(undefined)).toBe(false)
35
+ })
36
+
37
+ it("prices at the model's LONGEST clip — the top tier is also the catalog's longest duration", () => {
38
+ for (const id of SEEDANCE_2_PROVIDERS) {
39
+ const ceiling = maxVideoDurationSec(id)!
40
+ expect(ceiling, id).toBe(Math.max(...MODEL_CATALOG[id]!.durations!))
41
+ expect(pricedOutputDurationSec(id, VIDEO_DURATION_AUTO), id).toBe(ceiling)
42
+ expect(pricedOutputDurationSec(id, "-1"), id).toBe(ceiling)
43
+ }
44
+ expect(pricedOutputDurationSec("seedance-2-5", -1)).toBe(30)
45
+ expect(pricedOutputDurationSec("seedance-2", -1)).toBe(15)
46
+ })
47
+
48
+ it("never prices the cheapest tier for Auto (the under-reserve this guards)", () => {
49
+ expect(buildVideoCreditModelIdentifier("seedance-2-5", -1, undefined, undefined, undefined, "480p")).toBe("seedance-2-5:30s:480p")
50
+ expect(buildVideoCreditModelIdentifier("seedance-2", -1, undefined, undefined, undefined, "720p", true)).toBe("seedance-2:15s:720p-ref")
51
+ })
52
+
53
+ it("a model without the capability prices its render default, not a ceiling it will not render", () => {
54
+ expect(pricedOutputDurationSec("minimax-h3", -1)).toBe(pricedOutputDurationSec("minimax-h3", undefined))
55
+ expect(pricedOutputDurationSec("seedance-2-5", 0)).toBe(pricedOutputDurationSec("seedance-2-5", undefined))
56
+ })
57
+
58
+ it("passes the catalog normalizer and validator only where declared", () => {
59
+ expect(normalizeModelInput("seedance-2-5", { duration: -1 }).duration).toBe(-1)
60
+ expect(validateModelInput("seedance-2-5", { duration: -1 })).toBeNull()
61
+ const other = Object.keys(MODEL_CATALOG).find((id) => MODEL_CATALOG[id]!.durations && !MODEL_CATALOG[id]!.autoDuration)!
62
+ expect(normalizeModelInput(other, { duration: -1 }).duration).toBe(MODEL_CATALOG[other]!.durations![0])
63
+ expect(validateModelInput(other, { duration: -1 })?.field).toBe("duration")
64
+ })
65
+ })
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { editPlanSourceDurationSec, extractVideoDurationFromNode } from "../video-duration.js"
3
+
4
+ describe("editPlanSourceDurationSec", () => {
5
+ it("reads a video node's own length first", () => {
6
+ expect(editPlanSourceDurationSec({ duration: 90 })).toBe(90)
7
+ expect(editPlanSourceDurationSec({ activeResultIndex: 0, generatedResults: [{ duration: 42 }] })).toBe(42)
8
+ expect(extractVideoDurationFromNode({ duration: 90 })).toBe(90)
9
+ })
10
+
11
+ it("reads the audio lane's metadata.durationSeconds (upload-audio: unstamped, trusted as ever)", () => {
12
+ expect(editPlanSourceDurationSec({ url: "https://cdn/a.mp3", metadata: { durationSeconds: 2700 } })).toBe(2700)
13
+ })
14
+
15
+ it("rejects nonsense lengths", () => {
16
+ for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, "2700", null]) {
17
+ expect(editPlanSourceDurationSec({ metadata: { durationSeconds: bad } })).toBeUndefined()
18
+ }
19
+ expect(editPlanSourceDurationSec(undefined)).toBeUndefined()
20
+ expect(editPlanSourceDurationSec({})).toBeUndefined()
21
+ })
22
+ })
23
+
24
+ // The read-side invariant. A length stamped with the media it was measured from
25
+ // is trusted only while that media is still the node's — so NO writer can make
26
+ // it stale: not a copilot patch, an MCP workflow-JSON write, an import, a
27
+ // run-time override, nor code that has not been written yet. A mismatch reads as
28
+ // "unknown", and every caller then falls to its safe side instead of
29
+ // under-bucketing a longer file.
30
+ describe("editPlanSourceDurationSec — a stamped length is bound to its media", () => {
31
+ const stamped = (mediaUrl: string) => ({ durationSeconds: 720, mediaUrl })
32
+
33
+ it("trusts the length while the stamp matches the node's extracted audio", () => {
34
+ expect(
35
+ editPlanSourceDurationSec({ extractedAudioUrl: "https://cdn/ep41.mp3", metadata: stamped("https://cdn/ep41.mp3") }),
36
+ ).toBe(720)
37
+ })
38
+
39
+ it("ignores it once the media was swapped underneath — whoever swapped it", () => {
40
+ // e.g. copilot `patchNodes { extractedAudioUrl }` / MCP update_workflow_json:
41
+ // a shallow merge that replaces the url and carries `metadata` along.
42
+ expect(
43
+ editPlanSourceDurationSec({ extractedAudioUrl: "https://host/ep42-3h.mp3", metadata: stamped("https://cdn/ep41.mp3") }),
44
+ ).toBeUndefined()
45
+ })
46
+
47
+ it("ignores it when the media was cleared", () => {
48
+ expect(editPlanSourceDurationSec({ extractedAudioUrl: "", metadata: stamped("https://cdn/ep41.mp3") })).toBeUndefined()
49
+ expect(editPlanSourceDurationSec({ metadata: stamped("https://cdn/ep41.mp3") })).toBeUndefined()
50
+ })
51
+
52
+ it("accepts a stamp matching the node's `url` field too (upload-style nodes)", () => {
53
+ expect(editPlanSourceDurationSec({ url: "https://cdn/a.mp3", metadata: stamped("https://cdn/a.mp3") })).toBe(720)
54
+ expect(editPlanSourceDurationSec({ url: "https://cdn/b.mp3", metadata: stamped("https://cdn/a.mp3") })).toBeUndefined()
55
+ })
56
+ })
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ computeFrameFitPlan,
4
+ resolveFrameFitAspect,
5
+ resolveFrameDelivery,
6
+ minimalRatioDimensions,
7
+ centreCropToAspect,
8
+ parseAspectToken,
9
+ FRAME_FIT_STRETCH_TOLERANCE,
10
+ } from "../video-frame-fit.js"
11
+ import { resolveOutputCanvas, measuredCanvasCombinations } from "../video-output-canvas.js"
12
+
13
+ /** The image behind every number in this file: a 1K 9:16 GPT image. */
14
+ const GPT_1K = { sourceWidth: 940, sourceHeight: 1672 }
15
+
16
+ describe("measured output canvases", () => {
17
+ it("answers the combinations we measured, case-insensitively on resolution", () => {
18
+ expect(resolveOutputCanvas("seedance-2-5", "720p", "9:16")).toEqual([720, 1280])
19
+ expect(resolveOutputCanvas("minimax-h3", "768P", "9:16")).toEqual([768, 1344])
20
+ expect(resolveOutputCanvas("minimax-h3", "2K", "16:9")).toEqual([2560, 1440])
21
+ })
22
+
23
+ it("keeps the 2.0 family apart from 2.5 at 480p — they really do differ", () => {
24
+ expect(resolveOutputCanvas("seedance-2-5", "480p", "16:9")).toEqual([854, 480])
25
+ expect(resolveOutputCanvas("seedance-2-fast", "480p", "16:9")).toEqual([864, 496])
26
+ })
27
+
28
+ it("answers undefined for anything never measured", () => {
29
+ expect(resolveOutputCanvas("seedance-2-5", "4k", "9:16")).toBeUndefined()
30
+ expect(resolveOutputCanvas("kling-3.0", "720p", "16:9")).toBeUndefined()
31
+ expect(resolveOutputCanvas(undefined, "720p", "9:16")).toBeUndefined()
32
+ })
33
+
34
+ it("stores only even, positive dimensions", () => {
35
+ for (const { provider, resolution, aspect, canvas } of measuredCanvasCombinations()) {
36
+ const where = `${provider} ${resolution} ${aspect}`
37
+ expect(canvas[0] % 2, where).toBe(0)
38
+ expect(canvas[1] % 2, where).toBe(0)
39
+ expect(canvas[0] > 0 && canvas[1] > 0, where).toBe(true)
40
+ }
41
+ })
42
+
43
+ it("does not claim a canvas whose ratio is wildly off the label", () => {
44
+ // H3's 768P 9:16 really is 0.5714 — a 1.6% lie we keep on purpose. Anything
45
+ // further out is a typo, not a provider quirk.
46
+ for (const { provider, resolution, aspect, canvas } of measuredCanvasCombinations()) {
47
+ const label = parseAspectToken(aspect)
48
+ if (label === undefined) continue
49
+ const actual = canvas[0] / canvas[1]
50
+ expect(Math.abs(actual - label) / label, `${provider} ${resolution} ${aspect}`).toBeLessThan(0.06)
51
+ }
52
+ })
53
+ })
54
+
55
+ describe("aspect resolution", () => {
56
+ it("uses an explicit ratio as-is", () => {
57
+ expect(resolveFrameFitAspect({ provider: "seedance-2-5", requestedAspect: "16:9", ...GPT_1K })).toBe("16:9")
58
+ })
59
+
60
+ it("snaps adaptive and Auto to the model's nearest listed ratio", () => {
61
+ // 940x1672 = 0.5622, which is 9:16 to within 0.05%.
62
+ expect(resolveFrameFitAspect({ provider: "seedance-2-5", requestedAspect: "adaptive", ...GPT_1K })).toBe("9:16")
63
+ expect(resolveFrameFitAspect({ provider: "seedance-2-5", requestedAspect: "Auto", ...GPT_1K })).toBe("9:16")
64
+ expect(resolveFrameFitAspect({ provider: "seedance-2-5", requestedAspect: undefined, sourceWidth: 1920, sourceHeight: 1080 })).toBe("16:9")
65
+ })
66
+
67
+ it("gives up when the model declares no ratios", () => {
68
+ expect(resolveFrameFitAspect({ provider: "not-a-model", requestedAspect: "adaptive", ...GPT_1K })).toBeUndefined()
69
+ })
70
+ })
71
+
72
+ describe("computeFrameFitPlan", () => {
73
+ it("resizes the 1K image to the measured canvas — the case that fixed the snap", () => {
74
+ const plan = computeFrameFitPlan({
75
+ fit: "resolution", provider: "seedance-2-5", resolution: "720p", aspect: "9:16", ...GPT_1K,
76
+ })
77
+ expect(plan).toEqual({ width: 720, height: 1280, reason: "resolution" })
78
+ })
79
+
80
+ it("targets H3's real 768x1344 canvas rather than a true 9:16", () => {
81
+ const plan = computeFrameFitPlan({
82
+ fit: "resolution", provider: "minimax-h3", resolution: "768P", aspect: "9:16", ...GPT_1K,
83
+ })
84
+ expect(plan?.width).toBe(768)
85
+ expect(plan?.height).toBe(1344)
86
+ expect(plan?.crop).toBeUndefined() // 1.6% gap is inside the tolerance → stretch
87
+ })
88
+
89
+ it("does nothing when the frame is already the canvas", () => {
90
+ expect(computeFrameFitPlan({
91
+ fit: "resolution", provider: "seedance-2-5", resolution: "720p", aspect: "9:16",
92
+ sourceWidth: 720, sourceHeight: 1280,
93
+ })).toBeNull()
94
+ })
95
+
96
+ it("does nothing in original mode, whatever the size", () => {
97
+ expect(computeFrameFitPlan({
98
+ fit: "original", provider: "seedance-2-5", resolution: "720p", aspect: "9:16", ...GPT_1K,
99
+ })).toBeNull()
100
+ })
101
+
102
+ it("degrades resolution → ratio when the combination was never measured", () => {
103
+ const plan = computeFrameFitPlan({
104
+ fit: "resolution", provider: "seedance-2-5", resolution: "4k", aspect: "16:9",
105
+ sourceWidth: 1000, sourceHeight: 1000,
106
+ })
107
+ // No 4k canvas on file, so it falls back to the minimal change that makes 16:9.
108
+ expect(plan?.reason).toBe("ratio")
109
+ expect(plan!.width / plan!.height).toBeCloseTo(16 / 9, 2)
110
+ })
111
+
112
+ it("degrades to nothing when neither a canvas nor an aspect can be resolved", () => {
113
+ expect(computeFrameFitPlan({
114
+ fit: "resolution", provider: "not-a-model", resolution: "720p", aspect: undefined, ...GPT_1K,
115
+ })).toBeNull()
116
+ })
117
+
118
+ it("stretches inside the tolerance and crops outside it", () => {
119
+ // 4% off 9:16 — inside 5%, so a plain stretch, no crop.
120
+ const inside = computeFrameFitPlan({
121
+ fit: "ratio", provider: "seedance-2-5", resolution: "720p", aspect: "9:16",
122
+ sourceWidth: 1000, sourceHeight: 1710,
123
+ })
124
+ expect(Math.abs(1000 / 1710 - 9 / 16) / (9 / 16)).toBeLessThan(FRAME_FIT_STRETCH_TOLERANCE)
125
+ expect(inside?.crop).toBeUndefined()
126
+
127
+ // A square photo into 9:16 is a 78% gap — crop first, never squash.
128
+ const outside = computeFrameFitPlan({
129
+ fit: "resolution", provider: "seedance-2-5", resolution: "720p", aspect: "9:16",
130
+ sourceWidth: 1024, sourceHeight: 1024,
131
+ })
132
+ expect(outside).toMatchObject({ width: 720, height: 1280, reason: "resolution" })
133
+ expect(outside?.crop).toEqual({ left: 224, top: 0, width: 576, height: 1024 })
134
+ expect(outside!.crop!.width / outside!.crop!.height).toBeCloseTo(9 / 16, 3)
135
+ })
136
+
137
+ it("produces even dimensions (yuv420p rejects odd ones)", () => {
138
+ const plan = computeFrameFitPlan({
139
+ fit: "ratio", provider: "seedance-2-5", resolution: "720p", aspect: "16:9",
140
+ sourceWidth: 1001, sourceHeight: 667,
141
+ })
142
+ expect(plan!.width % 2).toBe(0)
143
+ expect(plan!.height % 2).toBe(0)
144
+ })
145
+ })
146
+
147
+ describe("geometry helpers", () => {
148
+ it("minimalRatioDimensions keeps the long side and moves the short one", () => {
149
+ // 940 wide is 9:16 at 1671.1 tall, which rounds back to the image's own
150
+ // 1672 — the 1K GPT image really is 9:16 to the nearest even pixel, so
151
+ // "match ratio" alone would leave it untouched (and the snap would stay).
152
+ expect(minimalRatioDimensions(940, 1672, 9 / 16)).toEqual({ width: 940, height: 1672 })
153
+ expect(minimalRatioDimensions(1920, 1000, 16 / 9)).toEqual({ width: 1920, height: 1080 })
154
+ })
155
+
156
+ it("centreCropToAspect drops the overhang evenly", () => {
157
+ expect(centreCropToAspect(1024, 1024, 9 / 16)).toEqual({ left: 224, top: 0, width: 576, height: 1024 })
158
+ expect(centreCropToAspect(1000, 1000, 16 / 9)).toEqual({ left: 0, top: 219, width: 1000, height: 562 })
159
+ })
160
+
161
+ it("parseAspectToken reads the tokens the catalog uses", () => {
162
+ expect(parseAspectToken("16:9")).toBeCloseTo(16 / 9, 6)
163
+ expect(parseAspectToken("9:16")).toBeCloseTo(9 / 16, 6)
164
+ expect(parseAspectToken("adaptive")).toBeUndefined()
165
+ expect(parseAspectToken(undefined)).toBeUndefined()
166
+ })
167
+ })
168
+
169
+ describe("frame delivery", () => {
170
+ it("sends the Seedance 2.0 family as references and everything else as frames", () => {
171
+ const ref = { requested: "auto" as const, supportsReferenceImages: true }
172
+ expect(resolveFrameDelivery({ provider: "seedance-2-fast", ...ref })).toBe("reference")
173
+ expect(resolveFrameDelivery({ provider: "seedance-2", ...ref })).toBe("reference")
174
+ expect(resolveFrameDelivery({ provider: "seedance-2-mini", ...ref })).toBe("reference")
175
+ expect(resolveFrameDelivery({ provider: "seedance-2-5", ...ref })).toBe("frame")
176
+ expect(resolveFrameDelivery({ provider: "wan-3", ...ref })).toBe("frame")
177
+ expect(resolveFrameDelivery({ provider: "veo3.1", ...ref })).toBe("frame")
178
+ })
179
+
180
+ it("honours an explicit choice", () => {
181
+ expect(resolveFrameDelivery({ provider: "seedance-2-fast", requested: "frame", supportsReferenceImages: true })).toBe("frame")
182
+ expect(resolveFrameDelivery({ provider: "veo3.1", requested: "reference", supportsReferenceImages: true })).toBe("reference")
183
+ })
184
+
185
+ it("never asks for reference delivery on a model that takes no references", () => {
186
+ expect(resolveFrameDelivery({ provider: "seedance-2-fast", requested: "auto", supportsReferenceImages: false })).toBe("frame")
187
+ expect(resolveFrameDelivery({ provider: "wan-i2v", requested: "reference", supportsReferenceImages: false })).toBe("frame")
188
+ })
189
+ })
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ SOCIAL_VIDEO_HOSTS,
4
+ YOUTUBE_HOSTS,
5
+ INSTAGRAM_HOSTS,
6
+ VIDEO_LINK_TOLERANT_CONSUMER_TYPES,
7
+ hasUrlParserHazard,
8
+ hostnameMatchesAllowlist,
9
+ isSocialVideoUrl,
10
+ detectVideoLinkPlatform,
11
+ videoLinkDownloadedFile,
12
+ resolveVideoLinkOutput,
13
+ videoLinkNeedsDownload,
14
+ } from "../video-link.js"
15
+
16
+ describe("social video host allowlist", () => {
17
+ it("admits the domain itself and true subdomains only", () => {
18
+ expect(hostnameMatchesAllowlist("youtube.com", YOUTUBE_HOSTS)).toBe(true)
19
+ expect(hostnameMatchesAllowlist("www.youtube.com", YOUTUBE_HOSTS)).toBe(true)
20
+ expect(hostnameMatchesAllowlist("m.youtu.be", YOUTUBE_HOSTS)).toBe(true)
21
+ expect(hostnameMatchesAllowlist("YOUTUBE.COM.", YOUTUBE_HOSTS)).toBe(true)
22
+ })
23
+
24
+ it("rejects a lookalike host that merely CONTAINS an allowlisted name", () => {
25
+ expect(hostnameMatchesAllowlist("evilyoutube.com", YOUTUBE_HOSTS)).toBe(false)
26
+ expect(hostnameMatchesAllowlist("youtube.com.attacker.example", YOUTUBE_HOSTS)).toBe(false)
27
+ // "x.com" is a substring of "netflix.com" — the old loose regex called this X.
28
+ expect(isSocialVideoUrl("https://www.netflix.com/watch/1")).toBe(false)
29
+ })
30
+
31
+ it("keeps the YouTube and Instagram subsets inside the full list", () => {
32
+ for (const h of [...YOUTUBE_HOSTS, ...INSTAGRAM_HOSTS]) {
33
+ expect(SOCIAL_VIDEO_HOSTS).toContain(h)
34
+ }
35
+ })
36
+
37
+ it("isSocialVideoUrl never throws on junk", () => {
38
+ expect(isSocialVideoUrl("")).toBe(false)
39
+ expect(isSocialVideoUrl("not a url")).toBe(false)
40
+ expect(isSocialVideoUrl("ftp://youtube.com/x")).toBe(false)
41
+ expect(isSocialVideoUrl("https://www.tiktok.com/@a/video/1")).toBe(true)
42
+ expect(isSocialVideoUrl("https://www.tiktok.com/@a/video/1", YOUTUBE_HOSTS)).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe("links that URL parsers read differently", () => {
47
+ const BACKSLASH = "https://tiktok.com\\@169.254.169.254/latest/meta-data"
48
+
49
+ it("flags a backslash and every ASCII control character, and nothing else", () => {
50
+ expect(hasUrlParserHazard(BACKSLASH)).toBe(true)
51
+ for (const ch of ["\t", "\n", "\r", "\u0000", "\u001f", "\u007f"]) {
52
+ expect(hasUrlParserHazard(`https://youtu.be/aqz${ch}-KE-bpKQ`)).toBe(true)
53
+ }
54
+ expect(hasUrlParserHazard("https://www.youtube.com/watch?v=aqz-KE-bpKQ&t=30s#x")).toBe(false)
55
+ expect(hasUrlParserHazard("https://pub-x.r2.dev/avideo%20preview/98.mp4")).toBe(false)
56
+ expect(hasUrlParserHazard("https://example.com/שלום עולם.mp4")).toBe(false)
57
+ expect(hasUrlParserHazard("")).toBe(false)
58
+ })
59
+
60
+ it("is refused as a social link even though its WHATWG host is allowlisted", () => {
61
+ // The reading THIS file would make: host tiktok.com. The download tool is
62
+ // handed the raw string and may read 169.254.169.254.
63
+ expect(new URL(BACKSLASH).hostname).toBe("tiktok.com")
64
+ expect(isSocialVideoUrl(BACKSLASH)).toBe(false)
65
+ expect(detectVideoLinkPlatform(BACKSLASH)).toBe("unknown")
66
+ expect(videoLinkNeedsDownload({ youtubeUrl: BACKSLASH })).toBe(false)
67
+ })
68
+ })
69
+
70
+ describe("consumers that do not need the video file", () => {
71
+ it("names exactly the three readers that take the audio track or the page link", () => {
72
+ expect([...VIDEO_LINK_TOLERANT_CONSUMER_TYPES].sort()).toEqual(["dubbing", "suno-cover", "transcribe"])
73
+ })
74
+ })
75
+
76
+ describe("detectVideoLinkPlatform", () => {
77
+ it.each([
78
+ ["https://www.youtube.com/watch?v=aqz-KE-bpKQ", "youtube"],
79
+ ["https://youtu.be/aqz-KE-bpKQ?si=x", "youtube"],
80
+ ["https://music.youtube.com/watch?v=aqz-KE-bpKQ", "youtube"],
81
+ ["https://www.instagram.com/reels/DaK89TnRB5m/", "instagram"],
82
+ ["https://vm.tiktok.com/ZS99nqdaG/", "tiktok"],
83
+ ["https://x.com/someone/status/123", "twitter"],
84
+ ["https://twitter.com/someone/status/123", "twitter"],
85
+ ["https://fb.watch/abc/", "facebook"],
86
+ ["https://www.facebook.com/reel/123", "facebook"],
87
+ ["https://cdn.nodaro.ai/videos/yt-1.mp4", "unknown"],
88
+ ["https://www.netflix.com/watch/1", "unknown"],
89
+ ["garbage", "unknown"],
90
+ ])("%s → %s", (url, platform) => {
91
+ expect(detectVideoLinkPlatform(url)).toBe(platform)
92
+ })
93
+ })
94
+
95
+ describe("Video URL node output", () => {
96
+ const YT = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
97
+ const FILE = "https://cdn.nodaro.ai/videos/yt-1.mp4"
98
+
99
+ it("emits the downloaded file when there is one", () => {
100
+ expect(resolveVideoLinkOutput({ youtubeUrl: YT, downloadedVideoUrl: FILE, downloadedFromUrl: YT })).toBe(FILE)
101
+ })
102
+
103
+ it("trusts a downloaded file with no recorded source — every node saved before the field existed", () => {
104
+ expect(resolveVideoLinkOutput({ youtubeUrl: YT, downloadedVideoUrl: FILE })).toBe(FILE)
105
+ })
106
+
107
+ it("does NOT emit a file that was downloaded from a different link", () => {
108
+ const data = { youtubeUrl: "https://youtu.be/otherVideo01", downloadedVideoUrl: FILE, downloadedFromUrl: YT }
109
+ expect(videoLinkDownloadedFile(data)).toBeUndefined()
110
+ // Falls back to the link itself, never to the wrong video.
111
+ expect(resolveVideoLinkOutput(data)).toBe("https://youtu.be/otherVideo01")
112
+ })
113
+
114
+ it("passes a direct file link through untouched (the Welcome Demo / saved-template shape)", () => {
115
+ expect(resolveVideoLinkOutput({ youtubeUrl: ` ${FILE} ` })).toBe(FILE)
116
+ expect(videoLinkNeedsDownload({ youtubeUrl: FILE })).toBe(false)
117
+ })
118
+
119
+ it("is undefined for an empty node", () => {
120
+ expect(resolveVideoLinkOutput({})).toBeUndefined()
121
+ expect(resolveVideoLinkOutput({ youtubeUrl: " " })).toBeUndefined()
122
+ })
123
+
124
+ it("needs a download exactly when the link is social and no file matches it", () => {
125
+ expect(videoLinkNeedsDownload({ youtubeUrl: YT })).toBe(true)
126
+ expect(videoLinkNeedsDownload({ youtubeUrl: YT, downloadedVideoUrl: FILE, downloadedFromUrl: YT })).toBe(false)
127
+ expect(videoLinkNeedsDownload({ youtubeUrl: YT, downloadedVideoUrl: FILE })).toBe(false)
128
+ expect(videoLinkNeedsDownload({ youtubeUrl: "https://youtu.be/otherVideo01", downloadedVideoUrl: FILE, downloadedFromUrl: YT })).toBe(true)
129
+ expect(videoLinkNeedsDownload({ youtubeUrl: "https://example.com/page" })).toBe(false)
130
+ expect(videoLinkNeedsDownload({})).toBe(false)
131
+ })
132
+
133
+ it("ignores non-string junk in the fields", () => {
134
+ expect(resolveVideoLinkOutput({ youtubeUrl: 5, downloadedVideoUrl: null })).toBeUndefined()
135
+ expect(videoLinkNeedsDownload({ youtubeUrl: { a: 1 } })).toBe(false)
136
+ })
137
+ })
@@ -1,8 +1,30 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { stripExportContent } from "../workflow-export.js"
2
+ import { stripExportContent, stripUnownedRefs } from "../workflow-export.js"
3
3
  import { EXECUTION_DATA_KEYS } from "../node-runtime-keys.js"
4
4
  import type { GenericNode } from "../types.js"
5
5
 
6
+ describe("stripExportContent — a Schedule Trigger never exports armed", () => {
7
+ it("drops `active` and keeps the schedule itself", () => {
8
+ const node: GenericNode = {
9
+ id: "s1",
10
+ type: "schedule-trigger",
11
+ data: { label: "Daily", rules: [{ id: "rule-1", kind: "days", every: 1, hour: 9, minute: 0 }], timezone: "Asia/Jerusalem", maxExecutions: 3, active: true },
12
+ }
13
+ const [out] = stripExportContent([node])
14
+ const data = out.data as Record<string, unknown>
15
+ expect(data.active).toBeUndefined()
16
+ expect(data.rules).toEqual([{ id: "rule-1", kind: "days", every: 1, hour: 9, minute: 0 }])
17
+ expect(data.timezone).toBe("Asia/Jerusalem")
18
+ expect(data.maxExecutions).toBe(3)
19
+ })
20
+
21
+ it("a schedule that was never armed is exported unchanged", () => {
22
+ const node: GenericNode = { id: "s1", type: "schedule-trigger", data: { label: "Daily", rules: [{ id: "rule-1", kind: "days", every: 1, hour: 9, minute: 0 }] } }
23
+ const [out] = stripExportContent([node])
24
+ expect(out.data).toEqual(node.data)
25
+ })
26
+ })
27
+
6
28
  /**
7
29
  * Invariant guard for the template-export leak class (audit R2-H4): a
8
30
  * "shareable" template export must never carry runtime/result fields —
@@ -36,6 +58,42 @@ describe("stripExportContent — template export hygiene", () => {
36
58
  expect(outData.shots, "shots (Kling-3 config) must NOT be stripped").toBe("SENSITIVE_RUNTIME_VALUE")
37
59
  })
38
60
 
61
+ it("clears a Webhook Output's credentialId and a publisher's connectionId — pointers at rows the importer does not own", () => {
62
+ const hook: GenericNode = {
63
+ id: "h1",
64
+ type: "webhook-output",
65
+ data: { url: "https://mine.example/hook", credentialId: "11111111-1111-4111-8111-111111111111", params: [] },
66
+ }
67
+ const post: GenericNode = { id: "p1", type: "telegram-post", data: { connectionId: "conn-1", text: "hello" } }
68
+ const [outHook, outPost] = stripExportContent([hook, post])
69
+ expect((outHook.data as Record<string, unknown>).credentialId).toBeUndefined()
70
+ expect((outHook.data as Record<string, unknown>).url).toBe("https://mine.example/hook")
71
+ expect((outPost.data as Record<string, unknown>).connectionId).toBeUndefined()
72
+ expect((outPost.data as Record<string, unknown>).text).toBe("hello")
73
+ })
74
+
75
+ it("stripUnownedRefs alone covers the asset-bundle export, which keeps every other field", () => {
76
+ const hook: GenericNode = {
77
+ id: "h1",
78
+ type: "webhook-output",
79
+ data: { url: "https://mine.example/hook", credentialId: "11111111-1111-4111-8111-111111111111", webhookResponseBody: "kept by this pass" },
80
+ }
81
+ const other: GenericNode = { id: "g1", type: "generate-image", data: { credentialId: "not-a-webhook", prompt: "a cat" } }
82
+ const [outHook, outOther] = stripUnownedRefs([hook, other])
83
+ expect((outHook.data as Record<string, unknown>).credentialId).toBeUndefined()
84
+ // Only the owner-bound pointer goes; the bundle's verbatim-data contract holds for the rest.
85
+ expect((outHook.data as Record<string, unknown>).webhookResponseBody).toBe("kept by this pass")
86
+ // A node type that has no owner-bound field is returned as-is.
87
+ expect(outOther).toBe(other)
88
+ expect(hook.data.credentialId, "input not mutated").toBe("11111111-1111-4111-8111-111111111111")
89
+ })
90
+
91
+ it("the webhook delivery receipt is a runtime key — a reflected secret never rides a template", () => {
92
+ for (const key of ["webhookSuccess", "webhookStatusCode", "webhookResponseBody"]) {
93
+ expect(EXECUTION_DATA_KEYS.has(key), key).toBe(true)
94
+ }
95
+ })
96
+
39
97
  it("clears faceDbId / referencedWorkflowId on face + sub-workflow template nodes", () => {
40
98
  const face: GenericNode = { id: "f1", type: "face", data: { faceDbId: "exporter-face-id", name: "Hero" } }
41
99
  const sub: GenericNode = { id: "s1", type: "sub-workflow", data: { referencedWorkflowId: "exporter-wf-id" } }