@nodaro/shared 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2047 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1556 -27
- package/dist/index.d.ts +1556 -27
- package/dist/index.js +1861 -85
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/caption-styles.test.ts +207 -0
- package/src/__tests__/edl-multicam.test.ts +304 -0
- package/src/__tests__/edl.test.ts +822 -0
- package/src/__tests__/fan-out-rows.test.ts +208 -0
- package/src/__tests__/instagram-scrape.test.ts +66 -0
- package/src/__tests__/llm-models.test.ts +48 -11
- package/src/__tests__/meta-ads-scrape.test.ts +284 -0
- package/src/__tests__/node-runtime-keys.test.ts +15 -0
- package/src/__tests__/presentation-utils.test.ts +67 -0
- package/src/__tests__/producer-types.test.ts +19 -0
- package/src/__tests__/schedule-rules.test.ts +265 -0
- package/src/__tests__/speaker-layouts.test.ts +203 -0
- package/src/__tests__/transcribe-capabilities.test.ts +104 -0
- package/src/__tests__/transcribe-preflight.test.ts +60 -0
- package/src/__tests__/trigger-feeds.test.ts +39 -0
- package/src/__tests__/video-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -0
- package/src/__tests__/video-link.test.ts +137 -0
- package/src/__tests__/workflow-export-strip.test.ts +59 -1
- package/src/caption-styles.ts +240 -0
- package/src/credit-identifiers.ts +31 -0
- package/src/edit-plan-contract.ts +96 -0
- package/src/edl-multicam.ts +185 -0
- package/src/edl.ts +747 -0
- package/src/entity-image-handle.ts +24 -1
- package/src/fan-out-rows.ts +213 -0
- package/src/index.ts +206 -3
- package/src/instagram-scrape.ts +204 -0
- package/src/llm-models.ts +80 -3
- package/src/meta-ads-scrape.ts +463 -0
- package/src/model-catalog.ts +48 -5
- package/src/model-constants.ts +148 -5
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-runtime-keys.ts +28 -0
- package/src/presentation-utils.ts +49 -0
- package/src/producer-types.ts +20 -0
- package/src/schedule-rules.ts +484 -0
- package/src/speaker-layouts.ts +220 -0
- package/src/transcribe-preflight.ts +101 -0
- package/src/trigger-feeds.ts +59 -0
- package/src/trigger-node-types.ts +20 -0
- package/src/video-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-link.ts +167 -0
- package/src/workflow-export.ts +37 -1
|
@@ -0,0 +1,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" } }
|
package/src/caption-styles.ts
CHANGED
|
@@ -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 {
|