@deepblend/dsh-blender-contracts 0.2.20 → 0.3.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.
- package/lib/artistic-review.js +57 -0
- package/lib/asset-content.js +6 -3
- package/lib/handled-cup.js +55 -0
- package/lib/index.js +31 -14
- package/lib/recipe.js +253 -0
- package/lib/reference-images.js +28 -0
- package/lib/runtime-types.d.ts +208 -0
- package/lib/scene-patch.js +133 -1
- package/lib/scene-spec.js +249 -4
- package/lib/schema-types.d.ts +185 -0
- package/lib/schemas/job-result.schema.json +117 -0
- package/lib/schemas/recipe.schema.json +332 -0
- package/lib/schemas/scene-patch.schema.json +793 -57
- package/lib/schemas/scene-spec.schema.json +653 -76
- package/lib/sdk.d.ts +101 -0
- package/lib/sdk.js +41 -0
- package/lib/ui-api.js +68 -3
- package/lib/visual-composition.js +38 -24
- package/lib/visual-issue.js +26 -8
- package/lib/visual-loop.js +204 -26
- package/package.json +8 -1
package/lib/sdk.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Node.js authoring SDK. Validate untrusted JSON; TypeScript cannot check semantic constraints. */
|
|
2
|
+
import type {
|
|
3
|
+
SceneSpec, ScenePatch, ScenePatchOperation, SceneSpecSchema, RecipeManifest, RecipeCapability,
|
|
4
|
+
BlenderErrorCodeMap, BlenderWarningCodeMap, Material, AnimationTrack, ReferenceImage,
|
|
5
|
+
} from './schema-types.js';
|
|
6
|
+
export type * from './schema-types.js';
|
|
7
|
+
export type * from './runtime-types.js';
|
|
8
|
+
|
|
9
|
+
export const BLENDER_PROTOCOL_VERSION: 'deepblend.blender/v1';
|
|
10
|
+
export const SCENE_SCHEMA_VERSION: 'deepblend.scene/v1';
|
|
11
|
+
export const SCENE_PATCH_VERSION: 'deepblend.scene-patch/v1';
|
|
12
|
+
export const HOST_API_VERSION: 6;
|
|
13
|
+
export const BlenderErrorCode: Readonly<BlenderErrorCodeMap>;
|
|
14
|
+
export const BlenderWarningCode: Readonly<BlenderWarningCodeMap>;
|
|
15
|
+
export class BlenderError extends Error {
|
|
16
|
+
constructor(code: string, message: string, options?: { detail?: unknown; cause?: unknown });
|
|
17
|
+
code: string;
|
|
18
|
+
detail?: unknown;
|
|
19
|
+
toJSON(): { code: string; message: string; detail?: unknown };
|
|
20
|
+
}
|
|
21
|
+
export function isBlenderError(value: unknown): value is BlenderError;
|
|
22
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
23
|
+
export function canonicalStringify(value: unknown): string | undefined;
|
|
24
|
+
export function canonicalPretty(value: unknown, indent?: number): string | undefined;
|
|
25
|
+
export function sha256(value: string | Uint8Array): string;
|
|
26
|
+
export function sha256Canonical(value: unknown): string;
|
|
27
|
+
export interface ValidationIssue { code: string; path: string; message: string }
|
|
28
|
+
export interface SceneSpecIssue extends ValidationIssue { severity: 'error' | 'notice' }
|
|
29
|
+
export interface ValidationResult<Issue = ValidationIssue> { ok: boolean; errors: Issue[]; summary: string }
|
|
30
|
+
export interface SceneSpecValidation extends ValidationResult<SceneSpecIssue> { notices: SceneSpecIssue[] }
|
|
31
|
+
export function validateSceneSpec(input: unknown): SceneSpecValidation;
|
|
32
|
+
export function parseSceneSpec(input: unknown): SceneSpec;
|
|
33
|
+
/** Does not validate first. Call parseSceneSpec or check validateSceneSpec before compiling. */
|
|
34
|
+
export function compileSceneSpec(spec: SceneSpec): {
|
|
35
|
+
spec: SceneSpec; notices: SceneSpecIssue[];
|
|
36
|
+
entityBounds: Record<string, { location: number[]; radius: number }>;
|
|
37
|
+
};
|
|
38
|
+
export function sceneSpecCanonicalText(spec: SceneSpec): string;
|
|
39
|
+
export function sceneSpecDigest(spec: SceneSpec): string;
|
|
40
|
+
export function specHash(spec: SceneSpec): string;
|
|
41
|
+
export function reviewInputsDigest(spec: SceneSpec): string;
|
|
42
|
+
export interface SceneSummary {
|
|
43
|
+
revision: string | null; revisionNumber: number | null; digest: string; schemaVersion: SceneSpec['schemaVersion'];
|
|
44
|
+
project: {
|
|
45
|
+
id: string; title: string; goal: string | null; referenceImages: ReferenceImage[]; reviewSubjectId: string | null;
|
|
46
|
+
fps: number; frameStart: number; frameEnd: number; aspectRatio: string; units: 'metric' | 'imperial'; activeCamera: string | null;
|
|
47
|
+
};
|
|
48
|
+
counts: { assets: number; entities: number; materials: number; lights: number; cameras: number; shots: number; animationTracks: number };
|
|
49
|
+
entities: Array<{ id: string; type: SceneSpecSchema.Entity['type']; shape: SceneSpecSchema.Generator['shape'] | null;
|
|
50
|
+
materialId: string | null; materialBindings?: SceneSpecSchema.MaterialBindings; location: number[]; visible: boolean; locked: boolean; tags: string[] }>;
|
|
51
|
+
materials: Array<{ id: string; shader: Material['shader']; parameters: NonNullable<Material['parameters']>; tangent?: Material['tangent'] }>;
|
|
52
|
+
lights: Array<{ id: string; type: SceneSpecSchema.Light['type']; energy: number; location: number[] }>;
|
|
53
|
+
cameras: Array<{ id: string; role: SceneSpecSchema.Camera['role'] | null; lens: number; location: number[]; rotationEuler: number[];
|
|
54
|
+
targetEntityId: string | null; targetPoint: SceneSpecSchema.Vec3 | null }>;
|
|
55
|
+
shots: Array<{ id: string; cameraId: string; frameRange: [number, number]; description: string | null }>;
|
|
56
|
+
animationTracks: Array<{ id: string; targetKind?: AnimationTrack['targetKind']; targetEntityId: string;
|
|
57
|
+
property: AnimationTrack['property']; keyframeCount: number; frameRange: [number | null, number | null] }>;
|
|
58
|
+
renderProfiles: SceneSpec['renderProfiles'];
|
|
59
|
+
world: { declared: boolean; color: SceneSpecSchema.Vec3; strength: number; environment?: SceneSpecSchema.World['environment'] };
|
|
60
|
+
bounds: { min: number[]; max: number[] }; subjectBounds: { min: number[]; max: number[] } | null;
|
|
61
|
+
}
|
|
62
|
+
export function summarizeSceneSpec(spec: SceneSpec, context?: { revision?: string; revisionNumber?: number; digest?: string }): SceneSummary;
|
|
63
|
+
export const SCENE_OPERATION_NAMES: ReadonlyArray<ScenePatchOperation['op']>;
|
|
64
|
+
export function validateScenePatch(input: unknown): ValidationResult;
|
|
65
|
+
export function parseScenePatch(input: unknown): ScenePatch;
|
|
66
|
+
export interface AppliedOperation { op: ScenePatchOperation['op']; target: string; summary: string; changedPaths: string[] }
|
|
67
|
+
export interface AppliedPatch {
|
|
68
|
+
spec: SceneSpec; operations: AppliedOperation[]; digestBefore: string; digestAfter: string; specHashBefore: string; specHashAfter: string;
|
|
69
|
+
}
|
|
70
|
+
/** Requires compiled input and a validated patch. Revalidate the resulting scene before use. */
|
|
71
|
+
export function applyPatchToSpec(spec: SceneSpec, patch: ScenePatch): AppliedPatch;
|
|
72
|
+
export interface OperationManifest {
|
|
73
|
+
schemaVersion: 'deepblend.operation-manifest/v1'; revision: string; baseRevision: string;
|
|
74
|
+
digestBefore: string; digestAfter: string; specHashBefore: string | null; specHashAfter: string | null;
|
|
75
|
+
sceneChanged: boolean; specChanged: boolean; idempotencyKey: string | undefined;
|
|
76
|
+
actor: string | null; stage: string | null; note: string | null; savedCheckpoint: boolean; renderedPreview: boolean;
|
|
77
|
+
operationCount: number; operations: AppliedOperation[]; notices: SceneSpecIssue[];
|
|
78
|
+
}
|
|
79
|
+
export function buildOperationManifest(input: {
|
|
80
|
+
operations: AppliedOperation[]; notices: SceneSpecIssue[]; request: ScenePatch;
|
|
81
|
+
revision: { revision: string; baseRevision: string; digestBefore: string; digestAfter: string; specHashBefore?: string; specHashAfter?: string };
|
|
82
|
+
}): OperationManifest;
|
|
83
|
+
export const RECIPE_SCHEMA_VERSION: 'deepblend.recipe/v1';
|
|
84
|
+
export const RECIPE_CAPABILITIES: ReadonlyArray<RecipeCapability>;
|
|
85
|
+
export const RECIPE_LIMITS: Readonly<{ sceneBytes: number; previewBytes: number; previewPixels: number }>;
|
|
86
|
+
export class RecipeError extends Error {
|
|
87
|
+
constructor(code: string, message: string, details?: Record<string, unknown>);
|
|
88
|
+
code: string; details: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
export type RecipeValues = Record<string, number | [number, number, number]>;
|
|
91
|
+
export interface RecipeBundle { manifest: RecipeManifest; sceneBytes: string | Uint8Array; previewBytes: string | Uint8Array }
|
|
92
|
+
export interface RecipeOptions { supportedCapabilities?: Iterable<RecipeCapability> }
|
|
93
|
+
export interface RecipeValidation extends ValidationResult { notices: SceneSpecIssue[]; sceneSpec?: SceneSpec }
|
|
94
|
+
export function recipeCapabilitiesForScene(spec: SceneSpec): RecipeCapability[];
|
|
95
|
+
export function validateRecipeManifest(manifest: unknown, options?: RecipeOptions): RecipeValidation;
|
|
96
|
+
export function parseRecipeManifest(manifest: unknown, options?: RecipeOptions): RecipeManifest;
|
|
97
|
+
export function validateRecipePackage(bundle: unknown, options?: RecipeOptions): RecipeValidation;
|
|
98
|
+
export function instantiateRecipe(bundle: RecipeBundle, values?: RecipeValues, options?: RecipeOptions): {
|
|
99
|
+
spec: SceneSpec; values: RecipeValues; notices: SceneSpecIssue[];
|
|
100
|
+
recipe: { id: string; version: string; manifestSha256: string; inputSha256: string; previewSha256: string; valuesSha256: string };
|
|
101
|
+
};
|
package/lib/sdk.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Stable Node.js authoring entry point. Runtime ports are described in sdk.d.ts. */
|
|
2
|
+
import { validateSceneSpec } from './scene-spec.js'
|
|
3
|
+
import { validateScenePatch } from './scene-patch.js'
|
|
4
|
+
import { BlenderError, BlenderErrorCode } from './index.js'
|
|
5
|
+
import { validateRecipeManifest, RecipeError } from './recipe.js'
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
BLENDER_PROTOCOL_VERSION, SCENE_SCHEMA_VERSION, SCENE_PATCH_VERSION, HOST_API_VERSION,
|
|
9
|
+
BlenderError, BlenderErrorCode, BlenderWarningCode, isBlenderError,
|
|
10
|
+
} from './index.js'
|
|
11
|
+
export { canonicalStringify, canonicalPretty, sha256, sha256Canonical } from './canonical.js'
|
|
12
|
+
export {
|
|
13
|
+
validateSceneSpec, compileSceneSpec, sceneSpecCanonicalText, sceneSpecDigest, specHash,
|
|
14
|
+
reviewInputsDigest, summarizeSceneSpec,
|
|
15
|
+
} from './scene-spec.js'
|
|
16
|
+
export { SCENE_OPERATION_NAMES, validateScenePatch, applyPatchToSpec, buildOperationManifest } from './scene-patch.js'
|
|
17
|
+
export {
|
|
18
|
+
RECIPE_SCHEMA_VERSION, RECIPE_CAPABILITIES, RECIPE_LIMITS, RecipeError,
|
|
19
|
+
recipeCapabilitiesForScene, validateRecipeManifest, validateRecipePackage, instantiateRecipe,
|
|
20
|
+
} from './recipe.js'
|
|
21
|
+
|
|
22
|
+
/** Validate untrusted JSON and return a separate document; never assert a type without checking it. */
|
|
23
|
+
export function parseSceneSpec(input) {
|
|
24
|
+
const checked = validateSceneSpec(input)
|
|
25
|
+
if (!checked.ok) throw new BlenderError(BlenderErrorCode.SCENE_SPEC_INVALID, checked.summary, { detail: checked.errors })
|
|
26
|
+
return structuredClone(input)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Structural patch validation; entity references and final scene validity need a scene. */
|
|
30
|
+
export function parseScenePatch(input) {
|
|
31
|
+
const checked = validateScenePatch(input)
|
|
32
|
+
if (!checked.ok) throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, checked.summary, { detail: checked.errors })
|
|
33
|
+
return structuredClone(input)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Check a recipe manifest's declarations; package bytes are checked by validateRecipePackage. */
|
|
37
|
+
export function parseRecipeManifest(input, options = {}) {
|
|
38
|
+
const checked = validateRecipeManifest(input, options)
|
|
39
|
+
if (!checked.ok) throw new RecipeError(checked.errors[0].code, checked.summary, { errors: checked.errors })
|
|
40
|
+
return structuredClone(input)
|
|
41
|
+
}
|
package/lib/ui-api.js
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
|
|
21
21
|
import { RENDER_JOB_TERMINAL_STATUSES } from './render-job.js'
|
|
22
22
|
import { redactHome } from './redact.js'
|
|
23
|
+
import { resolveSubject } from './visual-composition.js'
|
|
24
|
+
import { resolveHandledCup } from './handled-cup.js'
|
|
23
25
|
|
|
24
26
|
// ---------------------------------------------------------------------------
|
|
25
27
|
// Routes
|
|
@@ -57,6 +59,8 @@ export const UI_ROUTES = Object.freeze([
|
|
|
57
59
|
// (`contract/workbench-page.test.mjs` is what holds that).
|
|
58
60
|
{ id: 'workbench.page', method: 'GET', path: '/deepblend/workbench', write: false, summary: 'The standalone fullscreen workbench document.' },
|
|
59
61
|
{ id: 'state', method: 'GET', path: '/deepblend/state', write: false, summary: 'Everything the panel needs to render itself from scratch.' },
|
|
62
|
+
{ id: 'recipes.list', method: 'GET', path: '/deepblend/recipes', write: false, summary: 'Validated local recipes and their editable parameters.' },
|
|
63
|
+
{ id: 'recipes.preview', method: 'GET', path: '/deepblend/recipes/:recipeId/:version/preview', write: false, summary: 'A hash-pinned recipe preview.' },
|
|
60
64
|
{ id: 'projects.list', method: 'GET', path: '/deepblend/projects', write: false, summary: 'Every project in the store.' },
|
|
61
65
|
{ id: 'projects.create', method: 'POST', path: '/deepblend/projects', write: true, summary: 'Create a project (title, optional seed scene).' },
|
|
62
66
|
{ id: 'project.overview', method: 'GET', path: '/deepblend/projects/:projectId', write: false, summary: 'One project: summary, revisions, current digest.' },
|
|
@@ -67,8 +71,19 @@ export const UI_ROUTES = Object.freeze([
|
|
|
67
71
|
{ id: 'project.qa', method: 'GET', path: '/deepblend/projects/:projectId/qa', write: false, summary: 'The QA view of a revision (?revision=).' },
|
|
68
72
|
{ id: 'project.previews', method: 'GET', path: '/deepblend/projects/:projectId/previews', write: false, summary: 'Preview sets per revision, for Preview Compare.' },
|
|
69
73
|
{ id: 'project.preview', method: 'POST', path: '/deepblend/projects/:projectId/preview', write: true, summary: 'Render the low-cost multi-view preview (and its contact sheet).' },
|
|
74
|
+
{ id: 'project.referenceImage.upload', method: 'POST', path: '/deepblend/projects/:projectId/reference-images', write: true, summary: 'Upload a PNG or JPEG as a project asset; saving the brief creates its revision reference.' },
|
|
75
|
+
{ id: 'project.assets.list', method: 'GET', path: '/deepblend/projects/:projectId/assets', write: false, summary: 'List staged assets and the selected revision references, with current Host limits.' },
|
|
76
|
+
{ id: 'project.assets.upload', method: 'POST', path: '/deepblend/projects/:projectId/assets', write: true, summary: 'Upload local asset bytes into project storage without creating a scene revision.' },
|
|
77
|
+
{ id: 'project.assetUploads.create', method: 'POST', path: '/deepblend/projects/:projectId/asset-uploads', write: true, summary: 'Create a bounded model resource upload from a relative-path manifest.' },
|
|
78
|
+
{ id: 'project.assetUploads.get', method: 'GET', path: '/deepblend/projects/:projectId/asset-uploads/:uploadId', write: false, summary: 'Read received bytes or recover a completed asset upload receipt.' },
|
|
79
|
+
{ id: 'project.assetUploads.file', method: 'POST', path: '/deepblend/projects/:projectId/asset-uploads/:uploadId/files/:fileId', write: true, summary: 'Receive the original bytes of one declared upload member.' },
|
|
80
|
+
{ id: 'project.assetUploads.complete', method: 'POST', path: '/deepblend/projects/:projectId/asset-uploads/:uploadId/complete', write: true, summary: 'Register a complete model resource bundle without changing the scene revision.' },
|
|
81
|
+
{ id: 'project.assetUploads.cancel', method: 'POST', path: '/deepblend/projects/:projectId/asset-uploads/:uploadId/cancel', write: true, summary: 'Cancel private upload work, or return its already committed asset receipt.' },
|
|
82
|
+
{ id: 'project.assets.preview', method: 'POST', path: '/deepblend/projects/:projectId/assets/:assetId/preview', write: true, summary: 'Inspect and preview one hash-pinned asset in isolation without changing the scene.' },
|
|
83
|
+
{ id: 'project.review', method: 'POST', path: '/deepblend/projects/:projectId/review', write: true, summary: 'Explicitly render and consult the visual reviewer for one revision.' },
|
|
84
|
+
{ id: 'project.autofix', method: 'POST', path: '/deepblend/projects/:projectId/autofix', write: true, summary: 'Explicitly run a bounded visual correction loop for one revision.' },
|
|
70
85
|
{ id: 'project.patch', method: 'POST', path: '/deepblend/projects/:projectId/patch', write: true, summary: 'Apply a ScenePatch as one atomic revision.' },
|
|
71
|
-
{ id: 'project.restore', method: 'POST', path: '/deepblend/projects/:projectId/restore', write: true, summary: '
|
|
86
|
+
{ id: 'project.restore', method: 'POST', path: '/deepblend/projects/:projectId/restore', write: true, summary: 'Move the current revision pointer to a saved revision, preserving history without creating a new revision.' },
|
|
72
87
|
{ id: 'project.jobs', method: 'GET', path: '/deepblend/projects/:projectId/jobs', write: false, summary: 'Render/export jobs of a project.' },
|
|
73
88
|
{ id: 'project.job', method: 'GET', path: '/deepblend/projects/:projectId/jobs/:jobId', write: false, summary: 'One job with its live progress.' },
|
|
74
89
|
{ id: 'project.job.cancel', method: 'POST', path: '/deepblend/projects/:projectId/jobs/:jobId/cancel', write: true, summary: 'Cancel a running job and verify the process is gone.' },
|
|
@@ -304,7 +319,7 @@ export function buildRevisionDiff(from, to, context = {}) {
|
|
|
304
319
|
* human looking for a collection that does not exist in the outliner.
|
|
305
320
|
*
|
|
306
321
|
* @param {object} spec - a SceneSpec document
|
|
307
|
-
* @param {{ revision?: string, digest?: string, compiled?: object|null }} [context]
|
|
322
|
+
* @param {{ revision?: string, digest?: string, compiled?: object|null, assetParts?: object[] }} [context]
|
|
308
323
|
* @returns {Record<string, unknown>}
|
|
309
324
|
*/
|
|
310
325
|
export function buildSceneTree(spec, context = {}) {
|
|
@@ -315,6 +330,9 @@ export function buildSceneTree(spec, context = {}) {
|
|
|
315
330
|
const shots = spec?.shots ?? []
|
|
316
331
|
const tracks = spec?.animationTracks ?? []
|
|
317
332
|
const assets = spec?.assets ?? []
|
|
333
|
+
// Only the compiled revision knows imported part selectors and original slots.
|
|
334
|
+
// A spec alone cannot supply an inventory, even when it declares bindings.
|
|
335
|
+
const assetParts = context.assetParts ?? []
|
|
318
336
|
|
|
319
337
|
/** @type {Record<string, unknown[]>} */
|
|
320
338
|
const nodes = {
|
|
@@ -322,9 +340,15 @@ export function buildSceneTree(spec, context = {}) {
|
|
|
322
340
|
id: entity.id,
|
|
323
341
|
kind: entity.type,
|
|
324
342
|
shape: entity.generator?.shape ?? null,
|
|
343
|
+
generator: clone(entity.generator?.shape === 'handled_cup'
|
|
344
|
+
? resolveHandledCup(entity.generator) : entity.generator ?? null),
|
|
345
|
+
modifiers: clone(entity.modifiers ?? []),
|
|
325
346
|
assetId: entity.assetId ?? null,
|
|
326
347
|
materialId: entity.materialId ?? null,
|
|
327
|
-
|
|
348
|
+
materialBindings: clone(entity.materialBindings ?? []),
|
|
349
|
+
assetParts: clone(assetParts.filter(part => part.entityId === entity.id)),
|
|
350
|
+
visible: entity.visible ?? true,
|
|
351
|
+
tags: clone(entity.tags ?? []),
|
|
328
352
|
locked: entity.locked === true,
|
|
329
353
|
transform: clone(entity.transform ?? null),
|
|
330
354
|
detail: entity.detail ?? null,
|
|
@@ -333,17 +357,20 @@ export function buildSceneTree(spec, context = {}) {
|
|
|
333
357
|
id: material.id,
|
|
334
358
|
shader: material.shader ?? null,
|
|
335
359
|
parameters: clone(material.parameters ?? null),
|
|
360
|
+
definition: clone(material),
|
|
336
361
|
})),
|
|
337
362
|
lights: lights.map(light => ({
|
|
338
363
|
id: light.id,
|
|
339
364
|
type: light.type ?? null,
|
|
340
365
|
energy: light.energy ?? null,
|
|
366
|
+
definition: clone(light),
|
|
341
367
|
transform: clone(light.transform ?? null),
|
|
342
368
|
})),
|
|
343
369
|
cameras: cameras.map(camera => ({
|
|
344
370
|
id: camera.id,
|
|
345
371
|
role: camera.role ?? null,
|
|
346
372
|
lens: camera.lens ?? null,
|
|
373
|
+
definition: clone(camera),
|
|
347
374
|
targetEntityId: camera.targetEntityId ?? null,
|
|
348
375
|
isActive: (spec?.project?.activeCamera ?? null) === camera.id,
|
|
349
376
|
})),
|
|
@@ -367,12 +394,17 @@ export function buildSceneTree(spec, context = {}) {
|
|
|
367
394
|
project: {
|
|
368
395
|
id: spec?.project?.id ?? null,
|
|
369
396
|
title: spec?.project?.title ?? null,
|
|
397
|
+
goal: spec?.project?.goal ?? '',
|
|
398
|
+
referenceImages: clone(spec?.project?.referenceImages ?? []),
|
|
399
|
+
reviewSubjectId: spec?.project?.reviewSubjectId ?? null,
|
|
370
400
|
fps: spec?.project?.fps ?? null,
|
|
371
401
|
frameStart: spec?.project?.frameStart ?? null,
|
|
372
402
|
frameEnd: spec?.project?.frameEnd ?? null,
|
|
373
403
|
activeCamera: spec?.project?.activeCamera ?? null,
|
|
374
404
|
},
|
|
375
405
|
world: clone(spec?.world ?? null),
|
|
406
|
+
reviewSubject: clone(resolveSubject(spec)),
|
|
407
|
+
assetParts: clone(assetParts),
|
|
376
408
|
counts: {
|
|
377
409
|
entities: nodes.entities.length,
|
|
378
410
|
materials: nodes.materials.length,
|
|
@@ -440,8 +472,13 @@ export function buildQaView({ projectId, revision, validation = null, review = n
|
|
|
440
472
|
available: review !== null,
|
|
441
473
|
score: review?.score ?? null,
|
|
442
474
|
pass: review?.pass ?? null,
|
|
475
|
+
artistic: clone(review?.referenceInputError ? { status: 'unassessable', dimensions: {} } : review?.artistic ?? { status: 'unassessable', dimensions: {} }),
|
|
476
|
+
referenceImages: clone(review?.referenceImages ?? []),
|
|
477
|
+
reviewInputsDigest: review?.reviewInputsDigest ?? null,
|
|
478
|
+
referenceInputError: clone(review?.referenceInputError ?? null),
|
|
443
479
|
iteration: review?.iteration ?? null,
|
|
444
480
|
subjectId: review?.subjectId ?? null,
|
|
481
|
+
subject: clone(review?.subject ?? null),
|
|
445
482
|
viewCount: review?.viewCount ?? null,
|
|
446
483
|
sheet: clone(review?.sheet ?? null),
|
|
447
484
|
measuredIssueCount: measured.length,
|
|
@@ -539,6 +576,33 @@ export function buildJobView(job, options) {
|
|
|
539
576
|
deliverable: job.status === 'completed' && job.delivery !== null,
|
|
540
577
|
approval: buildApprovalView({ record: { expectedFrames: expected, warnings: job.warnings ?? [] }, threshold: options?.threshold ?? Number.POSITIVE_INFINITY }),
|
|
541
578
|
detail: describeJobForHuman(job),
|
|
579
|
+
provenanceDisplay: describeFrameProvenance(job.provenance),
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Bounded display facts from the same source summary; the client localizes labels. */
|
|
584
|
+
function describeFrameProvenance(provenance) {
|
|
585
|
+
if (!provenance) return null
|
|
586
|
+
const groups = provenance.groups ?? []
|
|
587
|
+
const framesLabel = frames => {
|
|
588
|
+
const ranges = []
|
|
589
|
+
for (let i = 0; i < frames.length;) {
|
|
590
|
+
const start = frames[i]; let end = start
|
|
591
|
+
while (frames[++i] === end + 1) end = frames[i]
|
|
592
|
+
ranges.push(start === end ? String(start) : `${start}–${end}`)
|
|
593
|
+
}
|
|
594
|
+
return ranges.slice(0, 8).join(', ') + (ranges.length > 8 ? '…' : '')
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
mixed: provenance.configuration === 'mixed',
|
|
598
|
+
uncheckedCount: provenance.uncheckedFrameCount ?? 0,
|
|
599
|
+
groups: groups.slice(0, 8).map(group => ({
|
|
600
|
+
engine: group.renderConfig.engine, resolution: group.renderConfig.resolution?.join('×'),
|
|
601
|
+
samples: group.renderConfig.samples, frames: framesLabel(group.frames), frameCount: group.frames.length,
|
|
602
|
+
})),
|
|
603
|
+
moreGroups: Math.max(0, groups.length - 8),
|
|
604
|
+
unknownCount: provenance.unknownFrameCount ?? provenance.unknownFrames?.length ?? 0,
|
|
605
|
+
missingCount: provenance.missingFrameCount ?? provenance.missingFrames?.length ?? 0,
|
|
542
606
|
}
|
|
543
607
|
}
|
|
544
608
|
|
|
@@ -849,6 +913,7 @@ export function parseToolCallTarget(argsRaw) {
|
|
|
849
913
|
*/
|
|
850
914
|
export const UI_TOOL_CARD_KEYS = Object.freeze([
|
|
851
915
|
'blender_capabilities',
|
|
916
|
+
'blender_recipe_list',
|
|
852
917
|
'blender_project_create',
|
|
853
918
|
'blender_project_get',
|
|
854
919
|
'blender_scene_get',
|
|
@@ -282,8 +282,8 @@ export function buildViewPlan(input) {
|
|
|
282
282
|
*
|
|
283
283
|
* The subject is always tracked. The others matter because occlusion is only
|
|
284
284
|
* visible as a comparison: without a hiding object's own numbers, "the subject is
|
|
285
|
-
* 40% hidden" says nothing about what to move. They are ranked by bounding
|
|
286
|
-
*
|
|
285
|
+
* 40% hidden" says nothing about what to move. They are ranked by bounding
|
|
286
|
+
* radius, which is a proxy for "big enough to hide something" and costs nothing to
|
|
287
287
|
* compute — the alternative, tracking everything, would multiply the render count
|
|
288
288
|
* of every view.
|
|
289
289
|
*
|
|
@@ -293,7 +293,7 @@ export function buildViewPlan(input) {
|
|
|
293
293
|
*/
|
|
294
294
|
export function trackedObjects(spec, subjectId) {
|
|
295
295
|
const entities = Array.isArray(spec?.entities) ? spec.entities : []
|
|
296
|
-
// Every declared PART is tracked regardless of size. The
|
|
296
|
+
// Every declared PART is tracked regardless of size. The radius ranking below is a
|
|
297
297
|
// guess about what might hide the subject; a declaration is not a guess, and a small
|
|
298
298
|
// component that is missing is exactly the defect that a size ranking would skip.
|
|
299
299
|
const declared = subjectParts(spec)
|
|
@@ -349,21 +349,35 @@ export function subjectParts(spec) {
|
|
|
349
349
|
* The rule now has a stated order of evidence, and every step of it is a fact about
|
|
350
350
|
* the file rather than a fact about the alphabet:
|
|
351
351
|
*
|
|
352
|
-
* 1.
|
|
352
|
+
* 1. the explicitly saved project.reviewSubjectId, even if it is unavailable;
|
|
353
|
+
* 2. otherwise what the ACTIVE camera aims at — a camera exists to frame something, and
|
|
353
354
|
* `targetEntityId` is the author saying what;
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
355
|
+
* 3. entities tagged `hero-product`, then entities tagged `subject`;
|
|
356
|
+
* 4. among several in the chosen tag group, the largest bounding radius,
|
|
357
|
+
* ties broken by id;
|
|
358
|
+
* 5. with neither tag, the largest visible non-environment entity.
|
|
357
359
|
*
|
|
358
|
-
* Step
|
|
360
|
+
* Step 4 is deliberately "largest" rather than "first": a scene that tags four things
|
|
359
361
|
* hero is ambiguous, and the biggest of them is the only defensible reading.
|
|
360
362
|
*
|
|
361
363
|
* @param {object} spec
|
|
362
|
-
* @returns {{ id: string|null, source: string, candidates: string[] }}
|
|
364
|
+
* @returns {{ id: string|null, source: string, candidates: string[], mode: 'explicit'|'automatic', available: boolean, reason: string|null }}
|
|
363
365
|
*/
|
|
364
366
|
export function resolveSubject(spec) {
|
|
365
367
|
const entities = Array.isArray(spec?.entities) ? spec.entities : []
|
|
366
368
|
const visible = entities.filter(entity => entity.visible !== false)
|
|
369
|
+
const result = (id, source, candidates, mode = 'automatic') => {
|
|
370
|
+
const entity = entities.find(entry => entry.id === id)
|
|
371
|
+
const reason = id === null ? 'No review subject is available.'
|
|
372
|
+
: !entity ? `Review subject "${id}" does not exist in this revision.`
|
|
373
|
+
: entity.type === 'empty' ? `Review subject "${id}" is an empty and cannot be measured.`
|
|
374
|
+
: entity.visible === false ? `Review subject "${id}" is hidden in this revision.` : null
|
|
375
|
+
return { id, source, candidates, mode, available: reason === null, reason }
|
|
376
|
+
}
|
|
377
|
+
if (spec?.project?.reviewSubjectId !== undefined) {
|
|
378
|
+
const id = spec.project.reviewSubjectId
|
|
379
|
+
return result(id, 'it is the explicitly saved review subject', [id], 'explicit')
|
|
380
|
+
}
|
|
367
381
|
|
|
368
382
|
// 1. What the shot is actually framed on.
|
|
369
383
|
const cameras = Array.isArray(spec?.cameras) ? spec.cameras : []
|
|
@@ -372,33 +386,33 @@ export function resolveSubject(spec) {
|
|
|
372
386
|
if (active?.targetEntityId !== undefined) {
|
|
373
387
|
const aimed = visible.find(entity => entity.id === active.targetEntityId)
|
|
374
388
|
if (aimed !== undefined) {
|
|
375
|
-
return
|
|
389
|
+
return result(aimed.id, `the active camera "${active.id}" aims at it`, [aimed.id])
|
|
376
390
|
}
|
|
377
391
|
}
|
|
378
392
|
|
|
379
|
-
const
|
|
393
|
+
const hero = visible.filter(entity => (entity.tags ?? []).includes('hero-product'))
|
|
394
|
+
const tag = hero.length > 0 ? 'hero-product' : 'subject'
|
|
395
|
+
const tagged = hero.length > 0 ? hero : visible.filter(entity => (entity.tags ?? []).includes('subject'))
|
|
380
396
|
if (tagged.length === 1) {
|
|
381
|
-
return
|
|
397
|
+
return result(tagged[0].id, `it is the only entity tagged ${tag}`, [tagged[0].id])
|
|
382
398
|
}
|
|
383
399
|
|
|
384
|
-
const pool = tagged.length > 1 ? tagged : visible.filter(entity =>
|
|
385
|
-
|
|
400
|
+
const pool = tagged.length > 1 ? tagged : visible.filter(entity =>
|
|
401
|
+
!(entity.tags ?? []).includes('environment') && entityExtent(entity) > 0)
|
|
402
|
+
if (pool.length === 0) return result(null, 'this scene has no visible non-environment subject candidate', [])
|
|
386
403
|
|
|
387
|
-
// Descending
|
|
404
|
+
// Descending bounding radius, and the id only breaks exact ties — so two entities of equal
|
|
388
405
|
// size resolve the same way on every machine and after every re-sort.
|
|
389
406
|
const ranked = [...pool].sort((left, right) => {
|
|
390
|
-
const
|
|
391
|
-
if (
|
|
407
|
+
const byExtent = entityExtent(right) - entityExtent(left)
|
|
408
|
+
if (byExtent !== 0) return byExtent
|
|
392
409
|
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0
|
|
393
410
|
})
|
|
394
411
|
const candidates = ranked.map(entity => entity.id)
|
|
395
|
-
return
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
: 'it is the largest visible entity and nothing is tagged hero-product',
|
|
400
|
-
candidates,
|
|
401
|
-
}
|
|
412
|
+
return result(ranked[0].id, tagged.length > 1
|
|
413
|
+
? `${tagged.length} entities are tagged ${tag}, so the largest was taken; the ambiguity is reported`
|
|
414
|
+
: 'it is the largest visible non-environment entity and nothing is tagged hero-product or subject',
|
|
415
|
+
candidates)
|
|
402
416
|
}
|
|
403
417
|
|
|
404
418
|
/**
|
package/lib/visual-issue.js
CHANGED
|
@@ -87,8 +87,8 @@ export const VISUAL_SEVERITIES = Object.freeze(['minor', 'major', 'critical'])
|
|
|
87
87
|
const SEVERITY_POINTS = Object.freeze({ minor: 4, major: 10, critical: 18 })
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
90
|
+
* Technical passing threshold. Artistic quality is assessed separately; reaching
|
|
91
|
+
* this value does not establish that the work meets its visual goal.
|
|
92
92
|
*/
|
|
93
93
|
export const VISUAL_PASS_SCORE = 90
|
|
94
94
|
|
|
@@ -216,7 +216,7 @@ export function scoreView(view, options = {}) {
|
|
|
216
216
|
if (exposure !== null) add(exposure)
|
|
217
217
|
|
|
218
218
|
for (const object of subjects) {
|
|
219
|
-
const composition = assessComposition(object)
|
|
219
|
+
const composition = assessComposition(object, options.subjectId)
|
|
220
220
|
if (composition !== null) add(composition)
|
|
221
221
|
const occlusion = assessOcclusion(object)
|
|
222
222
|
if (occlusion !== null) add(occlusion)
|
|
@@ -412,9 +412,26 @@ function assessExposure(metrics, viewId, subject = null) {
|
|
|
412
412
|
* Where the subject sits in the frame, and how big it is.
|
|
413
413
|
*
|
|
414
414
|
* @param {object} object - one object entry from the view's measurements.
|
|
415
|
+
* @param {string|null} subjectId - the selected primary subject, when supplied.
|
|
415
416
|
* @returns {VisualIssue|null}
|
|
416
417
|
*/
|
|
417
|
-
function assessComposition(object) {
|
|
418
|
+
function assessComposition(object, subjectId) {
|
|
419
|
+
// No isolated silhouette is different from a silhouette hidden by another object.
|
|
420
|
+
// Require the provider's complete zero/out-of-frame reading rather than treating
|
|
421
|
+
// omitted legacy measurements or a tiny positive silhouette as proof of absence.
|
|
422
|
+
// A component may legitimately face away in this view; its presence is assessed
|
|
423
|
+
// across views below, unless the user explicitly selected that component itself.
|
|
424
|
+
if (object.silhouettePixels === 0 && object.visiblePixels === 0 && object.inFrame === false &&
|
|
425
|
+
(!isSubjectPart(object) || object.id === subjectId)) {
|
|
426
|
+
return buildIssue({
|
|
427
|
+
category: 'composition', code: 'SUBJECT_OUT_OF_FRAME', severity: 'critical',
|
|
428
|
+
viewId: object.viewId, objectId: object.id,
|
|
429
|
+
evidence: `"${object.id}" has no isolated silhouette in this view: silhouettePixels=0, ` +
|
|
430
|
+
'visiblePixels=0 and inFrame=false. The subject is absent from the measured frame, so its framing cannot pass.',
|
|
431
|
+
measurements: { silhouettePixels: 0, visiblePixels: 0, inFrame: 'false' },
|
|
432
|
+
buckets: ['absent'],
|
|
433
|
+
})
|
|
434
|
+
}
|
|
418
435
|
if (object.visiblePixels === 0) return null // fully hidden is an occlusion finding
|
|
419
436
|
const centroid = object.centroid
|
|
420
437
|
if (!Array.isArray(centroid) || centroid.length !== 2) return null
|
|
@@ -642,7 +659,7 @@ export function validateFindings(raw, context) {
|
|
|
642
659
|
continue
|
|
643
660
|
}
|
|
644
661
|
const category = entry.category
|
|
645
|
-
if (!VISUAL_ISSUE_CATEGORIES.includes(category)) {
|
|
662
|
+
if (![...VISUAL_ISSUE_CATEGORIES, 'geometry', 'materials', 'lighting', 'goalFit'].includes(category)) {
|
|
646
663
|
rejected.push({ finding: entry, reason: `unknown category "${String(category)}"` })
|
|
647
664
|
continue
|
|
648
665
|
}
|
|
@@ -687,13 +704,14 @@ export function validateFindings(raw, context) {
|
|
|
687
704
|
* Three ways to stop, and each one exists because the alternative is a loop that
|
|
688
705
|
* spends money to make things worse:
|
|
689
706
|
*
|
|
690
|
-
* - the score
|
|
707
|
+
* - both the technical score and the separate artistic review pass;
|
|
691
708
|
* - the iteration cap is reached (SPEC §12.3 "达到最大迭代数后进入人工审查");
|
|
692
709
|
* - the same fingerprint has repeated `stopOnRepeatedIssueCount` times, which is
|
|
693
710
|
* the structural form of "同一问题两轮未改善则停止自动迭代".
|
|
694
711
|
*
|
|
695
712
|
* @param {object} input
|
|
696
713
|
* @param {number} input.score
|
|
714
|
+
* @param {boolean} input.artisticPassed
|
|
697
715
|
* @param {number} input.iteration
|
|
698
716
|
* @param {number} input.maxIterations
|
|
699
717
|
* @param {Map<string, number>} input.fingerprints - fingerprint -> consecutive count
|
|
@@ -701,8 +719,8 @@ export function validateFindings(raw, context) {
|
|
|
701
719
|
* @returns {{ stop: boolean, reason: string|null }}
|
|
702
720
|
*/
|
|
703
721
|
export function shouldHandOver(input) {
|
|
704
|
-
if (input.score >= VISUAL_PASS_SCORE) {
|
|
705
|
-
return { stop: true, reason: '
|
|
722
|
+
if (input.score >= VISUAL_PASS_SCORE && input.artisticPassed === true) {
|
|
723
|
+
return { stop: true, reason: 'PASSING_REVIEW' }
|
|
706
724
|
}
|
|
707
725
|
if (input.iteration >= input.maxIterations) {
|
|
708
726
|
return { stop: true, reason: 'MAX_ITERATIONS' }
|