@nodaro/shared 2.19.0 → 2.21.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 (35) hide show
  1. package/dist/index.cjs +548 -31
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1043 -386
  4. package/dist/index.d.ts +1043 -386
  5. package/dist/index.js +529 -32
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/credit-identifiers.test.ts +133 -0
  9. package/src/__tests__/image-pricing-catalog-coverage.test.ts +139 -0
  10. package/src/__tests__/normalize-node-params.test.ts +36 -0
  11. package/src/__tests__/organizations-types.test.ts +29 -1
  12. package/src/__tests__/prompt-length-limits.test.ts +36 -0
  13. package/src/__tests__/safety-retry-policy.test.ts +47 -0
  14. package/src/__tests__/suno-credit-type.test.ts +54 -0
  15. package/src/__tests__/topaz-upscale.test.ts +132 -0
  16. package/src/__tests__/unresolved-ref-tokens.test.ts +66 -0
  17. package/src/__tests__/video-analysis-brief.test.ts +61 -0
  18. package/src/__tests__/video-analysis.test.ts +83 -0
  19. package/src/__tests__/video-audio-capability.test.ts +38 -4
  20. package/src/__tests__/video-catalog-totality.test.ts +85 -0
  21. package/src/__tests__/video-collapse-parity.test.ts +76 -0
  22. package/src/__tests__/video-ref-video-duration-limits.test.ts +69 -0
  23. package/src/__tests__/video-request-normalize.test.ts +239 -0
  24. package/src/credit-identifiers.ts +264 -20
  25. package/src/index.ts +30 -2
  26. package/src/model-catalog.ts +287 -6
  27. package/src/model-constants.ts +185 -13
  28. package/src/node-refs.ts +82 -0
  29. package/src/node-runtime-keys.ts +5 -0
  30. package/src/normalize-node-params.ts +8 -0
  31. package/src/organizations/types.ts +12 -0
  32. package/src/organizations/views.ts +114 -0
  33. package/src/safety-retry-policy.ts +37 -0
  34. package/src/topaz-upscale.ts +163 -0
  35. package/src/video-analysis.ts +115 -5
