@nodaro/shared 2.20.0 → 2.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ import { getModel } from "./model-catalog.js"
2
+
3
+ /**
4
+ * Retry/fallback behavior derived from a catalog entry's `safetyFilter`
5
+ * flag (see `ModelCatalogEntry.safetyFilter` in `model-catalog.ts`).
6
+ */
7
+ export interface SafetyRetryPolicy {
8
+ /** 2 when the model declares `safetyFilter.stochastic`, else 1. */
9
+ maxAttempts: 1 | 2
10
+ /**
11
+ * Catalog model id to offer when the retry also blocks. Only present
12
+ * when the entry declares one AND it resolves to a real catalog entry.
13
+ */
14
+ fallback?: string
15
+ }
16
+
17
+ /**
18
+ * The provider's safety filter is known to be non-deterministic on some
19
+ * catalog models — a benign prompt can trip it once and pass on an
20
+ * identical retry. For those models the platform retries a blocked
21
+ * request once (`maxAttempts: 2`) before giving up; `fallback` names the
22
+ * catalog model to offer the user when the retry also blocks.
23
+ *
24
+ * Every model not flagged this way — including an unrecognized id —
25
+ * gets a single attempt and no fallback.
26
+ */
27
+ export function safetyRetryPolicy(modelId: string): SafetyRetryPolicy {
28
+ const entry = getModel(modelId)
29
+ const safetyFilter = entry?.safetyFilter
30
+ if (!safetyFilter?.stochastic) return { maxAttempts: 1 }
31
+
32
+ const fallback = safetyFilter.fallback
33
+ if (fallback && getModel(fallback)) {
34
+ return { maxAttempts: 2, fallback }
35
+ }
36
+ return { maxAttempts: 2 }
37
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Topaz image upscale — the single authority for "which lever did the user
3
+ * pull, what do we send, and what do we charge".
4
+ *
5
+ * KIE's `topaz/image-upscale` takes exactly ONE quality lever:
6
+ * upscale_factor: "1" | "2" | "4" (default "2")
7
+ * There is no target-resolution parameter (docs.kie.ai/market/topaz/image-upscale,
8
+ * verified 2026-09-02). The editor nevertheless shipped TWO controls — an
9
+ * `upscaleFactor` select AND a `targetResolution` select (2K/4K/8K) — and billed
10
+ * on the second one, which never reached the worker. Everyone who bought 4K or
11
+ * 8K got a 2x render at 2x/4x the price (app-reports triage 2026-09-01 §4.3).
12
+ *
13
+ * So: the FACTOR is the lever. `targetResolution` survives only as a legacy
14
+ * input (stored node data, old API/MCP callers) and is mapped forward here.
15
+ * The credit tier is derived from the SAME resolution, which is why callers
16
+ * must pass `creditTier` into `buildCreditModelIdentifier` rather than the raw
17
+ * request value — that is what makes CHECK = DEBIT = SENT.
18
+ *
19
+ * The composite `topaz-image-upscale:4K` is retained as the id of the 4x tier
20
+ * (migration 288 wrote that row; renaming it would need a pricing migration for
21
+ * zero user-visible gain). Read it as "the top Topaz tier", not as a promise of
22
+ * 4096 pixels. `topaz-image-upscale:8K` stays priced in STATIC_CREDIT_COSTS so
23
+ * historical usage_logs still resolve, but nothing can reserve it any more.
24
+ *
25
+ * Resolution order (fix round 1, 2026-09-02 review):
26
+ * 1. Resolve the WINNING factor first — a valid `upscaleFactor` always wins;
27
+ * an invalid one falls through to `targetResolution` exactly as if it had
28
+ * never been sent, rather than freezing the default before the legacy
29
+ * tier gets a chance to raise it (an invalid factor + a stored 4K/8K tier
30
+ * must still resolve — and bill — at the tier's factor, not the default).
31
+ * 2. Only THEN build `adjustments`, so every `to` reflects the resolved
32
+ * factor rather than an intermediate guess, and every `from` is the raw,
33
+ * untransformed input string (never the trimmed/uppercased copy used
34
+ * internally to match).
35
+ * 3. A valid `upscaleFactor` alongside a `targetResolution` it disagrees
36
+ * with emits an informational (not corrective) adjustment — the stored
37
+ * legacy choice is being overridden, not silently dropped — with
38
+ * `to: undefined` because nothing was actually sent for that field.
39
+ * Agreement (they resolve to the same factor) is reported as nothing:
40
+ * there is no override to disclose.
41
+ */
42
+
43
+ export type TopazUpscaleFactor = "1" | "2" | "4"
44
+
45
+ export const TOPAZ_UPSCALE_FACTORS: readonly TopazUpscaleFactor[] = ["1", "2", "4"]
46
+
47
+ /** KIE's own default when `upscale_factor` is omitted (providers/kie/models.ts). */
48
+ export const TOPAZ_DEFAULT_UPSCALE_FACTOR: TopazUpscaleFactor = "2"
49
+
50
+ export interface TopazUpscaleAdjustment {
51
+ field: "upscaleFactor" | "targetResolution"
52
+ /** The raw, untransformed value as received — never trimmed or case-normalized. */
53
+ from: string
54
+ /**
55
+ * The resolved factor this adjustment corresponds to, or `undefined` for the
56
+ * informational "your stored targetResolution was overridden" notice, which
57
+ * names nothing to switch TO — `upscaleFactor` already won.
58
+ */
59
+ to: string | undefined
60
+ reason: string
61
+ }
62
+
63
+ export interface TopazUpscaleResolution {
64
+ /** Sent to KIE as `upscale_factor`. Always set — never rely on the model default. */
65
+ upscaleFactor: TopazUpscaleFactor
66
+ /**
67
+ * Passed VERBATIM as `buildCreditModelIdentifier`'s `targetResolution` arg.
68
+ * `undefined` = the bare id (1x / 2x); `"4K"` = the 4x tier.
69
+ */
70
+ creditTier: "4K" | undefined
71
+ /** Non-empty when a legacy or out-of-enum value was coerced or overridden. */
72
+ adjustments: TopazUpscaleAdjustment[]
73
+ }
74
+
75
+ /** Legacy 2K/4K/8K tier → the factor the provider can actually deliver. */
76
+ const LEGACY_TIER_TO_FACTOR: Record<string, TopazUpscaleFactor> = {
77
+ "2K": "2",
78
+ "4K": "4",
79
+ "8K": "4",
80
+ }
81
+
82
+ function isFactor(v: string): v is TopazUpscaleFactor {
83
+ return (TOPAZ_UPSCALE_FACTORS as readonly string[]).includes(v)
84
+ }
85
+
86
+ export function resolveTopazUpscale(input: {
87
+ upscaleFactor?: string | null
88
+ targetResolution?: string | null
89
+ }): TopazUpscaleResolution {
90
+ const adjustments: TopazUpscaleAdjustment[] = []
91
+
92
+ // Raw, untransformed inputs — these are what every adjustment's `from` reports.
93
+ const rawFactor = typeof input.upscaleFactor === "string" ? input.upscaleFactor : ""
94
+ const rawTier = typeof input.targetResolution === "string" ? input.targetResolution : ""
95
+
96
+ // Trimmed/normalized copies used ONLY for matching, never for display.
97
+ const trimmedFactor = rawFactor.trim()
98
+ const normalizedTier = rawTier.trim().toUpperCase()
99
+
100
+ const factorGiven = trimmedFactor.length > 0
101
+ const factorValid = factorGiven && isFactor(trimmedFactor)
102
+
103
+ const tierGiven = normalizedTier.length > 0
104
+ const tierMappedFactor = tierGiven ? LEGACY_TIER_TO_FACTOR[normalizedTier] : undefined
105
+
106
+ // --- 1. Resolve the winning factor FIRST. ---
107
+ // A valid factor always wins. An invalid (or absent) one falls through to
108
+ // whatever the legacy tier maps to; only if that also comes up empty do we
109
+ // land on the provider default.
110
+ const finalFactor: TopazUpscaleFactor = factorValid
111
+ ? (trimmedFactor as TopazUpscaleFactor)
112
+ : (tierMappedFactor ?? TOPAZ_DEFAULT_UPSCALE_FACTOR)
113
+
114
+ // --- 2. Emit adjustments LAST, against the already-resolved factor. ---
115
+ if (factorGiven && !factorValid) {
116
+ adjustments.push({
117
+ field: "upscaleFactor",
118
+ from: rawFactor,
119
+ to: finalFactor,
120
+ reason: `Topaz upscale accepts a factor of 1, 2 or 4 — "${rawFactor}" was replaced with ${finalFactor}.`,
121
+ })
122
+ }
123
+
124
+ if (factorValid && tierGiven) {
125
+ // The factor won outright. Say so if it disagrees with the stored legacy
126
+ // tier — otherwise the tier silently vanishes with no record. Agreement
127
+ // needs no note: nothing was overridden.
128
+ if (tierMappedFactor !== finalFactor) {
129
+ adjustments.push({
130
+ field: "targetResolution",
131
+ from: rawTier,
132
+ to: undefined,
133
+ reason: "upscaleFactor takes precedence over the legacy targetResolution.",
134
+ })
135
+ }
136
+ } else if (!factorValid && tierGiven) {
137
+ // No valid explicit factor — the legacy tier is the (partial) source of
138
+ // the resolved factor, or was consulted and found unusable.
139
+ if (tierMappedFactor) {
140
+ if (normalizedTier === "8K") {
141
+ adjustments.push({
142
+ field: "targetResolution",
143
+ from: rawTier,
144
+ to: finalFactor,
145
+ reason: "Topaz upscale offers factors up to 4x — an 8K target renders and bills at the 4x tier.",
146
+ })
147
+ }
148
+ } else {
149
+ adjustments.push({
150
+ field: "targetResolution",
151
+ from: rawTier,
152
+ to: finalFactor,
153
+ reason: `Unknown Topaz target "${rawTier}" — rendering at ${finalFactor}x.`,
154
+ })
155
+ }
156
+ }
157
+
158
+ return {
159
+ upscaleFactor: finalFactor,
160
+ creditTier: finalFactor === "4" ? "4K" : undefined,
161
+ adjustments,
162
+ }
163
+ }
@@ -175,6 +175,26 @@ export const clipLookSchema = z.object({
175
175
  * insert) states the deviation in that scene's `visual`, as with `lighting`.
176
176
  */
177
177
  style: z.string().optional(),
178
+ /**
179
+ * The Style picker CATALOG ID the prose in `style` corresponds to — the
180
+ * analyzer's PICK ("pixar-3d" beside "3D stylized animation"), not a second
181
+ * description of it.
182
+ *
183
+ * Worth strictly more than the prose it accompanies: an id addresses the
184
+ * catalog, so a recreation renders the same medium the product's own Style
185
+ * picker would render, instead of re-interpreting a sentence. Absent when the
186
+ * analyzer read a medium it could not place in the catalog — `style` then
187
+ * carries the whole answer, as it always did.
188
+ *
189
+ * A free `string` here on purpose. The PRODUCER validates it against the
190
+ * catalog (the analyzer plugin's wire schema is an enum generated from
191
+ * `STYLES`, so an invented id never leaves it), while this package must not
192
+ * carry the catalog itself. A closed enum here would only add a second copy
193
+ * of the vocabulary to drift out of date — and, worse, would REJECT a
194
+ * catalog entry newer than the installed `@nodaro/shared`, which is exactly
195
+ * the analysis a consumer most wants to read.
196
+ */
197
+ styleId: z.string().optional(),
178
198
  /** Colour grade / palette — "muted teal-and-orange, crushed blacks". */
179
199
  grade: z.string().optional(),
180
200
  /** Camera or film FORMAT and stock — "anamorphic digital", "16mm film grain". */
@@ -265,14 +285,32 @@ export const entitySlotSchema = z.object({
265
285
  })
266
286
  export type EntitySlot = z.infer<typeof entitySlotSchema>
267
287
 
288
+ /**
289
+ * The sound LAYER vocabulary — what kind of thing this layer is.
290
+ *
291
+ * `ambience` is deliberately its own member rather than a flavour of `sfx`:
292
+ * they are different layers of a real mix and they are recreated by different
293
+ * means — ambience is the continuous bed a scene sits in (room tone, distant
294
+ * traffic, wind), an sfx is a discrete hit (a door slam, a gunshot). Folded
295
+ * together, a scene's bed and its one-off noises arrive indistinguishable and
296
+ * anything recreating the mix has to guess which it was told.
297
+ *
298
+ * Exported so a consumer keying its own document by these modes imports the
299
+ * vocabulary instead of restating it — the same reason every other closed
300
+ * vocabulary in this file is a const.
301
+ */
302
+ export const VIDEO_ANALYSIS_AUDIO_MODES = ["speech", "music", "sfx", "ambience"] as const
303
+ export type VideoAnalysisAudioMode = (typeof VIDEO_ANALYSIS_AUDIO_MODES)[number]
304
+
268
305
  /**
269
306
  * One concurrent sound layer in a scene. Real footage stacks sound (music bed
270
- * under dialogue over ambient sfx), so a scene carries an ARRAY of these — an
271
- * empty array means genuine silence. `content`: speech = verbatim words;
272
- * music/sfx = gen-ready description. `voice` is speech-only voice-casting.
307
+ * under dialogue over ambient room tone), so a scene carries an ARRAY of these
308
+ * — an empty array means genuine silence. `content`: speech = verbatim words;
309
+ * music/sfx/ambience = gen-ready description. `voice` is speech-only
310
+ * voice-casting.
273
311
  */
274
312
  const audioLayerSchema = z.object({
275
- mode: z.enum(["speech", "music", "sfx"]),
313
+ mode: z.enum(VIDEO_ANALYSIS_AUDIO_MODES),
276
314
  content: z.string().min(1),
277
315
  voice: z.string().optional(),
278
316
  /**
@@ -296,6 +334,33 @@ const audioLayerSchema = z.object({
296
334
  * defect that `stripOrphanSlots` exists to kill.
297
335
  */
298
336
  speakerSlot: z.string().optional(),
337
+ /**
338
+ * SPEECH ONLY — WHO says these words, by NAME.
339
+ *
340
+ * The second of two ways to name a speaker, and the one for documents that
341
+ * have no slots: `speakerSlot` addresses an `EntitySlot` of THIS analysis by
342
+ * id, while `speaker` is the plain cast name a production keys its own cast
343
+ * by ("Jack Mercer"). A layer may legitimately carry both — the id for the
344
+ * analysis it came from, the name for the document it is going into — and a
345
+ * consumer that understands only one reads the one it understands.
346
+ *
347
+ * Optional, and not refined against `mode`, for the same reason
348
+ * `speakerSlot` is not: the window schema is the enforced decode grammar and
349
+ * must not throw away a whole roll over one mis-tagged field.
350
+ *
351
+ * NOT swept by `dropUnknownSpeakers`. That function is the SLOT channel's
352
+ * sanitizer — it judges an id against the surviving slot list, and a name has
353
+ * no id space to be unknown in. The asymmetry is deliberate and pinned by
354
+ * test; whoever owns the name's vocabulary sanitizes the name.
355
+ *
356
+ * The residual that leaves: a layer can keep a `speaker` naming someone
357
+ * `stripOrphanSlots` already pruned as a phantom, where the same claim spelled
358
+ * `speakerSlot` would have been dropped — the invented-narrator defect, in the
359
+ * one spelling the sweep cannot see. LATENT today, since nothing in this
360
+ * repo emits `speaker`; the day the analyzer does, that sweep is what has to
361
+ * grow, not this field.
362
+ */
363
+ speaker: z.string().optional(),
299
364
  })
300
365
  export type AudioLayer = z.infer<typeof audioLayerSchema>
301
366
 
@@ -502,7 +567,7 @@ export function rewriteSpeakerSlots(audio: AudioLayer[], slotRenames: Record<str
502
567
  * Strip attribution that no scene can honour — the `dropUnknownBindings` mirror
503
568
  * for the `audio` channel. Two cases, both model sloppiness rather than errors
504
569
  * worth failing a roll over:
505
- * - a `speakerSlot` on a `music`/`sfx` layer (nobody is speaking)
570
+ * - a `speakerSlot` on any non-`speech` layer (nobody is speaking)
506
571
  * - a `speakerSlot` naming a slot that is not in the final list
507
572
  *
508
573
  * MUST run AFTER the orphan-slot sweep, and attribution must NEVER count as a