@nodaro/shared 3.11.0 → 3.12.1

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 (91) hide show
  1. package/dist/index.cjs +2047 -84
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1556 -27
  4. package/dist/index.d.ts +1556 -27
  5. package/dist/index.js +1861 -85
  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__/presentation-utils.test.ts +67 -0
  17. package/src/__tests__/producer-types.test.ts +19 -0
  18. package/src/__tests__/schedule-rules.test.ts +265 -0
  19. package/src/__tests__/speaker-layouts.test.ts +203 -0
  20. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  21. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  22. package/src/__tests__/trigger-feeds.test.ts +39 -0
  23. package/src/__tests__/video-duration-auto.test.ts +65 -0
  24. package/src/__tests__/video-duration.test.ts +56 -0
  25. package/src/__tests__/video-link.test.ts +137 -0
  26. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  27. package/src/caption-styles.ts +240 -0
  28. package/src/credit-identifiers.ts +31 -0
  29. package/src/edit-plan-contract.ts +96 -0
  30. package/src/edl-multicam.ts +185 -0
  31. package/src/edl.ts +747 -0
  32. package/src/entity-image-handle.ts +24 -1
  33. package/src/fan-out-rows.ts +213 -0
  34. package/src/i18n/action-fx.he.ts +8 -8
  35. package/src/i18n/aesthetic.he.ts +8 -8
  36. package/src/i18n/animals.he.ts +34 -34
  37. package/src/i18n/atmosphere.he.ts +6 -6
  38. package/src/i18n/backdrop.he.ts +7 -7
  39. package/src/i18n/camera-format.he.ts +4 -4
  40. package/src/i18n/camera-motions.he.ts +20 -20
  41. package/src/i18n/character-fx.he.ts +16 -16
  42. package/src/i18n/character-motion.he.ts +184 -184
  43. package/src/i18n/color-look.he.ts +4 -4
  44. package/src/i18n/composition-effects.he.ts +3 -3
  45. package/src/i18n/era.he.ts +9 -9
  46. package/src/i18n/exposure-settings.he.ts +2 -2
  47. package/src/i18n/framing.he.ts +13 -13
  48. package/src/i18n/furniture.he.ts +52 -52
  49. package/src/i18n/held-prop.he.ts +10 -10
  50. package/src/i18n/instrumentation.he.ts +26 -26
  51. package/src/i18n/lens.he.ts +6 -6
  52. package/src/i18n/lighting.he.ts +17 -17
  53. package/src/i18n/loop-subject.he.ts +4 -4
  54. package/src/i18n/materials.he.ts +8 -8
  55. package/src/i18n/mood.he.ts +12 -12
  56. package/src/i18n/music-genre.he.ts +9 -9
  57. package/src/i18n/music-mood.he.ts +7 -7
  58. package/src/i18n/person.he.ts +94 -94
  59. package/src/i18n/photo-genre.he.ts +24 -24
  60. package/src/i18n/photographer.he.ts +21 -21
  61. package/src/i18n/pose.he.ts +14 -14
  62. package/src/i18n/post-process-effects.he.ts +4 -4
  63. package/src/i18n/render-quality.he.ts +7 -7
  64. package/src/i18n/setting.he.ts +14 -14
  65. package/src/i18n/style.he.ts +9 -9
  66. package/src/i18n/styling.he.ts +126 -126
  67. package/src/i18n/temporal.he.ts +8 -8
  68. package/src/i18n/transitions.he.ts +9 -9
  69. package/src/i18n/vehicles.he.ts +25 -25
  70. package/src/i18n/voice-character.he.ts +14 -14
  71. package/src/i18n/voice-delivery.he.ts +11 -11
  72. package/src/i18n/weapons.he.ts +23 -23
  73. package/src/index.ts +206 -3
  74. package/src/instagram-scrape.ts +204 -0
  75. package/src/llm-models.ts +80 -3
  76. package/src/meta-ads-scrape.ts +463 -0
  77. package/src/model-catalog.ts +48 -5
  78. package/src/model-constants.ts +148 -5
  79. package/src/node-mappable-fields.ts +2 -0
  80. package/src/node-runtime-keys.ts +28 -0
  81. package/src/presentation-utils.ts +49 -0
  82. package/src/producer-types.ts +20 -0
  83. package/src/schedule-rules.ts +484 -0
  84. package/src/speaker-layouts.ts +220 -0
  85. package/src/transcribe-preflight.ts +101 -0
  86. package/src/trigger-feeds.ts +59 -0
  87. package/src/trigger-node-types.ts +20 -0
  88. package/src/video-duration-auto.ts +18 -0
  89. package/src/video-duration.ts +32 -0
  90. package/src/video-link.ts +167 -0
  91. 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
