@nodaro/shared 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/index.cjs +2242 -86
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1779 -32
  4. package/dist/index.d.ts +1779 -32
  5. package/dist/index.js +2041 -87
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/caption-styles.test.ts +207 -0
  9. package/src/__tests__/edl-multicam.test.ts +304 -0
  10. package/src/__tests__/edl.test.ts +822 -0
  11. package/src/__tests__/fan-out-rows.test.ts +208 -0
  12. package/src/__tests__/instagram-scrape.test.ts +66 -0
  13. package/src/__tests__/llm-models.test.ts +48 -11
  14. package/src/__tests__/meta-ads-scrape.test.ts +284 -0
  15. package/src/__tests__/node-runtime-keys.test.ts +15 -0
  16. package/src/__tests__/parameter-node-value.test.ts +13 -1
  17. package/src/__tests__/presentation-utils.test.ts +67 -0
  18. package/src/__tests__/producer-types.test.ts +19 -0
  19. package/src/__tests__/schedule-rules.test.ts +265 -0
  20. package/src/__tests__/speaker-layouts.test.ts +203 -0
  21. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  22. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  23. package/src/__tests__/trigger-feeds.test.ts +39 -0
  24. package/src/__tests__/video-analysis.test.ts +15 -0
  25. package/src/__tests__/video-duration-auto.test.ts +65 -0
  26. package/src/__tests__/video-duration.test.ts +56 -0
  27. package/src/__tests__/video-frame-fit.test.ts +189 -0
  28. package/src/__tests__/video-link.test.ts +137 -0
  29. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  30. package/src/caption-styles.ts +240 -0
  31. package/src/catalog-projection.ts +3 -0
  32. package/src/character-motion-metadata.ts +19 -0
  33. package/src/credit-identifiers.ts +31 -0
  34. package/src/edit-plan-contract.ts +96 -0
  35. package/src/edl-multicam.ts +185 -0
  36. package/src/edl.ts +747 -0
  37. package/src/entity-image-handle.ts +24 -1
  38. package/src/fan-out-rows.ts +213 -0
  39. package/src/i18n/character-motion.ar.ts +126 -75
  40. package/src/i18n/character-motion.de.ts +126 -75
  41. package/src/i18n/character-motion.es.ts +126 -75
  42. package/src/i18n/character-motion.fr.ts +126 -75
  43. package/src/i18n/character-motion.he.ts +126 -75
  44. package/src/i18n/character-motion.hi.ts +126 -75
  45. package/src/i18n/character-motion.ja.ts +126 -75
  46. package/src/i18n/character-motion.ko.ts +126 -75
  47. package/src/i18n/character-motion.pt-BR.ts +126 -75
  48. package/src/i18n/character-motion.ru.ts +126 -75
  49. package/src/i18n/character-motion.zh-CN.ts +126 -75
  50. package/src/index.ts +211 -3
  51. package/src/instagram-scrape.ts +204 -0
  52. package/src/llm-models.ts +80 -3
  53. package/src/meta-ads-scrape.ts +463 -0
  54. package/src/model-catalog.ts +48 -5
  55. package/src/model-constants.ts +148 -5
  56. package/src/node-mappable-fields.ts +2 -0
  57. package/src/node-runtime-keys.ts +28 -0
  58. package/src/parameter-node-value.ts +31 -5
  59. package/src/presentation-utils.ts +49 -0
  60. package/src/producer-types.ts +20 -0
  61. package/src/schedule-rules.ts +484 -0
  62. package/src/speaker-layouts.ts +220 -0
  63. package/src/transcribe-preflight.ts +101 -0
  64. package/src/trigger-feeds.ts +59 -0
  65. package/src/trigger-node-types.ts +20 -0
  66. package/src/video-analysis.ts +15 -0
  67. package/src/video-duration-auto.ts +18 -0
  68. package/src/video-duration.ts +32 -0
  69. package/src/video-frame-fit.ts +228 -0
  70. package/src/video-link.ts +167 -0
  71. package/src/video-output-canvas.ts +119 -0
  72. package/src/workflow-export.ts +37 -1
@@ -47,12 +47,31 @@ const AGGREGATE_LANE_EFFECTIVE_TYPE: Readonly<Record<string, string>> = {
47
47
  "out-text": "list",
48
48
  }
49
49
 
