@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.
- package/dist/index.cjs +548 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1043 -386
- package/dist/index.d.ts +1043 -386
- package/dist/index.js +529 -32
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/credit-identifiers.test.ts +133 -0
- package/src/__tests__/image-pricing-catalog-coverage.test.ts +139 -0
- package/src/__tests__/normalize-node-params.test.ts +36 -0
- package/src/__tests__/organizations-types.test.ts +29 -1
- package/src/__tests__/prompt-length-limits.test.ts +36 -0
- package/src/__tests__/safety-retry-policy.test.ts +47 -0
- package/src/__tests__/suno-credit-type.test.ts +54 -0
- package/src/__tests__/topaz-upscale.test.ts +132 -0
- package/src/__tests__/unresolved-ref-tokens.test.ts +66 -0
- package/src/__tests__/video-analysis-brief.test.ts +61 -0
- package/src/__tests__/video-analysis.test.ts +83 -0
- package/src/__tests__/video-audio-capability.test.ts +38 -4
- package/src/__tests__/video-catalog-totality.test.ts +85 -0
- package/src/__tests__/video-collapse-parity.test.ts +76 -0
- package/src/__tests__/video-ref-video-duration-limits.test.ts +69 -0
- package/src/__tests__/video-request-normalize.test.ts +239 -0
- package/src/credit-identifiers.ts +264 -20
- package/src/index.ts +30 -2
- package/src/model-catalog.ts +287 -6
- package/src/model-constants.ts +185 -13
- package/src/node-refs.ts +82 -0
- package/src/node-runtime-keys.ts +5 -0
- package/src/normalize-node-params.ts +8 -0
- package/src/organizations/types.ts +12 -0
- package/src/organizations/views.ts +114 -0
- package/src/safety-retry-policy.ts +37 -0
- package/src/topaz-upscale.ts +163 -0
- 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
|
+
}
|
package/src/node-runtime-keys.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/video-analysis.ts
CHANGED
|
@@ -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
|
|
271
|
-
* empty array means genuine silence. `content`: speech = verbatim words;
|
|
272
|
-
* music/sfx = gen-ready description. `voice` is speech-only
|
|
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(
|
|
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
|
|
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
|
+
}
|