@deepblend/dsh-blender-contracts 0.2.20 → 0.3.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/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/visual-loop.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* reviewer() ask the vision model what it sees, and what it would change
|
|
13
13
|
*
|
|
14
14
|
* That boundary is not decoration. It is what lets the loop's SEMANTICS — the
|
|
15
|
-
* iteration cap, the repeated-issue stop, "only adopt
|
|
16
|
-
*
|
|
15
|
+
* iteration cap, the repeated-issue stop, "only adopt evidenced improvements without
|
|
16
|
+
* regression", and the handover package — be tested deterministically against a stub,
|
|
17
17
|
* while "the model really sees the image" is tested once, live. Mixing those two
|
|
18
18
|
* claims into one untestable blob is how a project ends up asserting only one of
|
|
19
19
|
* them (decision D34).
|
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
updateFingerprintCounters,
|
|
47
47
|
validateFindings,
|
|
48
48
|
} from './visual-issue.js'
|
|
49
|
+
import { validateArtisticReview, artisticRegressed } from './artistic-review.js'
|
|
50
|
+
import { reviewInputsDigest } from './scene-spec.js'
|
|
49
51
|
|
|
50
52
|
/**
|
|
51
53
|
* @typedef {object} VisualReviewRound
|
|
@@ -71,13 +73,15 @@ import {
|
|
|
71
73
|
* @param {string} input.projectId
|
|
72
74
|
* @param {string} input.revision - the starting (current) revision.
|
|
73
75
|
* @param {(request: object) => Promise<object>} input.review
|
|
74
|
-
* `({ projectId, revision, iteration, signal }) => VisualReview` — measures and scores.
|
|
76
|
+
* `({ projectId, revision, iteration, subjectId?, signal }) => VisualReview` — measures and scores.
|
|
77
|
+
* After the baseline, an own subjectId (including null) fixes what is measured.
|
|
75
78
|
* @param {(request: object) => Promise<object>} input.patch
|
|
76
79
|
* `({ projectId, baseRevision, operations, note, actor, stage, idempotencyKey, saveCheckpoint, signal }) => RevisionSummary`
|
|
77
80
|
* @param {(request: object) => Promise<object>} input.restore
|
|
78
|
-
* `({ projectId, revision, reason }) => unknown`
|
|
81
|
+
* `({ projectId, revision, expectedCurrentRevision, reason }) => unknown` — rejects a concurrent current-revision change.
|
|
79
82
|
* @param {(request: object) => Promise<{ findings: object[], operations: object[], note: string|null, detail: object|null }>} input.reviewer
|
|
80
|
-
* The vision port. Receives `{ review, round, previousRounds, signal }`.
|
|
83
|
+
* The vision port. Receives `{ review, baselineReview?, round, previousRounds, signal }`.
|
|
84
|
+
* Returns artistic dimension assessments and, for a candidate, a comparison judgment.
|
|
81
85
|
* @param {(line: string) => void} [input.log]
|
|
82
86
|
* @param {number} [input.maxIterations]
|
|
83
87
|
* @param {number} [input.stopOnRepeatedIssueCount]
|
|
@@ -87,7 +91,7 @@ import {
|
|
|
87
91
|
* a handover package whenever the loop stopped short of passing.
|
|
88
92
|
*/
|
|
89
93
|
export async function runVisualLoop(input) {
|
|
90
|
-
const maxIterations = positiveInteger(input.maxIterations, 5)
|
|
94
|
+
const maxIterations = input.maxIterations === 0 ? 0 : positiveInteger(input.maxIterations, 5)
|
|
91
95
|
const stopOnRepeatedIssueCount = positiveInteger(input.stopOnRepeatedIssueCount, 2)
|
|
92
96
|
const minConfidence = numberInRange(input.minConfidenceForAutoFix, 0.8)
|
|
93
97
|
const log = typeof input.log === 'function' ? input.log : () => {}
|
|
@@ -109,6 +113,16 @@ export async function runVisualLoop(input) {
|
|
|
109
113
|
signal: input.signal,
|
|
110
114
|
})
|
|
111
115
|
let score = currentReview.score
|
|
116
|
+
const authoredInputsDigest = checkedReviewInputsDigest(currentReview)
|
|
117
|
+
// Legacy measurement-only ports omitted both subject identity and scene context.
|
|
118
|
+
// Real Host reviews carry these facts, so later rounds must prove the same subject.
|
|
119
|
+
const subjectFixed = hasSubjectEvidence(currentReview)
|
|
120
|
+
const fixedSubjectId = currentReview.subjectId ?? null
|
|
121
|
+
const subjectEvidence = { sceneContext: currentReview.sceneContext != null, subject: currentReview.subject != null }
|
|
122
|
+
const baselineSubjectError = subjectFixed ? reviewedSubjectIssue(currentReview, fixedSubjectId) : null
|
|
123
|
+
let artistic = validateArtisticReview(null, new Set(), minConfidence, {
|
|
124
|
+
subject: baselineSubjectError ? { available: false, reason: baselineSubjectError.message } : currentReview.subject,
|
|
125
|
+
})
|
|
112
126
|
// The baseline is an OBSERVATION, not an attempt, so it seeds the counters at zero
|
|
113
127
|
// rather than one. Counting it would make "the same issue recurred twice" true
|
|
114
128
|
// after a single failed fix — and the loop would then stop before trying the second
|
|
@@ -124,15 +138,21 @@ export async function runVisualLoop(input) {
|
|
|
124
138
|
reported: [],
|
|
125
139
|
rejectedFindings: [],
|
|
126
140
|
outcome: 'baseline',
|
|
127
|
-
reason: null,
|
|
141
|
+
reason: baselineSubjectError?.message ?? null,
|
|
142
|
+
...(subjectFixed ? { fixedSubjectId } : {}),
|
|
128
143
|
newRevision: null,
|
|
129
144
|
appliedPatch: null,
|
|
130
145
|
})
|
|
131
146
|
log(`baseline ${currentRevision}: score ${score}, ${currentReview.issues.length} measured issue(s)`)
|
|
132
147
|
|
|
133
148
|
while (true) {
|
|
149
|
+
if (baselineSubjectError) {
|
|
150
|
+
handedOver = { reason: baselineSubjectError.reviewSubjectReason, iteration, score, revision: currentRevision }
|
|
151
|
+
break
|
|
152
|
+
}
|
|
134
153
|
const decision = shouldHandOver({
|
|
135
154
|
score,
|
|
155
|
+
artisticPassed: artistic.status === 'pass',
|
|
136
156
|
iteration,
|
|
137
157
|
maxIterations,
|
|
138
158
|
fingerprints,
|
|
@@ -157,6 +177,8 @@ export async function runVisualLoop(input) {
|
|
|
157
177
|
previousRounds: rounds.filter(round => round.outcome === 'applied' || round.outcome === 'rejected'),
|
|
158
178
|
signal: input.signal,
|
|
159
179
|
})
|
|
180
|
+
artistic = assessArtisticReview(vision.artistic, currentReview, minConfidence)
|
|
181
|
+
seedFingerprintCounters(fingerprints, reviewIssues(currentReview, artistic))
|
|
160
182
|
|
|
161
183
|
// The model's findings are validated against the review it was shown, and the
|
|
162
184
|
// ones that survive are kept as the round's `reported` list. They are NOT added
|
|
@@ -187,6 +209,16 @@ export async function runVisualLoop(input) {
|
|
|
187
209
|
reason: null,
|
|
188
210
|
newRevision: null,
|
|
189
211
|
appliedPatch: null,
|
|
212
|
+
artisticBefore: artistic,
|
|
213
|
+
reviewer: vision.detail ?? null,
|
|
214
|
+
...(subjectFixed ? { fixedSubjectId } : {}),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (score >= VISUAL_PASS_SCORE && artistic.status === 'pass') {
|
|
218
|
+
round.reason = 'PASSING_REVIEW'
|
|
219
|
+
rounds.push(round)
|
|
220
|
+
handedOver = { reason: 'PASSING_REVIEW', iteration, score, revision: currentRevision }
|
|
221
|
+
break
|
|
190
222
|
}
|
|
191
223
|
|
|
192
224
|
if (candidates.length === 0) {
|
|
@@ -205,6 +237,34 @@ export async function runVisualLoop(input) {
|
|
|
205
237
|
break
|
|
206
238
|
}
|
|
207
239
|
|
|
240
|
+
const subjectOperation = operations.find(operation => operation.op === 'project.reviewSubject.set' ||
|
|
241
|
+
(subjectFixed && fixedSubjectId !== null && operation.entityId === fixedSubjectId &&
|
|
242
|
+
(operation.op === 'entity.remove' || (operation.op === 'entity.visibility.set' && operation.visible === false))))
|
|
243
|
+
if (subjectOperation) {
|
|
244
|
+
round.outcome = 'rejected'
|
|
245
|
+
round.reason = `REVIEW_SUBJECT_OPERATION_REFUSED: ${subjectOperation.op}`
|
|
246
|
+
rounds.push(round)
|
|
247
|
+
handedOver = { reason: 'REVIEW_SUBJECT_OPERATION_REFUSED', iteration, score, revision: currentRevision }
|
|
248
|
+
break
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The model may improve the result, but cannot lower the authored target or
|
|
252
|
+
// replace the reference it is being judged against. Refuse the entire proposal.
|
|
253
|
+
const referenceAssets = new Set([
|
|
254
|
+
...(currentReview.sceneContext?.project?.referenceImages ?? []),
|
|
255
|
+
...(currentReview.referenceImages ?? []),
|
|
256
|
+
].map(reference => reference.assetId))
|
|
257
|
+
const forbidden = operations.find(operation => operation.op === 'project.brief.set' ||
|
|
258
|
+
(operation.op === 'asset.remove' && referenceAssets.has(operation.assetId)) ||
|
|
259
|
+
(operation.op === 'asset.add' && referenceAssets.has(operation.asset?.id)))
|
|
260
|
+
if (forbidden) {
|
|
261
|
+
round.outcome = 'rejected'
|
|
262
|
+
round.reason = `REVIEW_INPUT_OPERATION_REFUSED: ${forbidden.op}`
|
|
263
|
+
rounds.push(round)
|
|
264
|
+
handedOver = { reason: 'REVIEW_INPUT_OPERATION_REFUSED', iteration, score, revision: currentRevision }
|
|
265
|
+
break
|
|
266
|
+
}
|
|
267
|
+
|
|
208
268
|
let summary
|
|
209
269
|
try {
|
|
210
270
|
summary = await input.patch({
|
|
@@ -232,29 +292,71 @@ export async function runVisualLoop(input) {
|
|
|
232
292
|
// again and this round has to count towards the repeated-issue rule. Without
|
|
233
293
|
// this, a scene that refuses every proposal would run to the iteration cap
|
|
234
294
|
// instead of recognising what is happening after the second refusal.
|
|
235
|
-
updateFingerprintCounters(fingerprints, currentReview
|
|
295
|
+
updateFingerprintCounters(fingerprints, reviewIssues(currentReview, artistic))
|
|
236
296
|
continue
|
|
237
297
|
}
|
|
238
298
|
|
|
239
299
|
round.appliedPatch = { operations, note: vision.note ?? null }
|
|
240
300
|
round.newRevision = summary.revision
|
|
241
301
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
302
|
+
let after, afterVision, afterArtistic
|
|
303
|
+
try {
|
|
304
|
+
after = await input.review({
|
|
305
|
+
projectId: input.projectId, revision: summary.revision, iteration, signal: input.signal,
|
|
306
|
+
...(subjectFixed ? { subjectId: fixedSubjectId } : {}),
|
|
307
|
+
})
|
|
308
|
+
if (checkedReviewInputsDigest(after) !== authoredInputsDigest) {
|
|
309
|
+
throw reviewInputsChanged('The candidate changed the authored goal or visual reference inputs.')
|
|
310
|
+
}
|
|
311
|
+
if (subjectFixed) {
|
|
312
|
+
const issue = reviewedSubjectIssue(after, fixedSubjectId, subjectEvidence)
|
|
313
|
+
if (issue) throw issue
|
|
314
|
+
}
|
|
315
|
+
if (after.score >= score) {
|
|
316
|
+
afterVision = await input.reviewer({
|
|
317
|
+
review: after, baselineReview: currentReview, round: iteration,
|
|
318
|
+
previousRounds: rounds.filter(entry => entry.outcome === 'applied' || entry.outcome === 'rejected'),
|
|
319
|
+
signal: input.signal,
|
|
320
|
+
})
|
|
321
|
+
afterArtistic = assessArtisticReview(afterVision.artistic, after, minConfidence)
|
|
322
|
+
}
|
|
323
|
+
} catch (cause) {
|
|
324
|
+
await input.restore({ projectId: input.projectId, revision: currentRevision,
|
|
325
|
+
expectedCurrentRevision: summary.revision,
|
|
326
|
+
reason: 'candidate review failed; keep the previously reviewed revision' })
|
|
327
|
+
round.outcome = 'rejected'
|
|
328
|
+
const reason = cause?.reviewSubjectReason ?? (cause?.reviewInputsChanged ? 'REVIEW_INPUTS_CHANGED' : 'REVIEW_FAILED')
|
|
329
|
+
round.reason = `${reason}: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
330
|
+
round.rolledBackTo = currentRevision
|
|
331
|
+
rounds.push(round)
|
|
332
|
+
handedOver = { reason, iteration, score, revision: currentRevision }
|
|
333
|
+
break
|
|
334
|
+
}
|
|
335
|
+
round.artisticAfter = afterArtistic ?? null
|
|
336
|
+
round.comparisonReviewer = afterVision?.detail ?? null
|
|
337
|
+
// Aggregate gains cannot hide a newly introduced major/critical defect.
|
|
338
|
+
const severity = { minor: 1, major: 2, critical: 3 }
|
|
339
|
+
const priorSeverity = new Map(currentReview.issues.map(issue =>
|
|
340
|
+
[`${issue.viewId}|${issue.objectId ?? ''}|${issue.code}`, severity[issue.severity] ?? 0]))
|
|
341
|
+
const worsenedIssue = after.issues.some(issue => (severity[issue.severity] ?? 0) >= 2 &&
|
|
342
|
+
(severity[issue.severity] ?? 0) > (priorSeverity.get(`${issue.viewId}|${issue.objectId ?? ''}|${issue.code}`) ?? 0))
|
|
343
|
+
const artRegression = afterArtistic && artisticRegressed(artistic, afterArtistic)
|
|
344
|
+
const artImproved = afterArtistic?.comparison.verdict === 'improved'
|
|
345
|
+
const comparisonSupported = ['improved', 'equivalent'].includes(afterArtistic?.comparison.verdict)
|
|
248
346
|
|
|
249
|
-
if (after.score > score) {
|
|
347
|
+
if (after.score >= score && !worsenedIssue && !artRegression && comparisonSupported && (after.score > score || artImproved)) {
|
|
250
348
|
const previousScore = score
|
|
251
349
|
score = after.score
|
|
252
350
|
currentRevision = summary.revision
|
|
253
351
|
currentReview = after
|
|
352
|
+
artistic = afterArtistic
|
|
254
353
|
round.outcome = 'applied'
|
|
255
|
-
round.reason = `score ${previousScore} -> ${score}`
|
|
354
|
+
round.reason = previousScore === score ? `artistic improvement; technical score unchanged (${score})` : `score ${previousScore} -> ${score}`
|
|
256
355
|
rounds.push(round)
|
|
257
|
-
updateFingerprintCounters(fingerprints, after
|
|
356
|
+
updateFingerprintCounters(fingerprints, reviewIssues(after, artistic))
|
|
357
|
+
if (artImproved) for (const key of fingerprints.keys()) {
|
|
358
|
+
if (key.startsWith('artistic|')) fingerprints.set(key, 0)
|
|
359
|
+
}
|
|
258
360
|
log(`round ${iteration}: applied ${operations.length} operation(s), score ${previousScore} -> ${score} on ${currentRevision}`)
|
|
259
361
|
continue
|
|
260
362
|
}
|
|
@@ -263,35 +365,41 @@ export async function runVisualLoop(input) {
|
|
|
263
365
|
await input.restore({
|
|
264
366
|
projectId: input.projectId,
|
|
265
367
|
revision: round.revision,
|
|
266
|
-
|
|
368
|
+
expectedCurrentRevision: summary.revision,
|
|
369
|
+
reason: `visual round ${iteration} declined: technical ${score} -> ${after.score}, artistic ${afterArtistic?.comparison.verdict ?? 'unassessable'}`,
|
|
267
370
|
})
|
|
268
371
|
round.outcome = 'rejected'
|
|
269
|
-
round.reason =
|
|
372
|
+
round.reason = artRegression ? 'ARTISTIC_REGRESSION' : worsenedIssue ? 'TECHNICAL_ISSUE_REGRESSION'
|
|
373
|
+
: after.score > score && !comparisonSupported ? 'ARTISTIC_COMPARISON_UNASSESSABLE' : `score did not improve (${score} -> ${after.score})`
|
|
270
374
|
round.rolledBackTo = round.revision
|
|
271
375
|
rounds.push(round)
|
|
272
376
|
// The measured state is unchanged (the pointer went back), so the same
|
|
273
377
|
// fingerprints are present again — which is exactly what makes a fix that
|
|
274
378
|
// cannot work stop the loop instead of repeating until the cap.
|
|
275
|
-
updateFingerprintCounters(fingerprints, currentReview
|
|
276
|
-
log(`round ${iteration}: rejected —
|
|
379
|
+
updateFingerprintCounters(fingerprints, reviewIssues(currentReview, artistic))
|
|
380
|
+
log(`round ${iteration}: rejected — ${round.reason}; pointer restored to ${currentRevision}`)
|
|
277
381
|
}
|
|
278
382
|
|
|
279
383
|
// A handover is something a human has to pick up, so a run that PASSED has none.
|
|
280
384
|
// Reporting one would tell a reader to take over work that is finished.
|
|
281
|
-
const handover = handedOver === null || handedOver.reason === '
|
|
385
|
+
const handover = handedOver === null || handedOver.reason === 'PASSING_REVIEW'
|
|
282
386
|
? null
|
|
283
|
-
: buildHandover({ handedOver, openIssues: currentReview.issues, rounds, review: currentReview })
|
|
387
|
+
: { ...buildHandover({ handedOver, openIssues: currentReview.issues, rounds, review: currentReview }), artistic }
|
|
284
388
|
|
|
285
389
|
return {
|
|
286
390
|
projectId: input.projectId,
|
|
287
391
|
startRevision: input.revision,
|
|
288
392
|
finalRevision: currentRevision,
|
|
393
|
+
subjectFixed,
|
|
394
|
+
fixedSubjectId: subjectFixed ? fixedSubjectId : null,
|
|
289
395
|
startScore: rounds[0].score,
|
|
290
396
|
finalScore: score,
|
|
291
|
-
|
|
397
|
+
technicalPassed: !baselineSubjectError && score >= VISUAL_PASS_SCORE,
|
|
398
|
+
artistic,
|
|
399
|
+
passed: !baselineSubjectError && score >= VISUAL_PASS_SCORE && artistic.status === 'pass',
|
|
292
400
|
iterations: iteration,
|
|
293
401
|
maxIterations,
|
|
294
|
-
stopReason: handedOver?.reason ?? '
|
|
402
|
+
stopReason: handedOver?.reason ?? 'PASSING_REVIEW',
|
|
295
403
|
rounds,
|
|
296
404
|
openIssues: currentReview.issues,
|
|
297
405
|
sheet: currentReview.sheet ?? null,
|
|
@@ -312,12 +420,21 @@ export async function runVisualLoop(input) {
|
|
|
312
420
|
*/
|
|
313
421
|
function objectIdsOf(review) {
|
|
314
422
|
const ids = new Set()
|
|
423
|
+
for (const entity of review.sceneContext?.entities ?? []) ids.add(entity.id)
|
|
424
|
+
for (const view of review.views ?? []) for (const object of view.objects ?? []) ids.add(object.id)
|
|
315
425
|
for (const issue of review.issues ?? []) {
|
|
316
426
|
if (issue.objectId) ids.add(issue.objectId)
|
|
317
427
|
}
|
|
318
428
|
return [...ids]
|
|
319
429
|
}
|
|
320
430
|
|
|
431
|
+
/** Repeated unresolved art dimensions count separately from measured issues. */
|
|
432
|
+
function reviewIssues(review, artistic) {
|
|
433
|
+
return [...review.issues, ...Object.entries(artistic.dimensions)
|
|
434
|
+
.filter(([, entry]) => entry.status === 'needs_work')
|
|
435
|
+
.map(([dimension]) => ({ fingerprint: `artistic|${dimension}` }))]
|
|
436
|
+
}
|
|
437
|
+
|
|
321
438
|
/**
|
|
322
439
|
* The package a human needs to take over (SPEC §20 M2 "失败可人工接管").
|
|
323
440
|
*
|
|
@@ -344,7 +461,7 @@ function buildHandover(input) {
|
|
|
344
461
|
for (const viewId of investigatedViews) suggestions.push(`Re-render view "${viewId}" alone at full resolution before editing.`)
|
|
345
462
|
if (attempted.length > 0) {
|
|
346
463
|
suggestions.push(
|
|
347
|
-
`The automated rounds tried ${attempted.length} change(s)
|
|
464
|
+
`The automated rounds tried ${attempted.length} change(s); ` +
|
|
348
465
|
'the review rounds below record exactly what each one was.',
|
|
349
466
|
)
|
|
350
467
|
}
|
|
@@ -390,6 +507,67 @@ function normaliseOperation(proposed) {
|
|
|
390
507
|
return [operation]
|
|
391
508
|
}
|
|
392
509
|
|
|
510
|
+
function reviewInputsChanged(message) {
|
|
511
|
+
return Object.assign(new Error(message), { reviewInputsChanged: true })
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function hasSubjectEvidence(review) {
|
|
515
|
+
return Object.hasOwn(review, 'subjectId') || review.subject !== undefined || review.sceneContext != null
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function reviewedSubjectIssue(review, expectedId, required = {}) {
|
|
519
|
+
const issue = (reason, message) => Object.assign(new Error(message), { reviewSubjectReason: reason })
|
|
520
|
+
if ((required.sceneContext && review.sceneContext == null) || (required.subject && review.subject == null) ||
|
|
521
|
+
!Object.hasOwn(review, 'subjectId') ||
|
|
522
|
+
(review.subjectId !== null && (typeof review.subjectId !== 'string' || review.subjectId.length === 0)) ||
|
|
523
|
+
review.subjectId !== expectedId ||
|
|
524
|
+
(review.subject && (review.subject.id !== expectedId || typeof review.subject.available !== 'boolean'))) {
|
|
525
|
+
return issue('REVIEW_SUBJECT_CHANGED', 'The review did not report the fixed subject identity.')
|
|
526
|
+
}
|
|
527
|
+
if (expectedId === null || review.subject?.available === false) {
|
|
528
|
+
return issue('REVIEW_SUBJECT_UNAVAILABLE', review.subject?.reason ?? 'The fixed review subject is unavailable.')
|
|
529
|
+
}
|
|
530
|
+
if (review.sceneContext) {
|
|
531
|
+
const entity = (review.sceneContext.entities ?? []).find(entry => entry.id === expectedId)
|
|
532
|
+
if (!entity || entity.visible === false || entity.type === 'empty') {
|
|
533
|
+
return issue('REVIEW_SUBJECT_UNAVAILABLE', `The fixed review subject "${expectedId}" is missing, hidden or empty in this revision.`)
|
|
534
|
+
}
|
|
535
|
+
const authored = review.sceneContext.project?.reviewSubjectId
|
|
536
|
+
if (authored !== undefined && authored !== expectedId) {
|
|
537
|
+
return issue('REVIEW_SUBJECT_CHANGED', 'The review subject disagrees with the explicitly saved subject.')
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return null
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Old measurement-only ports have neither field; retain their null identity. */
|
|
544
|
+
function checkedReviewInputsDigest(review) {
|
|
545
|
+
const computed = review.sceneContext ? reviewInputsDigest(review.sceneContext) : null
|
|
546
|
+
const declared = review.reviewInputsDigest ?? null
|
|
547
|
+
if (declared !== null && (!/^[a-f0-9]{64}$/.test(declared) || (computed !== null && declared !== computed))) {
|
|
548
|
+
throw reviewInputsChanged('The review input digest does not match its authored scene context.')
|
|
549
|
+
}
|
|
550
|
+
if (computed === null && declared === null && (review.referenceImages ?? []).length > 0) {
|
|
551
|
+
throw reviewInputsChanged('A visual review with references must record its review input digest.')
|
|
552
|
+
}
|
|
553
|
+
return computed ?? declared
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function assessArtisticReview(raw, review, minConfidence) {
|
|
557
|
+
const referenceImages = review.referenceImages ?? []
|
|
558
|
+
const authored = review.sceneContext?.project?.referenceImages ?? []
|
|
559
|
+
const supplied = new Map(referenceImages.map(reference => [reference.id, reference]))
|
|
560
|
+
const missing = authored.some(reference => {
|
|
561
|
+
const actual = supplied.get(reference.id)
|
|
562
|
+
return !actual || actual.assetId !== reference.assetId || actual.sha256 !== reference.sha256 ||
|
|
563
|
+
JSON.stringify([...(actual.purposes ?? [])].sort()) !== JSON.stringify([...reference.purposes].sort())
|
|
564
|
+
}) || (review.sceneContext && referenceImages.length !== authored.length)
|
|
565
|
+
const assessment = validateArtisticReview(missing ? null : raw,
|
|
566
|
+
new Set(review.perView.map(entry => entry.viewId)), minConfidence, { referenceImages, subject: review.subject })
|
|
567
|
+
if (missing) assessment.problems.push('The actual reference image inventory does not match the authored references.')
|
|
568
|
+
return assessment
|
|
569
|
+
}
|
|
570
|
+
|
|
393
571
|
/** A positive integer option, with a default. */
|
|
394
572
|
function positiveInteger(value, fallback) {
|
|
395
573
|
return Number.isInteger(value) && value > 0 ? value : fallback
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DeepBlend Studio Service Definition, protocol contracts, and stable error codes.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
".": {
|
|
9
9
|
"default": "./lib/index.js"
|
|
10
10
|
},
|
|
11
|
+
"./sdk": {
|
|
12
|
+
"types": "./lib/sdk.d.ts",
|
|
13
|
+
"default": "./lib/sdk.js"
|
|
14
|
+
},
|
|
15
|
+
"./schemas/scene-spec.json": "./lib/schemas/scene-spec.schema.json",
|
|
16
|
+
"./schemas/scene-patch.json": "./lib/schemas/scene-patch.schema.json",
|
|
17
|
+
"./schemas/recipe.json": "./lib/schemas/recipe.schema.json",
|
|
11
18
|
"./package.json": "./package.json"
|
|
12
19
|
},
|
|
13
20
|
"license": "MIT",
|