+ })
@@ -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,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" } }
@@ -1,3 +1,5 @@
1
+ import type { SupportedFontName } from "./supported-fonts.js"
2
+
1
3
  /**
2
4
  * Caption styles for the add-captions node. Static path uses FFmpeg drawtext;
3
5
  * kinetic styles render via Remotion (BurnCaptions composition).
@@ -29,3 +31,241 @@ const KINETIC_SET = new Set<string>(KINETIC_CAPTION_STYLES)
29
31
  export function isKineticCaptionStyle(style: string | undefined | null): style is KineticCaptionStyle {
30
32
  return style !== null && style !== undefined && KINETIC_SET.has(style)
31
33
  }
34
+
35
+ // ── Named "looks" ──────────────────────────────────────────────────────────
36
+ // A look is a bundle of visual levers so a caption reads well with one field
37
+ // instead of eight. Shared because the ids are wire contract (route Zod, MCP
38
+ // schema, SDK type) and the value table is consumed by BOTH the worker (render)
39
+ // and the canvas preview — deliberately given away, not creative doctrine.
40
+ export const CAPTION_LOOK_IDS = ["outline", "clean"] as const
41
+ export type CaptionLookId = (typeof CAPTION_LOOK_IDS)[number]
42
+
43
+ /** What an unset `look` means on a KINETIC style. ONE-LINE FLIP: set to "clean"
44
+ * to make an unset caption render as the pre-look-system lever set (face pinned)
45
+ * instead. */
46
+ export const DEFAULT_CAPTION_LOOK: CaptionLookId = "outline"
47
+
48
+ /** What an unset `look` means on the static `subtitle` style: the plain read —
49
+ * a pinned neutral sans, no outline, no casing. A subtitle must never be left
50
+ * with NO face: the Remotion render would fall back to headless Chrome's default
51
+ * SERIF, so adding e.g. a stroke to a subtitle would silently flip its font away
52
+ * from the sans the plain FFmpeg subtitle draws. */
53
+ export const DEFAULT_SUBTITLE_LOOK: CaptionLookId = "clean"
54
+
55
+ /**
56
+ * The lever field names that are MEANINGLESS on a `subtitle` render and so are
57
+ * rejected on it: `highlightColor` (subtitle has no per-word spoken cursor to
58
+ * colour) and `animate` (subtitle has no motion to switch off). The STYLING
59
+ * levers (look/fontFamily/fontWeight/strokeColor/strokeWidth/uppercase/positionY)
60
+ * are NOT here any more — a `subtitle` carrying any of them now routes to the
61
+ * Remotion renderer (see `captionRoutesToRemotion`), which applies them exactly
62
+ * as it does for the kinetic styles. Single source of truth for the route's
63
+ * reject-on-subtitle guard and the frontend's "don't send a stale lever" strip.
64
+ * `color`/`backgroundColor` are deliberately absent — FFmpeg subtitle honours
65
+ * those too.
66
+ */
67
+ export const KINETIC_ONLY_CAPTION_LEVER_KEYS = [
68
+ "highlightColor",
69
+ "animate",
70
+ ] as const
71
+ export type KineticOnlyCaptionLeverKey = (typeof KINETIC_ONLY_CAPTION_LEVER_KEYS)[number]
72
+
73
+ /**
74
+ * Does an add-captions request need the Remotion renderer, vs the cheap static
75
+ * FFmpeg drawtext path? A caption routes to Remotion when it needs anything the
76
+ * one-fixed-string drawtext pass cannot do:
77
+ * - per-segment treatments (`segments`),
78
+ * - a kinetic style,
79
+ * - any STYLING lever (look/font/weight/stroke/uppercase/position_y/
80
+ * max_words_per_line) — FFmpeg drawtext can't apply a webfont face, weight,
81
+ * outline, casing, a free vertical position, or line grouping,
82
+ * - TIMED captions (a wired `transcript` or an explicit `captions[]` array),
83
+ * - auto-transcription, i.e. no `text` to burn as one static block.
84
+ * Plain-`text` `subtitle` with no lever stays on FFmpeg (unchanged, cheap).
85
+ *
86
+ * SINGLE SOURCE for BOTH the worker dispatch (handleAddCaptions) AND the credit
87
+ * id (buildAddCaptionsCreditId) so the renderer and the price never drift: a
88
+ * Remotion render bills as `add-captions:kinetic`, a plain drawtext burn as
89
+ * `add-captions`.
90
+ */
91
+ export function captionRoutesToRemotion(input: {
92
+ style?: string | null
93
+ text?: string | null
94
+ segments?: readonly unknown[] | null
95
+ transcript?: unknown
96
+ captions?: readonly unknown[] | null
97
+ look?: unknown
98
+ fontFamily?: unknown
99
+ fontWeight?: unknown
100
+ strokeColor?: unknown
101
+ strokeWidth?: unknown
102
+ uppercase?: unknown
103
+ positionY?: unknown
104
+ maxWordsPerLine?: unknown
105
+ }): boolean {
106
+ if (input.segments && input.segments.length > 0) return true
107
+ if (isKineticCaptionStyle(input.style)) return true
108
+ // From here the style is `subtitle` (or unset → the subtitle default).
109
+ // `null` is "not set", exactly like `undefined`: stored node JSON (an agent's
110
+ // write, an import, a cleared field) carries nulls, and a null lever that
111
+ // counted as a lever would route a plain subtitle to Remotion — and its price
112
+ // — for a lever nobody chose.
113
+ const isSet = (v: unknown): boolean => v !== undefined && v !== null
114
+ const hasStylingLever =
115
+ isSet(input.look) ||
116
+ isSet(input.fontFamily) ||
117
+ isSet(input.fontWeight) ||
118
+ isSet(input.strokeColor) ||
119
+ isSet(input.strokeWidth) ||
120
+ isSet(input.uppercase) ||
121
+ isSet(input.positionY) ||
122
+ isSet(input.maxWordsPerLine)
123
+ if (hasStylingLever) return true
124
+ if (input.transcript !== undefined && input.transcript !== null) return true
125
+ if (input.captions && input.captions.length > 0) return true
126
+ // No `text` to burn as one static block → the only caption source is
127
+ // transcription, which produces TIMED captions the drawtext pass can't show.
128
+ if (!input.text) return true
129
+ return false
130
+ }
131
+
132
+ /**
133
+ * `maxWordsPerLine` — caps how many words a caption LINE (or tiktok-words page)
134
+ * may hold, on top of the frame-width budget, sentence ends and pauses that
135
+ * already close a line. 1–2 gives the punchy CapCut read; unset = fit the width.
136
+ * Applies to every line/page-grouped render (word-highlight, karaoke, bouncy,
137
+ * tiktok-words, and a Remotion-rendered subtitle); inert on word-pop (always one
138
+ * word). Bounds single-sourced here for the route Zod, the plan schema, the MCP
139
+ * schema, the CLI and the canvas panel.
140
+ */
141
+ export const CAPTION_MAX_WORDS_PER_LINE_MIN = 1
142
+ export const CAPTION_MAX_WORDS_PER_LINE_MAX = 20
143
+
144
+ /**
145
+ * Numeric caption levers and their wire bounds — the SAME limits the route Zod
146
+ * and the render-plan schema enforce. Single-sourced so the coercion below and
147
+ * those schemas cannot disagree (a guard test pins the route to these).
148
+ */
149
+ export const CAPTION_LEVER_BOUNDS = {
150
+ fontSize: { min: 12, max: 200 },
151
+ strokeWidth: { min: 0, max: 40 },
152
+ positionY: { min: 0, max: 100 },
153
+ fontWeight: { min: 100, max: 900 },
154
+ maxWordsPerLine: { min: CAPTION_MAX_WORDS_PER_LINE_MIN, max: CAPTION_MAX_WORDS_PER_LINE_MAX },
155
+ } as const
156
+
157
+ type CaptionNumericLeverKey = keyof typeof CAPTION_LEVER_BOUNDS
158
+ const CAPTION_NUMERIC_LEVER_KEYS = Object.keys(CAPTION_LEVER_BOUNDS) as CaptionNumericLeverKey[]
159
+
160
+ /**
161
+ * COERCE, never reject: bring the numeric caption levers of node data that
162
+ * never passed a Zod (a workflow written by an agent, an import, a template, a
163
+ * FieldMapping) into the range the render plan accepts. Without this an
164
+ * out-of-range value only surfaces when the plan schema throws — mid-run, after
165
+ * credits are reserved. A `null` / non-finite / non-numeric value is DROPPED (the
166
+ * render default applies); an out-of-range one is clamped; `fontWeight` snaps to the
167
+ * nearest 100 and `maxWordsPerLine` to a whole number. Pure; returns a copy and
168
+ * leaves every other field untouched. Applied by payload-builder to the node's
169
+ * top level and to each `segments[]` entry.
170
+ */
171
+ export function normalizeCaptionNumericLevers<T extends Record<string, unknown>>(input: T): T {
172
+ const out: Record<string, unknown> = { ...input }
173
+ for (const key of CAPTION_NUMERIC_LEVER_KEYS) {
174
+ if (!(key in out) || out[key] === undefined) continue
175
+ // `null` is "not set": drop it rather than carry it — the render plan's
176
+ // numeric schema rejects a null, mid-run, after credits are reserved.
177
+ if (out[key] === null) {
178
+ delete out[key]
179
+ continue
180
+ }
181
+ const raw = typeof out[key] === "string" && (out[key] as string).trim() !== "" ? Number(out[key]) : out[key]
182
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
183
+ delete out[key]
184
+ continue
185
+ }
186
+ const { min, max } = CAPTION_LEVER_BOUNDS[key]
187
+ const shaped = key === "fontWeight" ? Math.round(raw / 100) * 100 : key === "maxWordsPerLine" ? Math.round(raw) : raw
188
+ out[key] = Math.min(max, Math.max(min, shaped))
189
+ }
190
+ return out as T
191
+ }
192
+
193
+ /** The concrete levers a look (and any explicit override) resolves to. */
194
+ export interface CaptionLookLevers {
195
+ fontFamily?: SupportedFontName
196
+ fontWeight?: number
197
+ color?: string
198
+ backgroundColor?: string
199
+ strokeColor?: string
200
+ strokeWidth?: number
201
+ highlightColor?: string
202
+ uppercase?: boolean
203
+ }
204
+
205
+ /** Outline width = 10% of font size (min 2px). `paint-order: stroke fill` puts
206
+ * half the stroke OUTSIDE the glyph, so the visible rim is ~5% of font size. */
207
+ export function autoStrokeWidth(fontSize: number): number {
208
+ return Math.max(2, Math.round(fontSize * 0.1))
209
+ }
210
+
211
+ /** Each look is a function of font size (so the outline tracks the text size). */
212
+ export const CAPTION_LOOKS: Record<CaptionLookId, (fontSize: number) => CaptionLookLevers> = {
213
+ // The TikTok / CapCut read: heavy geometric sans, caps, white on a thick black
214
+ // outline, yellow spoken word.
215
+ outline: (fs) => ({
216
+ fontFamily: "Montserrat",
217
+ fontWeight: 900,
218
+ uppercase: true,
219
+ color: "#ffffff",
220
+ strokeColor: "#000000",
221
+ strokeWidth: autoStrokeWidth(fs),
222
+ highlightColor: "#FFE600",
223
+ }),
224
+ // The pre-look lever set with the face pinned (it never was): per-style weight,
225
+ // soft shadow only, no casing, no outline.
226
+ clean: () => ({ fontFamily: "Inter", color: "#ffffff" }),
227
+ }
228
+
229
+ /**
230
+ * Resolve a look + explicit overrides into concrete levers. An explicit lever
231
+ * always wins over the look; `strokeWidth: 0` explicitly means "no outline".
232
+ * The plan then carries concrete levers only — nothing to resolve at render.
233
+ */
234
+ export function resolveCaptionLook(
235
+ look: CaptionLookId | undefined,
236
+ explicit: CaptionLookLevers,
237
+ fontSize: number,
238
+ ): CaptionLookLevers {
239
+ // COERCE, never throw. The route Zod rejects a bad `look`, but the orchestrator /
240
+ // authored-JSON / import / Copilot paths write `look` straight onto node data with
241
+ // no validation (payload-builder passes `look: data.look` verbatim — the CLAUDE.md
242
+ // pitfall 5b class). An unknown id here would throw AFTER a paid transcription and
243
+ // fail the whole run, so an out-of-vocabulary look falls back to the default preset.
244
+ const preset = CAPTION_LOOKS[look ?? DEFAULT_CAPTION_LOOK] ?? CAPTION_LOOKS[DEFAULT_CAPTION_LOOK]
245
+ const out: CaptionLookLevers = { ...preset(fontSize) }
246
+ for (const k of Object.keys(explicit) as (keyof CaptionLookLevers)[]) {
247
+ if (explicit[k] !== undefined) (out[k] as CaptionLookLevers[typeof k]) = explicit[k]
248
+ }
249
+ return out
250
+ }
251
+
252
+ /**
253
+ * Resolve the concrete render levers for a caption, applying the DEFAULT look the
254
+ * way each STYLE expects: an unset `look` means `outline` on a kinetic style (the
255
+ * TikTok/CapCut read) and `clean` on the static `subtitle` (the plain read — a
256
+ * pinned neutral sans, no outline, no casing). So a subtitle that routes to
257
+ * Remotion never inherits the outline house-style unless asked, AND is never left
258
+ * with no face at all (which renders as headless Chrome's default serif). A named
259
+ * look always wins; explicit levers override either. SINGLE SOURCE for the worker
260
+ * top-level levers, the per-segment resolver, and the frontend config/preview
261
+ * mirror, so the per-style default can't drift between them.
262
+ */
263
+ export function resolveCaptionLevers(
264
+ style: string | undefined | null,
265
+ look: CaptionLookId | undefined,
266
+ explicit: CaptionLookLevers,
267
+ fontSize: number,
268
+ ): CaptionLookLevers {
269
+ const effective = look ?? (isKineticCaptionStyle(style) ? DEFAULT_CAPTION_LOOK : DEFAULT_SUBTITLE_LOOK)
270
+ return resolveCaptionLook(effective, explicit, fontSize)
271
+ }
@@ -28,6 +28,8 @@ import {
28
28
  getVideoAudioCapability,
29
29
  } from "./model-constants.js"
