@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/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: 'Restore an earlier revision as a new revision.' },
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
- tags: entity.tags ?? [],
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-box
286
- * volume, which is a proxy for "big enough to hide something" and costs nothing to
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 volume ranking below is a
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. what the ACTIVE camera aims at — a camera exists to frame something, and
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
- * 2. a `hero-product` tag when exactly one entity carries it;
355
- * 3. among several, the largest by volume, ties broken by id;
356
- * 4. with no tag at all, the largest visible entity, ties broken by id.
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 3 is deliberately "largest" rather than "first": a scene that tags four things
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 { id: aimed.id, source: `the active camera "${active.id}" aims at it`, candidates: [aimed.id] }
389
+ return result(aimed.id, `the active camera "${active.id}" aims at it`, [aimed.id])
376
390
  }
377
391
  }
378
392
 
379
- const tagged = visible.filter(entity => Array.isArray(entity.tags) && entity.tags.includes('hero-product'))
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 { id: tagged[0].id, source: 'it is the only entity tagged hero-product', candidates: [tagged[0].id] }
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 => entityExtent(entity) > 0)
385
- if (pool.length === 0) return { id: null, source: 'this scene has no entity to be the subject of', candidates: [] }
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 volume, and the id only breaks exact ties — so two entities of equal
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 byVolume = entityExtent(right) - entityExtent(left)
391
- if (byVolume !== 0) return byVolume
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
- id: ranked[0].id,
397
- source: tagged.length > 1
398
- ? `${tagged.length} entities are tagged hero-product, so the largest was taken; the ambiguity is reported`
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
  /**
@@ -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
- * A perfect score. Below this the automated loop has something to fix; at or above
91
- * it the loop hands over instead of pretending there is work (see `shouldHandOver`).
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 is already passing — there is no measured problem to improve;
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: 'PASSING_SCORE' }
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' }