50
+ /**
51
+ * Meta Ads scraper: besides its `json` handle it emits the FEATURED ad's
52
+ * copy, creative image and creative video on typed handles. Each behaves as
53
+ * the canonical single-media producer of that type — `text` maps to
54
+ * `combine-text` (ONE string, not a list), so it reaches prompt inputs but
55
+ * not the list consumers.
56
+ */
57
+ // Shared by every scraper node (Meta Ads, Instagram, …): the typed
58
+ // text / image / video handles emit the canonical single-media producers.
59
+ const SCRAPER_HANDLE_EFFECTIVE_TYPE: Readonly<Record<string, string>> = {
60
+ text: "combine-text",
61
+ image: "upload-image",
62
+ video: "upload-video",
63
+ }
64
+ const SCRAPER_SOURCE_TYPES = new Set(["meta-ads-scrape", "instagram-scrape"])
65
+
50
66
  /**
51
67
  * The effective output TYPE a given source handle emits. Returns the raw node
52
68
  * type for every `(type, handle)` pair EXCEPT:
53
69
  * - an entity `image` handle → `"upload-image"` (a plain image producer);
54
70
  * - an aggregate (group / collect) lane handle → the plain producer of that
55
- * lane's media type (see AGGREGATE_LANE_EFFECTIVE_TYPE).
71
+ * lane's media type (see AGGREGATE_LANE_EFFECTIVE_TYPE);
72
+ * - a Meta Ads `text` / `image` / `video` handle → the plain producer of
73
+ * that type (see META_ADS_HANDLE_EFFECTIVE_TYPE); its `json` handle keeps
74
+ * the raw type.
56
75
  * Pure — safe for both frontend and backend.
57
76
  */
58
77
  export function resolveEffectiveSourceType(
@@ -66,6 +85,10 @@ export function resolveEffectiveSourceType(
66
85
  const effective = AGGREGATE_LANE_EFFECTIVE_TYPE[sourceHandleId ?? ""]
67
86
  if (effective) return effective
68
87
  }
88
+ if (SCRAPER_SOURCE_TYPES.has(rawSourceType ?? "")) {
89
+ const effective = SCRAPER_HANDLE_EFFECTIVE_TYPE[sourceHandleId ?? ""]
90
+ if (effective) return effective
91
+ }
69
92
  return rawSourceType ?? ""
70
93
  }