30
30
  import { isFlux2Model, FLUX2_RES_MP, type Flux2Model } from "./flux2-pricing.js"
31
+ import { VIDEO_DURATION_AUTO } from "./video-duration-auto.js"
32
+ import { uiResolutionFill } from "./video-ui-defaults.js"
31
33
  import { MODEL_CATALOG, normalizeModelInput, defaultResolutionFor, type ModelInputAdjustment } from "./model-catalog.js"
32
34
 
33
35
  /**
@@ -461,6 +463,35 @@ export function buildVideoCreditModelIdentifier(
461
463
  return identifier
462
464
  }
463
465
 
466
+ /**
467
+ * The credit identifier a Video to Video node's Seedance EDIT lane reserves
468
+ * under. Seedance has no v2v endpoint — the lane is a text-to-video job in edit
469
+ * shape with the source clip as reference video 1 — so it prices on the
470
+ * REFERENCE-VIDEO ladder at the model's LONGEST clip (Auto duration), and the
471
+ * measured settlement refunds down to what was actually delivered.
472
+ *
473
+ * It exists because that is a 7-positional-argument call with four
474
+ * easy-to-transpose slots, and FOUR surfaces must agree on it exactly: the
475
+ * orchestrator's reservation (payload-builder.ts), the backend pre-run
476
+ * estimator (ee/billing/credits.ts), the node's cost pill, and the frontend
477
+ * run-level estimate (config-panels/helpers.ts). A quote that disagrees with
478
+ * the reserve is the documented `price_not_configured` / blank-pill trap.
479
+ *
480
+ * `resolution` is the node's one `v2vResolution` field; when unset the model's
481
+ * own UI fill is priced, which is what the lane will send.
482
+ */
483
+ export function seedanceVideoEditCreditId(provider: string, resolution?: string): string {
484
+ return buildVideoCreditModelIdentifier(
485
+ provider,
486
+ VIDEO_DURATION_AUTO,
487
+ undefined,
488
+ "text-to-video",
489
+ undefined,
490
+ resolution ?? uiResolutionFill(provider),
491
+ /* hasVideoRef */ true,
492
+ )
493
+ }
494
+
464
495
  /** What the video credit identifier PRICES for a request, for the levers whose
465
496
  * priced value must also be the value we SEND. */
466
497
  export interface PricedVideoSelection {