@nodaro/shared 3.10.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2242 -86
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1779 -32
- package/dist/index.d.ts +1779 -32
- package/dist/index.js +2041 -87
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/caption-styles.test.ts +207 -0
- package/src/__tests__/edl-multicam.test.ts +304 -0
- package/src/__tests__/edl.test.ts +822 -0
- package/src/__tests__/fan-out-rows.test.ts +208 -0
- package/src/__tests__/instagram-scrape.test.ts +66 -0
- package/src/__tests__/llm-models.test.ts +48 -11
- package/src/__tests__/meta-ads-scrape.test.ts +284 -0
- package/src/__tests__/node-runtime-keys.test.ts +15 -0
- package/src/__tests__/parameter-node-value.test.ts +13 -1
- package/src/__tests__/presentation-utils.test.ts +67 -0
- package/src/__tests__/producer-types.test.ts +19 -0
- package/src/__tests__/schedule-rules.test.ts +265 -0
- package/src/__tests__/speaker-layouts.test.ts +203 -0
- package/src/__tests__/transcribe-capabilities.test.ts +104 -0
- package/src/__tests__/transcribe-preflight.test.ts +60 -0
- package/src/__tests__/trigger-feeds.test.ts +39 -0
- package/src/__tests__/video-analysis.test.ts +15 -0
- package/src/__tests__/video-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -0
- package/src/__tests__/video-frame-fit.test.ts +189 -0
- package/src/__tests__/video-link.test.ts +137 -0
- package/src/__tests__/workflow-export-strip.test.ts +59 -1
- package/src/caption-styles.ts +240 -0
- package/src/catalog-projection.ts +3 -0
- package/src/character-motion-metadata.ts +19 -0
- package/src/credit-identifiers.ts +31 -0
- package/src/edit-plan-contract.ts +96 -0
- package/src/edl-multicam.ts +185 -0
- package/src/edl.ts +747 -0
- package/src/entity-image-handle.ts +24 -1
- package/src/fan-out-rows.ts +213 -0
- package/src/i18n/character-motion.ar.ts +126 -75
- package/src/i18n/character-motion.de.ts +126 -75
- package/src/i18n/character-motion.es.ts +126 -75
- package/src/i18n/character-motion.fr.ts +126 -75
- package/src/i18n/character-motion.he.ts +126 -75
- package/src/i18n/character-motion.hi.ts +126 -75
- package/src/i18n/character-motion.ja.ts +126 -75
- package/src/i18n/character-motion.ko.ts +126 -75
- package/src/i18n/character-motion.pt-BR.ts +126 -75
- package/src/i18n/character-motion.ru.ts +126 -75
- package/src/i18n/character-motion.zh-CN.ts +126 -75
- package/src/index.ts +211 -3
- package/src/instagram-scrape.ts +204 -0
- package/src/llm-models.ts +80 -3
- package/src/meta-ads-scrape.ts +463 -0
- package/src/model-catalog.ts +48 -5
- package/src/model-constants.ts +148 -5
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-runtime-keys.ts +28 -0
- package/src/parameter-node-value.ts +31 -5
- package/src/presentation-utils.ts +49 -0
- package/src/producer-types.ts +20 -0
- package/src/schedule-rules.ts +484 -0
- package/src/speaker-layouts.ts +220 -0
- package/src/transcribe-preflight.ts +101 -0
- package/src/trigger-feeds.ts +59 -0
- package/src/trigger-node-types.ts +20 -0
- package/src/video-analysis.ts +15 -0
- package/src/video-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-frame-fit.ts +228 -0
- package/src/video-link.ts +167 -0
- package/src/video-output-canvas.ts +119 -0
- package/src/workflow-export.ts +37 -1
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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CharacterMotionMetadata } from "./character-motion-metadata.js"
|
|
1
2
|
/**
|
|
2
3
|
* Tag-free, policy-free wire shape for the `GET /v1/catalogs` projection — the
|
|
3
4
|
* server-driven, pack-composed catalog view thin clients render their own
|
|
@@ -21,6 +22,8 @@ export interface ProjectedCatalogOption {
|
|
|
21
22
|
*/
|
|
22
23
|
term?: string
|
|
23
24
|
icon?: string
|
|
25
|
+
/** Authored Character Motion prerequisites and sequence state. Missing means unknown. */
|
|
26
|
+
motion?: CharacterMotionMetadata
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export interface ProjectedCatalogDimension {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Structural public catalog API metadata. Authored values remain in @nodaro/prompts. */
|
|
2
|
+
export interface CharacterMotionMetadata {
|
|
3
|
+
/** Search terms, including previous display names. IDs remain stable. */
|
|
4
|
+
readonly aliases?: readonly string[]
|
|
5
|
+
/** Hidden from new choices; saved workflows still resolve this entry. */
|
|
6
|
+
readonly deprecated?: true
|
|
7
|
+
readonly replacementId?: string
|
|
8
|
+
/** Authored prerequisites; omission is unknown, never a compatibility claim. */
|
|
9
|
+
readonly requires?: readonly string[]
|
|
10
|
+
readonly startPose?: "standing" | "seated" | "floor" | "any"
|
|
11
|
+
readonly endPose?: "standing" | "seated" | "floor" | "any"
|
|
12
|
+
readonly endVisibility?: "in-frame" | "out-of-frame"
|
|
13
|
+
readonly handsAfter?: "free" | "occupied" | "holding-partner"
|
|
14
|
+
readonly needsFreeHands?: true
|
|
15
|
+
readonly kind?: "single" | "compound"
|
|
16
|
+
readonly fixedPace?: true
|
|
17
|
+
/** Non-human/dependent recipient substituted through the Partner handle. */
|
|
18
|
+
readonly counterpart?: string
|
|
19
|
+
}
|
|
@@ -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 {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The edit-plan node's credit-id scheme and output unwrap — split out of
|
|
3
|
+
* `edl.ts` (which keeps the EDL shape itself) so that module stays within the
|
|
4
|
+
* file-size cap. Re-exported from the package index, so importers of
|
|
5
|
+
* `@nodaro/shared` are unaffected.
|
|
6
|
+
*/
|
|
7
|
+
import { EDL_VERSION } from "./edl.js"
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// edit-plan credit-id scheme (STRUCTURE only — the per-mode/tier/bucket credit
|
|
11
|
+
// VALUES live app-side in backend/ee/billing/credits.ts + migration 432, since
|
|
12
|
+
// they are probe-set placeholders and the DB row wins at runtime). Lives HERE
|
|
13
|
+
// so BOTH core payload-builder and ee credits.ts read one id builder — core
|
|
14
|
+
// may not import ee (check-ee-imports), the same reason buildVideoAnalysisCreditId
|
|
15
|
+
// is shared. Mirrors the plugin's own (D13-local) pricing.ts scheme.
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export type EditPlanMode = "tighten" | "clips" | "chapters"
|
|
19
|
+
export type EditPlanTier = "economy" | "standard" | "premium"
|
|
20
|
+
export const EDIT_PLAN_MODES: readonly EditPlanMode[] = ["tighten", "clips", "chapters"]
|
|
21
|
+
export const EDIT_PLAN_TIERS: readonly EditPlanTier[] = ["economy", "standard", "premium"]
|
|
22
|
+
/** The coarse duration ladder (MINUTES) a probed source duration rounds UP to;
|
|
23
|
+
* the composite credit id carries the bucket. 3 modes × 3 tiers × 6 buckets =
|
|
24
|
+
* 54 composites (+ the bare `edit-plan`). */
|
|
25
|
+
export const EDIT_PLAN_BUCKET_MINUTES: readonly number[] = [15, 30, 60, 90, 120, 180]
|
|
26
|
+
/** Hard duration cap (design §7.4). */
|
|
27
|
+
export const EDIT_PLAN_MAX_MINUTES = 180
|
|
28
|
+
/** `clips` mode: how many clips a plan returns when the caller names no count,
|
|
29
|
+
* and the most it may be asked for. One source for the credit estimate, the
|
|
30
|
+
* orchestrated payload clamp and the request schema. */
|
|
31
|
+
export const EDIT_PLAN_DEFAULT_CLIP_COUNT = 8
|
|
32
|
+
export const EDIT_PLAN_MAX_CLIP_COUNT = 50
|
|
33
|
+
|
|
34
|
+
/** Clamp a requested clip count into `[1, EDIT_PLAN_MAX_CLIP_COUNT]`; `undefined`
|
|
35
|
+
* for anything that is not a positive number (the planner then uses its default). */
|
|
36
|
+
export function clampEditPlanClipCount(count: unknown): number | undefined {
|
|
37
|
+
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return undefined
|
|
38
|
+
return Math.min(EDIT_PLAN_MAX_CLIP_COUNT, Math.max(1, Math.floor(count)))
|
|
39
|
+
}
|
|
40
|
+
/** The bare estimator / DB-down fallback id. */
|
|
41
|
+
export const EDIT_PLAN_BASE_CREDIT_ID = "edit-plan"
|
|
42
|
+
|
|
43
|
+
/** Round a source duration (seconds) UP to the smallest covering ladder bucket
|
|
44
|
+
* (capped at the max), in minutes. `undefined` / non-finite → the ceiling
|
|
45
|
+
* bucket (the safe over-reserve direction). */
|
|
46
|
+
export function editPlanBucketMinutes(durationSec: number | undefined): number {
|
|
47
|
+
const secs = typeof durationSec === "number" && Number.isFinite(durationSec) ? durationSec : EDIT_PLAN_MAX_MINUTES * 60
|
|
48
|
+
const mins = Math.max(1, Math.ceil(secs / 60))
|
|
49
|
+
const capped = Math.min(mins, EDIT_PLAN_MAX_MINUTES)
|
|
50
|
+
for (const b of EDIT_PLAN_BUCKET_MINUTES) if (capped <= b) return b
|
|
51
|
+
return EDIT_PLAN_BUCKET_MINUTES[EDIT_PLAN_BUCKET_MINUTES.length - 1]!
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `edit-plan:<mode>:<tier>:<bucket>m`. `durationSec` undefined → the ceiling
|
|
55
|
+
* bucket. Single source of truth for the composite id shape. */
|
|
56
|
+
export function buildEditPlanCreditId(mode: EditPlanMode, tier: EditPlanTier, durationSec?: number): string {
|
|
57
|
+
return `edit-plan:${mode}:${tier}:${editPlanBucketMinutes(durationSec)}m`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Narrow an arbitrary value to a known edit-plan mode, defaulting to "tighten". */
|
|
61
|
+
export function asEditPlanMode(v: unknown): EditPlanMode {
|
|
62
|
+
return v === "clips" || v === "chapters" ? v : "tighten"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Narrow an arbitrary value to a known edit-plan tier, defaulting to "standard". */
|
|
66
|
+
export function asEditPlanTier(v: unknown): EditPlanTier {
|
|
67
|
+
return v === "economy" || v === "premium" ? v : "standard"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Unwrap an `edit-plan` job's `output_data` into the value stored on the node's
|
|
72
|
+
* `data.generatedJson`, which every output extractor then reads. This is the ONE
|
|
73
|
+
* place the three modes are normalized (the same rule on both engines and every
|
|
74
|
+
* result-application site, so audit-dag parity can't drift):
|
|
75
|
+
* - `clips` → the BARE `Edl[]` (T5: the `list` fan-out reads `Array.isArray`
|
|
76
|
+
* on `generatedJson`; each element becomes one JSON-stringified
|
|
77
|
+
* item a downstream `edl` input `normalizeEdl`-parses).
|
|
78
|
+
* - `chapters` → the `{ version, chapters }` object.
|
|
79
|
+
* - `tighten` → the `Edl` object at top level.
|
|
80
|
+
*
|
|
81
|
+
* The cloud relay object-spreads `output_data` and adds `viaNodaroCloud: true`;
|
|
82
|
+
* that key (and any other bookkeeping) is stripped here. The unwrap lives HERE —
|
|
83
|
+
* NEVER in `output_data` — because a bare array written into `output_data` would
|
|
84
|
+
* be corrupted into numeric keys by the relay's object-spread (see `EdlClipSet`).
|
|
85
|
+
*/
|
|
86
|
+
export function unwrapEditPlanOutput(outputData: unknown): unknown {
|
|
87
|
+
if (!outputData || typeof outputData !== "object") return outputData
|
|
88
|
+
const o = outputData as Record<string, unknown>
|
|
89
|
+
// clips: EdlClipSet { version, clips: Edl[] } → the bare Edl[].
|
|
90
|
+
if (Array.isArray(o.clips)) return o.clips
|
|
91
|
+
// chapters: { version, chapters: [...] } → the object, minus bookkeeping.
|
|
92
|
+
if (Array.isArray(o.chapters)) return { version: EDL_VERSION, chapters: o.chapters }
|
|
93
|
+
// tighten: the Edl object at top level → drop the relay's viaNodaroCloud.
|
|
94
|
+
const { viaNodaroCloud: _viaNodaroCloud, ...rest } = o
|
|
95
|
+
return rest
|
|
96
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multicam helpers over the EDL contract (`edl.ts`): folding measured source
|
|
3
|
+
* offsets into an EDL (D19) and resolving what each on-screen slot of a
|
|
4
|
+
* segment shows (D20). Pure — no I/O. Type-only imports from `edl.ts` keep the
|
|
5
|
+
* module graph free of runtime cycles.
|
|
6
|
+
*/
|
|
7
|
+
import type { Edl, EdlRegion, EdlSegment, EdlSource } from "./edl.js"
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// D19 — source offsets
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const hasOwn = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k)
|
|
14
|
+
const isFiniteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
|
|
15
|
+
|
|
16
|
+
/** audio-sync → EDL source offsets (D19: masterMs = sourceMs + offsetMs).
|
|
17
|
+
* `offsets` are measured against ONE common reference (audio-sync's `reference`, which need not be the master).
|
|
18
|
+
* ANCHORED SET: anchor = opts.anchor ?? the unique role:"master-audio" source ?? none.
|
|
19
|
+
* - The anchor's own offsetMs is NEVER changed (the segments are on its clock).
|
|
20
|
+
* - Every other provided source s: offsetMs = round((anchor.offsetMs ?? 0) + offsets[s] − (offsets[anchor] ?? 0)).
|
|
21
|
+
* - No anchor: offsetMs = round(offsets[s]) verbatim (offsets already relative to the master clock).
|
|
22
|
+
* SET, never ADD (re-running is idempotent). Returns a new Edl; never mutates; never throws.
|
|
23
|
+
* `ignored[].reason` is a documented open string: "unknown-source" | "not-finite" | "anchor-unknown" | "anchor-not-finite".
|
|
24
|
+
* opts.anchor not in edl.sources → NOTHING applied, ignored = [{ sourceId: anchor, reason: "anchor-unknown" }].
|
|
25
|
+
* offsets[anchor] present but non-finite → NOTHING applied, ignored = [{ sourceId: anchor, reason: "anchor-not-finite" }]. */
|
|
26
|
+
export function mergeEdlSourceOffsets(
|
|
27
|
+
edl: Edl,
|
|
28
|
+
offsets: Readonly<Record<string, number>>,
|
|
29
|
+
opts?: { readonly anchor?: string },
|
|
30
|
+
): {
|
|
31
|
+
readonly edl: Edl
|
|
32
|
+
/** The source the offsets were anchored to — present whenever one resolved (it exists in `edl.sources`). */
|
|
33
|
+
readonly anchor?: string
|
|
34
|
+
/** Source ids whose `offsetMs` was set, in `offsets` key order. */
|
|
35
|
+
readonly applied: readonly string[]
|
|
36
|
+
readonly ignored: ReadonlyArray<{ readonly sourceId: string; readonly reason: string }>
|
|
37
|
+
} {
|
|
38
|
+
const sources: readonly EdlSource[] = Array.isArray(edl?.sources) ? edl.sources : []
|
|
39
|
+
const rows: Readonly<Record<string, unknown>> = offsets && typeof offsets === "object" ? offsets : {}
|
|
40
|
+
const byId = new Map<string, EdlSource>()
|
|
41
|
+
for (const s of sources) if (s && typeof s === "object" && !byId.has(s.id)) byId.set(s.id, s)
|
|
42
|
+
|
|
43
|
+
let anchor: string | undefined
|
|
44
|
+
if (opts?.anchor !== undefined) {
|
|
45
|
+
if (!byId.has(opts.anchor)) {
|
|
46
|
+
return { edl: { ...edl }, applied: [], ignored: [{ sourceId: opts.anchor, reason: "anchor-unknown" }] }
|
|
47
|
+
}
|
|
48
|
+
anchor = opts.anchor
|
|
49
|
+
} else {
|
|
50
|
+
const masters = sources.filter((s) => s && typeof s === "object" && s.role === "master-audio")
|
|
51
|
+
if (masters.length === 1) anchor = masters[0].id
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The anchor's own measurement is the rebase point; an absent one reads as 0.
|
|
55
|
+
let reference = 0
|
|
56
|
+
if (anchor !== undefined && hasOwn(rows, anchor)) {
|
|
57
|
+
const a = rows[anchor]
|
|
58
|
+
if (!isFiniteNumber(a)) {
|
|
59
|
+
return { edl: { ...edl }, anchor, applied: [], ignored: [{ sourceId: anchor, reason: "anchor-not-finite" }] }
|
|
60
|
+
}
|
|
61
|
+
reference = a
|
|
62
|
+
}
|
|
63
|
+
const anchorOffsetMs = anchor !== undefined ? (byId.get(anchor)?.offsetMs ?? 0) : 0
|
|
64
|
+
|
|
65
|
+
const next = new Map<string, number>()
|
|
66
|
+
const applied: string[] = []
|
|
67
|
+
const ignored: Array<{ sourceId: string; reason: string }> = []
|
|
68
|
+
for (const sourceId of Object.keys(rows)) {
|
|
69
|
+
if (sourceId === anchor) continue // never changed: the segments are on its clock
|
|
70
|
+
if (!byId.has(sourceId)) {
|
|
71
|
+
ignored.push({ sourceId, reason: "unknown-source" })
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
const measured = rows[sourceId]
|
|
75
|
+
const offsetMs = isFiniteNumber(measured)
|
|
76
|
+
? Math.round(anchor !== undefined ? anchorOffsetMs + measured - reference : measured)
|
|
77
|
+
: Number.NaN
|
|
78
|
+
// A non-finite result (the measurement, or a non-finite anchor offsetMs it
|
|
79
|
+
// is rebased onto) is never written.
|
|
80
|
+
if (!Number.isFinite(offsetMs)) {
|
|
81
|
+
ignored.push({ sourceId, reason: "not-finite" })
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
next.set(sourceId, offsetMs)
|
|
85
|
+
applied.push(sourceId)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const merged: Edl = Array.isArray(edl?.sources)
|
|
89
|
+
? { ...edl, sources: edl.sources.map((s) => (s && typeof s === "object" && next.has(s.id) ? { ...s, offsetMs: next.get(s.id)! } : s)) }
|
|
90
|
+
: { ...edl }
|
|
91
|
+
return { edl: merged, ...(anchor !== undefined ? { anchor } : {}), applied, ignored }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
95
|
+
// D20 — slot resolution
|
|
96
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export const EDL_FULL_FRAME: EdlRegion = Object.freeze({ x: 0, y: 0, w: 1, h: 1 })
|
|
99
|
+
|
|
100
|
+
export interface EdlResolvedSlot {
|
|
101
|
+
readonly source: string
|
|
102
|
+
readonly region: EdlRegion
|
|
103
|
+
/** "resolver" = the caller's `regionFor` (v3 per-segment tracks). */
|
|
104
|
+
readonly regionFrom: "slot" | "segment" | "resolver" | "speaker" | "source" | "full"
|
|
105
|
+
readonly speaker?: string
|
|
106
|
+
readonly weight?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ResolveEdlSlotsOptions {
|
|
110
|
+
/** speaker-view's per-speaker framing table (a node SETTING, not an EDL field), keyed by (source, speaker) so a
|
|
111
|
+
* wide-shot region never lands on a close-up camera framing the same person. */
|
|
112
|
+
readonly speakerRegions?: ReadonlyArray<{ readonly source: string; readonly speaker: string; readonly region: EdlRegion }>
|
|
113
|
+
/** v3 hook: a per-(segment, slot) region, e.g. from a face track. Undefined = no opinion. */
|
|
114
|
+
readonly regionFor?: (q: { readonly segment: EdlSegment; readonly source: string; readonly speaker?: string }) => EdlRegion | undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A valid in-frame box: finite, 0..1, w/h > 0, x+w ≤ 1, y+h ≤ 1 — with the
|
|
118
|
+
* same edge tolerance `validateEdl` accepts, so the two never disagree. */
|
|
119
|
+
function isInFrameRegion(r: unknown): r is EdlRegion {
|
|
120
|
+
if (!r || typeof r !== "object") return false
|
|
121
|
+
const { x, y, w, h } = r as Record<string, unknown>
|
|
122
|
+
for (const v of [x, y, w, h]) if (!isFiniteNumber(v) || v < 0 || v > 1) return false
|
|
123
|
+
const box = r as EdlRegion
|
|
124
|
+
return box.w > 0 && box.h > 0 && box.x + box.w <= 1 + 1e-9 && box.y + box.h <= 1 + 1e-9
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type Rung = EdlResolvedSlot["regionFrom"]
|
|
128
|
+
|
|
129
|
+
/** D20 — the ONE region-precedence implementation:
|
|
130
|
+
* slot.region ▷ segment.region (single-slot only) ▷ regionFor ▷ speakerRegions[(source, speaker)] ▷ source.region ▷ full frame.
|
|
131
|
+
* Slots = layout.slots when non-empty, else ONE implicit slot from segment.video (no video → []).
|
|
132
|
+
* A slot's speaker = slot.speaker ?? (single slot ? segment.speaker : undefined); no speaker → the speaker rung is skipped.
|
|
133
|
+
* A rung whose region is not a valid in-frame box (finite, 0..1, w/h > 0, x+w ≤ 1, y+h ≤ 1) falls through to the next.
|
|
134
|
+
* Unknown source id → the source rung is skipped. Pure; never throws on its own. */
|
|
135
|
+
export function resolveEdlSegmentSlots(edl: Edl, segment: EdlSegment, opts?: ResolveEdlSlotsOptions): readonly EdlResolvedSlot[] {
|
|
136
|
+
if (!segment || typeof segment !== "object") return []
|
|
137
|
+
const sources: readonly EdlSource[] = Array.isArray(edl?.sources) ? edl.sources : []
|
|
138
|
+
const layoutSlots = Array.isArray(segment.layout?.slots) ? segment.layout!.slots! : []
|
|
139
|
+
const slots: NonNullable<NonNullable<EdlSegment["layout"]>["slots"]> =
|
|
140
|
+
layoutSlots.length > 0
|
|
141
|
+
? layoutSlots
|
|
142
|
+
: typeof segment.video === "string" && segment.video
|
|
143
|
+
? [{ source: segment.video }]
|
|
144
|
+
: []
|
|
145
|
+
const single = slots.length === 1
|
|
146
|
+
|
|
147
|
+
const out: EdlResolvedSlot[] = []
|
|
148
|
+
for (const slot of slots) {
|
|
149
|
+
if (!slot || typeof slot !== "object") continue
|
|
150
|
+
const source = slot.source
|
|
151
|
+
const speaker = slot.speaker ?? (single ? segment.speaker : undefined)
|
|
152
|
+
|
|
153
|
+
// Each rung is evaluated lazily, top-down; the first valid box wins.
|
|
154
|
+
const rungs: ReadonlyArray<readonly [Rung, () => unknown]> = [
|
|
155
|
+
["slot", () => slot.region],
|
|
156
|
+
["segment", () => (single ? segment.region : undefined)],
|
|
157
|
+
["resolver", () => opts?.regionFor?.({ segment, source, ...(speaker !== undefined ? { speaker } : {}) })],
|
|
158
|
+
["speaker", () =>
|
|
159
|
+
speaker === undefined
|
|
160
|
+
? undefined
|
|
161
|
+
: Array.isArray(opts?.speakerRegions)
|
|
162
|
+
? opts.speakerRegions.find((row) => row && row.source === source && row.speaker === speaker)?.region
|
|
163
|
+
: undefined],
|
|
164
|
+
["source", () => sources.find((s) => s && typeof s === "object" && s.id === source)?.region],
|
|
165
|
+
]
|
|
166
|
+
let region: EdlRegion = EDL_FULL_FRAME
|
|
167
|
+
let regionFrom: Rung = "full"
|
|
168
|
+
for (const [from, read] of rungs) {
|
|
169
|
+
const candidate = read()
|
|
170
|
+
if (isInFrameRegion(candidate)) {
|
|
171
|
+
region = candidate
|
|
172
|
+
regionFrom = from
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
out.push({
|
|
177
|
+
source,
|
|
178
|
+
region,
|
|
179
|
+
regionFrom,
|
|
180
|
+
...(speaker !== undefined ? { speaker } : {}),
|
|
181
|
+
...(slot.weight !== undefined ? { weight: slot.weight } : {}),
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return out
|
|
185
|
+
}
|