package/src/node-refs.ts CHANGED
@@ -50,6 +50,10 @@ export function canonicalVarName(label: string): string {
50
50
  * SUPPRESS auto-injection of a connected node the author already placed
51
51
  * explicitly via `{label}` (so it isn't injected twice). `matchAll` over the
52
52
  * global pattern does not mutate its lastIndex, so sharing the constant is safe.
53
+ *
54
+ * Deliberately NOT `REF_TOKEN_NAMESPACE_PREFIXES`: widening this changes
55
+ * run-time auto-injection on BOTH engines (follow-up ticket), not just the
56
+ * editor.
53
57
  */
54
58
  export function extractReferencedLabels(
55
59
  ...texts: ReadonlyArray<string | undefined | null>
@@ -251,3 +255,81 @@ export function resolveNodeRefs(
251
255
  }
252
256
  return result
253
257
  }
258
+
259
+ /**
260
+ * Token namespaces that are NOT node-label references. `{image:1:face}` is the
261
+ * unified-reference grammar, `{slot:x}` is recast's, `{video:N}`/`{audio:N}`
262
+ * are reference handles, and `{ref:<id>}` is the id-addressed reference form —
263
+ * all five are substituted by their own resolvers (`resolveReferenceTokens`,
264
+ * `resolveRefIdTokens`, the recast slot pass), not by resolveNodeRefs. Before
265
+ * this list existed, only `image:` was excluded, so `{video:1}` classified as a
266
+ * MISSING node ref.
267
+ *
268
+ * Compared against the LOWERCASED token name: `REFERENCE_TOKEN_RE` is `/gi` and
269
+ * `REF_ID_TOKEN_RE` spells `[rR][eE][fF]`, so `{Image:1}` / `{Ref:x}` really do
270
+ * resolve downstream and must never read as a missing node ref.
271
+ */
272
+ export const REF_TOKEN_NAMESPACE_PREFIXES: readonly string[] = [
273
+ "image:", "video:", "audio:", "slot:", "ref:",
274
+ ]
275
+
276
+ export type RefTokenKind = "wired" | "reserved" | "missing" | "skip" | "unknown"
277
+
278
+ /**
279
+ * Classify a parsed `{...}` token name against the resolvable label set.
280
+ * `resolvable === null` means the caller has no ref data at all — such tokens
281
+ * classify `unknown` and must render like wired, so "no data" never
282
+ * masquerades as "nothing wired".
283
+ *
284
+ * Single source of truth for the editor decoration, the missing-refs chip
285
+ * (frontend/src/lib/prompt-ref-scan.ts delegates here) and the execution
286
+ * engine's dispatch guard.
287
+ */
288
+ export function classifyRefToken(
289
+ name: string,
290
+ resolvable: ReadonlySet<string> | null,
291
+ ): RefTokenKind {
292
+ const lower = name.toLowerCase()
293
+ if (name === "" || REF_TOKEN_NAMESPACE_PREFIXES.some((p) => lower.startsWith(p))) return "skip"
294
+ if (RESERVED_TEMPLATE_VARS.has(name)) return "reserved"
295
+ if (resolvable === null) return "unknown"
296
+ return resolvable.has(canonicalVarName(name)) ? "wired" : "missing"
297
+ }
298
+
299
+ /**
300
+ * The `{Label}` tokens in `text` that `resolveNodeRefs` would leave LITERAL and
301
+ * that no upstream node can explain — i.e. exactly what would reach a provider
302
+ * as the characters `{Label}`. Evidence for why this matters: `{Describe Image}`
303
+ * ×2 (gpt-image-2) and `{gravity flip}` / `{rewind}` (seedance-2-5) in the
304
+ * 2026-09-01 app-reports export.
305
+ *
306
+ * `resolvable` — labels with a value in the ref map (canonical/lowercase).
307
+ * `known` — canonical labels of the nodes the caller considers to EXIST
308
+ * (no state requirement; the backend passes every node in the
309
+ * run's graph).
310
+ *
311
+ * A token whose label is in `known` but not `resolvable` PASSES: the node
312
+ * exists and simply produced nothing, which is the caller's to substitute (the
313
+ * backend resolves it to empty text), not this function's to refuse. A token
314
+ * with an explicit `|| fallback` passes too — the fallback is substituted.
315
+ * Returns original-cased names, de-duplicated by canonical form, for the
316
+ * user-facing message.
317
+ */
318
+ export function unresolvedRefTokens(
319
+ text: string,
320
+ opts: { resolvable: ReadonlySet<string>; known: ReadonlySet<string> },
321
+ ): string[] {
322
+ if (typeof text !== "string" || text.length === 0) return []
323
+ const seen = new Set<string>()
324
+ const out: string[] = []
325
+ for (const m of text.matchAll(NODE_REF_PATTERN)) {
326
+ const { name, fallback } = parseNodeRef(m[1] ?? "")
327
+ if (fallback !== null) continue
328
+ if (classifyRefToken(name, opts.resolvable) !== "missing") continue
329
+ const canon = canonicalVarName(name)
330
+ if (opts.known.has(canon) || seen.has(canon)) continue
331
+ seen.add(canon)
332
+ out.push(name)
333
+ }
334
+ return out
335
+ }
@@ -16,6 +16,11 @@ export const EXECUTION_DATA_KEYS: ReadonlySet<string> = new Set([
16
16
  "currentJobId",
17
17
  "currentJobProgress",
18
18
  "errorMessage",
19
+ // Structured detail alongside errorMessage for a safety-filter block
20
+ // (see `JobErrorHint` in the app's frontend/src/types/nodes.ts). Same
21
+ // lifecycle as errorMessage: a RESULT the user expects to survive reload,
22
+ // never user-edited config.
23
+ "errorHint",
19
24
  "isStreaming",
20
25
  "generatedImageUrl",
21
26
  "generatedVideoUrl",
@@ -30,10 +30,18 @@ import { normalizeModelInput, type ModelInputAdjustment } from "./model-catalog.
30
30
  * through mode-dependent defaults (`"adaptive"` for Seedance/Hailuo, duration
31
31
  * composites tied to pricing) that this flat normalizer would flatten wrongly;
32
32
  * they get their own pass once those defaults are catalog-derived too.
33
+ *
34
+ * `modify-image` carries the same provider/aspectRatio/resolution/quality trio
35
+ * as `image-to-image` (it routes through the same worker), and `edit-image`
36
+ * carries provider + aspectRatio. `edit-image`'s `targetResolution` is an
37
+ * UPSCALE target, a different field this module never reads — so listing the
38
+ * type here heals its ratio without touching what it is priced on.
33
39
  */
34
40
  export const MODEL_PARAM_NODE_TYPES: ReadonlySet<string> = new Set([
35
41
  "generate-image",
36
42
  "image-to-image",
43
+ "modify-image",
44
+ "edit-image",
37
45
  ])
38
46
 
39
47
  export interface NodeParamAdjustment extends ModelInputAdjustment {
@@ -56,6 +56,16 @@ export type GrantedAccess = (typeof GRANTED_ACCESS)[number]
56
56
  export const SUBMISSION_STATUSES = ["submitted", "in_review", "returned", "approved"] as const
57
57
  export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number]
58
58
 
59
+ /**
60
+ * How a usage report is bucketed. `workspace` is org-scope only; `none`
61
+ * = flat rows. A runtime list, like `ORG_ROLES`, so the plugin route's Zod is
62
+ * `z.enum(USAGE_GROUP_BYS)` and the migration guard asserts the SQL
63
+ * `NOT IN (...)` list equals this minus `none` — one vocabulary, never three
64
+ * hand-copies that drift.
65
+ */
66
+ export const USAGE_GROUP_BYS = ["workspace", "member", "model", "day", "none"] as const
67
+ export type UsageGroupBy = (typeof USAGE_GROUP_BYS)[number]
68
+
59
69
  /**
60
70
  * Error codes the organization endpoints add to the standard envelope
61
71
  * (`{ error: { code, message } }`). Clients dispatch on the code, never on
@@ -80,6 +90,8 @@ export const ORG_ERROR_CODES = [
80
90
  "domain_not_allowed",
81
91
  "already_started",
82
92
  "collab_unavailable",
93
+ // A CSV usage export whose write-ahead audit row could not be written (503).
94
+ "audit_unavailable",
83
95
  // Organization, workspace and membership endpoints.
84
96
  "terms_required",
85
97
  "not_org_member",
@@ -4,6 +4,7 @@ import type {
4
4
  OrgRole,
5
5
  OrgSettings,
6
6
  OrgStatus,
7
+ UsageGroupBy,
7
8
  WorkspaceRole,
8
9
  WorkspaceSettings,
9
10
  } from "./types.js"
@@ -218,3 +219,116 @@ export interface OrgPage<T> {
218
219
  data: T[]
219
220
  nextCursor: string | null
220
221
  }
222
+
223
+ /**
224
+ * Usage reporting. What `GET /v1/orgs/:id/usage` and
225
+ * `GET /v1/workspaces/:id/usage` return. No cost/USD field appears anywhere —
226
+ * a report shows CREDITS a class or team spent, never the platform's own rates
227
+ * (pricing-leak class, guarded by organizations-types.test.ts and the
228
+ * migration guard).
229
+ */
230
+
231
+ /** One bucket of a usage report. Exactly one of workspace/member/model/day is set. */
232
+ export interface UsageReportRow {
233
+ key: string
234
+ workspace: { id: string; name: string | null; slug: string | null; archived: boolean } | null
235
+ member: { userId: string; displayName: string | null; email: string | null } | null
236
+ model: string | null
237
+ /** `YYYY-MM-DD` in the report's `tz`. */
238
+ day: string | null
239
+ runCount: number
240
+ appRunCount: number
241
+ /** Settled where known, the held reservation otherwise. = settledCredits + inFlightCredits. */
242
+ credits: number
243
+ settledCredits: number
244
+ inFlightCredits: number
245
+ inFlightRuns: number
246
+ }
247
+
248
+ /**
249
+ * A platform-absorbed line for one workspace, split by ORIGIN (never attributed
250
+ * to a member). Two ledgers share the `org_usage_variance` source: a
251
+ * `metered_overrun` (a metered run's overrun beyond the budget — it HAS a
252
+ * settled usage_logs counterpart) and an `app_markup` shortfall (an
253
+ * approved-app markup the budget could not cover — it has NO usage_logs row).
254
+ * `other` is a future/unrecognised origin.
255
+ */
256
+ export interface UsageVarianceRow {
257
+ workspace: { id: string; name: string | null; slug: string | null } | null
258
+ kind: "metered_overrun" | "app_markup" | "other"
259
+ credits: number
260
+ rowCount: number
261
+ }
262
+
263
+ /**
264
+ * Totals over the WHOLE window — every usage_logs row in [from, to] after the
265
+ * scope / userId / workspaceId narrowing — never over the returned `rows`.
266
+ * Unaffected by `truncated`; equals a `groupBy=day` report's column-wise sum.
267
+ */
268
+ export interface UsageReportTotals {
269
+ runCount: number
270
+ credits: number
271
+ settledCredits: number
272
+ inFlightCredits: number
273
+ /** Metered-overrun variance in the window — a run's overrun the platform absorbed. */
274
+ platformAbsorbedCredits: number
275
+ /** Approved-app markup shortfall the platform absorbed. It has NO usage_logs run, so it is not in the figures above. */
276
+ appMarkupAbsorbedCredits: number
277
+ /**
278
+ * settledCredits − platformAbsorbedCredits: the METERED settlement that
279
+ * reached the workspace budget(s). App markup charged to a budget (migration
280
+ * 352) is not a usage_logs row and is not included here; when a markup
281
+ * shortfall is absorbed this figure under-reports and may go negative — it is
282
+ * NOT `workspace_budgets.spent_credits`.
283
+ */
284
+ chargedToBudget: number
285
+ }
286
+
287
+ export interface UsageReport {
288
+ scope: "org" | "workspace"
289
+ scopeId: string
290
+ from: string
291
+ to: string
292
+ tz: string
293
+ groupBy: Exclude<UsageGroupBy, "none">
294
+ /** Present when a member's self-view or an admin's `?userId=` narrowed the report. */
295
+ userId: string | null
296
+ /** Present when an org report was narrowed to one workspace. */
297
+ workspaceId: string | null
298
+ rows: UsageReportRow[]
299
+ variance: UsageVarianceRow[]
300
+ totals: UsageReportTotals
301
+ /**
302
+ * True when more than 5000 buckets existed and the tail of `rows` was dropped
303
+ * — narrow the window. Only `rows` is incomplete; `totals` and `variance`
304
+ * cover the whole window regardless.
305
+ */
306
+ truncated: boolean
307
+ }
308
+
309
+ /** One usage_logs row as an organization sees it. No cost fields, ever. */
310
+ export interface UsageLogEntry {
311
+ id: string
312
+ createdAt: string
313
+ workspace: { id: string; name: string | null; slug: string | null } | null
314
+ member: { userId: string; displayName: string | null; email: string | null } | null
315
+ jobId: string | null
316
+ model: string
317
+ status: "reserved" | "committed"
318
+ creditsReserved: number
319
+ creditsSettled: number | null
320
+ credits: number
321
+ isAppRun: boolean
322
+ }
323
+
324
+ /** Query parameters shared by both usage routes (dates inclusive, IANA tz). */
325
+ export interface UsageQuery {
326
+ from?: string
327
+ to?: string
328
+ tz?: string
329
+ groupBy?: UsageGroupBy
330
+ workspaceId?: string
331
+ userId?: string
332
+ cursor?: string
333
+ limit?: number
334
+ }
@@ -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
@@ -633,3 +698,48 @@ export function inferMusicVideo(analysis: {
633
698
  }),
634
699
  )
635
700
  }
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // The analysis as a BRIEF — the compact projection an LLM is handed
704
+ // ---------------------------------------------------------------------------
705
+
706
+ /** Top-level keys the analyzer derives AFTER the model's pass (merge
707
+ * diagnostics, folded cast looks). A reader drafting FROM the analysis — a
708
+ * production plan, a script — needs none of them. */
709
+ const DERIVED_ANALYSIS_TOP_KEYS: ReadonlySet<string> = new Set(["warnings", "variationFolds"])
710
+
711
+ /** Per-scene keys the validator computes from `visual` and the slot list. */
712
+ const DERIVED_ANALYSIS_SCENE_KEYS: ReadonlySet<string> = new Set(["visualResolved", "slotRefs", "oversized"])
713
+
714
+ const asRecord = (v: unknown): Record<string, unknown> | null =>
715
+ typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
716
+
717
+ /**
718
+ * The analysis with its server-derived fields removed — the form an LLM is
719
+ * handed when the analysis IS the brief (Nodaro Studio's Director and its
720
+ * job-id loader). Drops `warnings` and `variationFolds` at the top and
721
+ * `visualResolved`, `slotRefs`, `oversized` on every scene; keeps
722
+ * `refImageUrl` (a downstream cast image) and everything else. Never mutates;
723
+ * a non-object input comes back as-is.
724
+ *
725
+ * ONE strip list, shared by the worker that composes the brief server-side
726
+ * (`llm-structured` jobs with a `videoUrl`) and the client that loads a
727
+ * finished analysis into a textarea, so the two can never drift.
728
+ */
729
+ export function stripDerivedAnalysisFields(json: unknown): unknown {
730
+ const doc = asRecord(json)
731
+ if (!doc) return json
732
+ const out: Record<string, unknown> = {}
733
+ for (const [key, value] of Object.entries(doc)) {
734
+ if (DERIVED_ANALYSIS_TOP_KEYS.has(key)) continue
735
+ out[key] =
736
+ key === "scenes" && Array.isArray(value)
737
+ ? value.map((scene) => {
738
+ const s = asRecord(scene)
739
+ if (!s) return scene
740
+ return Object.fromEntries(Object.entries(s).filter(([k]) => !DERIVED_ANALYSIS_SCENE_KEYS.has(k)))
741
+ })
742
+ : value
743
+ }
744
+ return out
745
+ }