71
94
 
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Fan-out pairs by ROW.
3
+ *
4
+ * When several list wires reach one node ("prompt" + "negative" columns of the
5
+ * same List, two Extract Field lists cut from the same JSON array, …) the node
6
+ * runs once per row and every wire contributes the value of THAT row. Both DAG
7
+ * engines (backend orchestrator + in-browser executor) build the plan with the
8
+ * helpers below, so the rules live in exactly one place:
9
+ *
10
+ * - which list drives the fan-out, and which rows run, never depend on the
11
+ * order the wires were drawn in (`resolveListFanOut`);
12
+ * - the driving item is only written into the prompt when its wire actually
13
+ * feeds the prompt (`fanOutTextFeedsPrompt`, `NON_PROMPT_TEXT_LANES`);
14
+ * - an empty cell stays empty IN ITS ROW instead of pulling the rows below it
15
+ * up (`liveRowColumn`, `alignedFieldList`, `compactWithRows`);
16
+ * - an iteration keeps its row when Repeat xN multiplies it (`planFanOut`).
17
+ */
18
+ import { evaluateJsonPath, stringifyPathResults } from "./json-path.js"
19
+ import { expandItemsWithRepeat } from "./repeat-types.js"
20
+
21
+ /**
22
+ * The text LANES the input resolvers route somewhere OTHER than the prompt slot
23
+ * (`negative` → negativePrompt, `system-prompt` → systemPrompt, an avatar's
24
+ * `script`, an editor's `transcript`, …). A list that drives a fan-out through
25
+ * one of these must NOT also be written into the prompt: the per-row resolution
26
+ * already delivered it where it belongs.
27
+ *
28
+ * Keyed by handle, scoped by node type, because the routers are: `transcript`
29
+ * is the caption track of Add Captions but the very text Forced Alignment
30
+ * aligns. `"*"` = every node type (the router branch is unconditional).
31
+ *
32
+ * DERIVED, not remembered: each engine has a totality test
33
+ * (`fanout-text-handle-totality`) that routes a text source into EVERY input
34
+ * handle of EVERY node type through the real resolver and fails the build on any
35
+ * pair where this table and the router disagree — in either direction. An
36
+ * unlisted lane keeps the old behavior (the item is the prompt), so a miss can
37
+ * only ever reproduce the old bug on that one lane, never a new one.
38
+ */
39
+ export const NON_PROMPT_TEXT_LANES: Readonly<Record<string, "*" | readonly string[]>> = {
40
+ negative: "*",
41
+ "system-prompt": "*",
42
+ script: ["ai-avatar"],
43
+ transcript: ["add-captions", "apply-edl", "edit-plan"],
44
+ edl: ["apply-edl"],
45
+ silence: ["edit-plan"],
46
+ qrText: ["image-overlay"],
47
+ transition: ["slideshow"],
48
+ }
49
+
50
+ /** Does a TEXT list wired to `targetHandle` of a `nodeType` node feed its prompt slot? */
51
+ export function fanOutTextFeedsPrompt(nodeType: string | null | undefined, targetHandle: string | null | undefined): boolean {
52
+ const scope = Object.hasOwn(NON_PROMPT_TEXT_LANES, targetHandle ?? "") ? NON_PROMPT_TEXT_LANES[targetHandle ?? ""] : undefined
53
+ if (scope === undefined) return true
54
+ return scope !== "*" && !scope.includes(nodeType ?? "")
55
+ }
56
+
57
+ const isBlank = (v: unknown): boolean => typeof v !== "string" || v.trim().length === 0
58
+
59
+ /**
60
+ * Drop the empty entries of a row-aligned list, remembering the row every kept
61
+ * value came from. The items are what drives a fan-out (nothing runs for an
62
+ * empty cell); the rows are what keeps the OTHER wires on the same row.
63
+ */
64
+ export function compactWithRows(aligned: readonly string[]): { items: string[]; rowIndices: number[] } {
65
+ const items: string[] = []
66
+ const rowIndices: number[] = []
67
+ aligned.forEach((value, row) => {
68
+ if (isBlank(value)) return
69
+ items.push(value)
70
+ rowIndices.push(row)
71
+ })
72
+ return { items, rowIndices }
73
+ }
74
+
75
+ /**
76
+ * One column of a manual List table, row-aligned: a row is dropped only when
77
+ * EVERY cell of it is blank (the trailing empty row the editor keeps), and an
78
+ * empty cell of a row that has content stays in place as "".
79
+ */
80
+ export function liveRowColumn(rows: ReadonlyArray<ReadonlyArray<string | undefined>>, colIndex: number): string[] {
81
+ return rows
82
+ .filter((row) => row.some((cell) => !isBlank(cell)))
83
+ .map((row) => (typeof row[colIndex] === "string" ? (row[colIndex] as string).trim() : ""))
84
+ }
85
+
86
+ /** One list wire that could drive a fan-out. */
87
+ export interface FanOutCandidate {
88
+ /** Target handle of the consumer-side wire. */
89
+ targetHandle: string | null | undefined
90
+ /** The wire's values in row order — may hold "" for an empty row. */
91
+ aligned: readonly string[]
92
+ }
93
+
94
+ /**
95
+ * Turn the lists that reach a node into ONE fan-out, so that nothing depends on
96
+ * the order the wires were drawn in. `candidates` are the "each" lists that hold
97
+ * more than one value, in wire order.
98
+ *
99
+ * The PRIMARY is the list that holds the most values (the first of them when
100
+ * several tie): it sets the row space. Both engines get it from here, so the
101
+ * orchestrator and the in-browser executor cannot disagree on how many times a
102
+ * node runs. Lists with the SAME number of rows share that row space — two
103
+ * columns of one table, two Extract Field lists cut from one array — and are
104
+ * settled together:
105
+ *
106
+ * - the DRIVER (its item becomes the per-row override) is a text list that
107
+ * feeds the prompt, when there is one: that item is what must win over the
108
+ * typed prompt, and a list wired to `negative` must never supply it;
109
+ * - a ROW runs when ANY of those lists has a value in it. A row is never
110
+ * dropped because one column is empty there — that column just contributes
111
+ * nothing for the row — and a row with no value anywhere does not run.
112
+ *
113
+ * A list with a different number of rows is not part of this: it keeps
114
+ * wrapping around per iteration, as it always has.
115
+ */
116
+ export function resolveListFanOut<C extends FanOutCandidate>(
117
+ candidates: readonly C[],
118
+ nodeType: string | null | undefined,
119
+ ): ListFanOut | undefined {
120
+ if (candidates.length === 0) return undefined
121
+ const held = (c: C) => compactWithRows(c.aligned).items.length
122
+ const primary = candidates.reduce((best, c) => (held(c) > held(best) ? c : best))
123
+ const space = candidates.filter((c) => c.aligned.length === primary.aligned.length)
124
+ const feedsPrompt = (c: C) => fanOutTextFeedsPrompt(nodeType, c.targetHandle) && isTextList(c.aligned)
125
+ const driver = feedsPrompt(primary) ? primary : (space.find(feedsPrompt) ?? primary)
126
+ const rowIndices: number[] = []
127
+ for (let row = 0; row < primary.aligned.length; row++) {
128
+ if (space.some((c) => !isBlank(c.aligned[row]))) rowIndices.push(row)
129
+ }
130
+ return {
131
+ items: rowIndices.map((row) => (isBlank(driver.aligned[row]) ? "" : driver.aligned[row])),
132
+ rowIndices,
133
+ targetHandle: driver.targetHandle,
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Is this fan-out item a media URL (as opposed to text)? The ONE guess both
139
+ * engines make when a list item has to be applied as an override — kept here so
140
+ * the backend worker and the in-browser executor cannot disagree on it.
141
+ */
142
+ export function isFanOutUrlItem(item: string): boolean {
143
+ return item.startsWith("http") || /\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mp3|wav|ogg)(\?|$)/i.test(item)
144
+ }
145
+
146
+ /** A media list never supplies a prompt override, whatever handle it is wired to.
147
+ * The FIRST value decides — a column is one kind of thing; this is not a scan. */
148
+ function isTextList(aligned: readonly string[]): boolean {
149
+ const first = aligned.find((v) => !isBlank(v))
150
+ return first !== undefined && !isFanOutUrlItem(first)
151
+ }
152
+
153
+ /** What a fan-out source resolved to. */
154
+ export interface ListFanOut {
155
+ /** The driving list's value for every row that runs — "" where the driver has
156
+ * none for a row another list keeps alive (nothing is overridden there). */
157
+ items: string[]
158
+ /** `rowIndices[k]` = the row `items[k]` came from, in the driver's row space. */
159
+ rowIndices: number[]
160
+ /** Target handle of the consumer-side wire that drives the fan-out. */
161
+ targetHandle: string | null | undefined
162
+ }
163
+
164
+ /** The iterations a node will run, in order. */
165
+ export interface FanOutPlan {
166
+ /** One entry per iteration (list items, repeat / provider sentinels). */
167
+ items: string[]
168
+ /** Row each iteration reads its inputs from; undefined when nothing is list-driven. */
169
+ rows: Array<number | undefined>
170
+ /** Handle the driving list is wired to; undefined when nothing is list-driven. */
171
+ targetHandle: string | null | undefined
172
+ }
173
+
174
+ /**
175
+ * Expand a fan-out into its iterations (list x Repeat xN, providers, repeats —
176
+ * `expandItemsWithRepeat` stays the single rule for that) and pin every
177
+ * iteration to the ROW its item came from. Repeat xN runs a row N times: the
178
+ * copies share the row, they do not walk on to the next one.
179
+ */
180
+ export function planFanOut(
181
+ fanOut: ListFanOut | undefined,
182
+ nodeType: string,
183
+ nodeData: Record<string, unknown>,
184
+ ): FanOutPlan | null {
185
+ const items = expandItemsWithRepeat(fanOut?.items, nodeType, nodeData)
186
+ if (!items) return null
187
+ const listDriven = fanOut !== undefined && fanOut.items.length > 1
188
+ if (!listDriven) return { items, rows: items.map(() => undefined), targetHandle: undefined }
189
+ const perRow = items.length / fanOut.items.length
190
+ return {
191
+ items,
192
+ rows: items.map((_, k) => fanOut.rowIndices[Math.floor(k / perRow)]),
193
+ targetHandle: fanOut.targetHandle,
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Extract Field, List output, over a root ARRAY: one entry per element, "" where
199
+ * the element has no value — so two Extract Field lists cut from the same array
200
+ * stay row-aligned. Returns undefined when rows cannot be defined (the root is
201
+ * not an array, an element fans into several values, or no element carries the
202
+ * field at all) — the caller then keeps the plain "values that exist" list.
203
+ */
204
+ export function alignedFieldList(value: unknown, path: string): string[] | undefined {
205
+ if (!Array.isArray(value) || value.length === 0) return undefined
206
+ const out: string[] = []
207
+ for (const element of value) {
208
+ const found = evaluateJsonPath(element, path)
209
+ if (found.length > 1) return undefined
210
+ out.push(stringifyPathResults(found)[0] ?? "")
211
+ }
212
+ return out.some((v) => v.length > 0) ? out : undefined
213
+ }