@deepblend/dsh-blender-contracts 0.1.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/asset-content.js +134 -0
- package/lib/bitmap-font.js +149 -0
- package/lib/canonical.js +98 -0
- package/lib/config-surface.js +88 -0
- package/lib/contact-sheet.js +233 -0
- package/lib/deployment-paths.js +153 -0
- package/lib/index.js +829 -0
- package/lib/json-schema.js +337 -0
- package/lib/png.js +452 -0
- package/lib/projections.js +209 -0
- package/lib/redact.js +53 -0
- package/lib/render-job.js +459 -0
- package/lib/scene-patch.js +947 -0
- package/lib/scene-spec.js +901 -0
- package/lib/schemas/job-result.schema.json +295 -0
- package/lib/schemas/scene-patch.schema.json +1307 -0
- package/lib/schemas/scene-spec.schema.json +791 -0
- package/lib/ui-api.js +695 -0
- package/lib/visual-composition.js +574 -0
- package/lib/visual-issue.js +738 -0
- package/lib/visual-loop.js +401 -0
- package/package.json +19 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual review composition: turning a render plan into the exact things a
|
|
3
|
+
* reviewer — model or human — is shown (SPEC §12.3).
|
|
4
|
+
*
|
|
5
|
+
* WHAT A REVIEW CONTAINS, AND WHY EACH PART IS THERE
|
|
6
|
+
* --------------------------------------------------
|
|
7
|
+
* 1. A contact sheet: one labelled image of every view. Answers the RELATIONAL
|
|
8
|
+
* questions ("is the subject off-centre", "is something in front of it") in one
|
|
9
|
+
* look, which is the only way to answer them cheaply.
|
|
10
|
+
* 2. The measurements: coverage, centring, silhouette visibility per object, and
|
|
11
|
+
* a luminance histogram per view. Answers the NUMERIC questions, and is the
|
|
12
|
+
* only part that is reproducible (decision D30).
|
|
13
|
+
* 3. The measured findings: what those numbers already prove is wrong.
|
|
14
|
+
*
|
|
15
|
+
* The sheet alone cannot decide anything (a partial occlusion can read as a small
|
|
16
|
+
* subject), and the numbers alone cannot either (a number cannot tell a deliberate
|
|
17
|
+
* silhouette from a mistake). That is why both travel together and why the score
|
|
18
|
+
* is computed from the second while the model is asked about the first.
|
|
19
|
+
*
|
|
20
|
+
* Owner: DeepBlend Studio — M2
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { composeContactSheet, viewCaption } from './contact-sheet.js'
|
|
24
|
+
import { SUBJECT_PART_TAG, scoreReview, VISUAL_REVIEW_VERSION } from './visual-issue.js'
|
|
25
|
+
import { entityBoundingRadius } from './scene-spec.js'
|
|
26
|
+
|
|
27
|
+
/** Roles a generated view plan can contain, in reading order. */
|
|
28
|
+
export const VIEW_ROLES = Object.freeze(['active-camera', 'three-quarter', 'top', 'detail'])
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How many frames an animated scene is sampled at.
|
|
32
|
+
*
|
|
33
|
+
* Four, not three, and the difference is not academic: a turntable is at its most
|
|
34
|
+
* obviously broken at a QUARTER turn, so three samples across a full rotation land on
|
|
35
|
+
* 0/180/360 degrees — every one of them an angle where a part-by-part rotation looks
|
|
36
|
+
* correct. Four samples land on 0/120/240/360 and the quarter turn is visible.
|
|
37
|
+
*/
|
|
38
|
+
export const ANIMATED_SAMPLE_FRAMES = 4
|
|
39
|
+
|
|
40
|
+
/** Cap on how many extra objects are tracked for occlusion, beyond the subject. */
|
|
41
|
+
const MAX_TRACKED_OCCLUDERS = 3
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} PlannedView
|
|
45
|
+
* @property {string} id - the view id used everywhere downstream. A role name when
|
|
46
|
+
* the scene determines one, otherwise the camera's own id.
|
|
47
|
+
* @property {string|null} role - one of {@link VIEW_ROLES}, or null when the scene
|
|
48
|
+
* assigns this camera no role and the view is named after the camera instead.
|
|
49
|
+
* @property {string} cameraId - the SceneSpec camera this view renders from.
|
|
50
|
+
* @property {string|null} frame
|
|
51
|
+
* @property {string} label - what the contact sheet draws on the cell.
|
|
52
|
+
* @property {string} purpose - why this view is in the plan, for the model.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the standard multi-view plan for a scene (SPEC §12.3: main camera, 45
|
|
57
|
+
* degrees, top, subject close-up).
|
|
58
|
+
*
|
|
59
|
+
* The plan is expressed in SceneSpec camera IDS, never as ad-hoc camera transforms:
|
|
60
|
+
* a view the project does not declare cannot be reported, compared across rounds,
|
|
61
|
+
* or turned into a camera patch. A rotation that is not in the spec is also not
|
|
62
|
+
* reproducible after a restore.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} input
|
|
65
|
+
* @param {object} input.spec - a compiled SceneSpec.
|
|
66
|
+
* @param {string|null} [input.subjectId]
|
|
67
|
+
* @param {number} [input.frame] - the frame for animated views; defaults to the
|
|
68
|
+
* middle of the range, because a turntable's first frame is its least
|
|
69
|
+
* informative angle.
|
|
70
|
+
* @param {string[]} [input.roles] - roles the CALLER asked for. Strict: if none can be
|
|
71
|
+
* filled the plan is empty and the caller reports why.
|
|
72
|
+
* @param {string[]} [input.preferredRoles] - the product's standard set, used when the
|
|
73
|
+
* caller named none. Advisory: a scene with no roles falls back to camera-id views.
|
|
74
|
+
* @param {number} [input.maxViews]
|
|
75
|
+
* @returns {{ views: PlannedView[], notices: string[] }} the plan, plus what could
|
|
76
|
+
* NOT be planned — an unfilled role or a missing one — because a review that
|
|
77
|
+
* silently covers two views instead of four reads as a clean four-view review.
|
|
78
|
+
*/
|
|
79
|
+
export function buildViewPlan(input) {
|
|
80
|
+
const spec = input?.spec
|
|
81
|
+
if (spec === null || typeof spec !== 'object') throw new Error('buildViewPlan needs a SceneSpec')
|
|
82
|
+
const cameras = Array.isArray(spec.cameras) ? spec.cameras : []
|
|
83
|
+
if (cameras.length === 0) throw new Error('the scene declares no camera to render from')
|
|
84
|
+
|
|
85
|
+
const frames = frameRange(spec)
|
|
86
|
+
const frame = normalizeFrame(input.frame, frames)
|
|
87
|
+
const subjectId = input.subjectId ?? null
|
|
88
|
+
// TWO KINDS OF "WHICH ROLES", and conflating them cost a real project its review.
|
|
89
|
+
//
|
|
90
|
+
// `roles` the CALLER asked for these. If none can be filled, that is an
|
|
91
|
+
// error: answering "give me the top view" with four views named
|
|
92
|
+
// after cameras is the same lie as guessing a role from the alphabet.
|
|
93
|
+
// `preferredRoles` the PRODUCT's standard set. It is a preference, so a scene that
|
|
94
|
+
// declares no roles still gets reviewed — by camera-id views.
|
|
95
|
+
//
|
|
96
|
+
// Treating the configured preference as an explicit request made
|
|
97
|
+
// `blender_visual_review` throw "needs at least one view" on any role-less project,
|
|
98
|
+
// which is every project built before roles existed.
|
|
99
|
+
const explicitRoles = Array.isArray(input.roles) && input.roles.length > 0 ? input.roles : null
|
|
100
|
+
const preferredRoles = Array.isArray(input.preferredRoles) && input.preferredRoles.length > 0
|
|
101
|
+
? input.preferredRoles
|
|
102
|
+
: VIEW_ROLES
|
|
103
|
+
const requestedRoles = explicitRoles ?? preferredRoles
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Camera selection is by ROLE. There is no positional fallback, and that took a
|
|
107
|
+
// real defect to learn.
|
|
108
|
+
//
|
|
109
|
+
// The first version fell back to array position ("the second camera is the
|
|
110
|
+
// three-quarter view"). That was defensible only while declaration order survived
|
|
111
|
+
// storage — and it does not: `upsertById` keeps every collection sorted by id, so
|
|
112
|
+
// after the first patch the array is alphabetical. A four-camera project therefore
|
|
113
|
+
// got its views assigned to whatever the alphabet produced, and the plan handed the
|
|
114
|
+
// reviewer a view LABELLED "top" that was actually the three-quarter camera. The
|
|
115
|
+
// reviewer itself flagged the mismatch at 0.62 confidence and correctly declined to
|
|
116
|
+
// "fix" it — a mislabelled view is worse than an unlabelled one, because the label
|
|
117
|
+
// is what the finding gets attached to.
|
|
118
|
+
//
|
|
119
|
+
// So a view exists only when the scene determines it: an explicit `role`, or
|
|
120
|
+
// `project.activeCamera` for the active view, or a scene with exactly one camera.
|
|
121
|
+
// Anything else falls back to one view per camera labelled by CAMERA ID — which is
|
|
122
|
+
// true whatever the order — and says so.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const determined = []
|
|
126
|
+
const claimed = new Set()
|
|
127
|
+
|
|
128
|
+
// A camera already used by a stronger role cannot fill a second one — four tiles of
|
|
129
|
+
// one camera would spend four renders to say one thing. But when SEVERAL cameras claim
|
|
130
|
+
// the same role (an author who retagged one), the first is not necessarily free, and
|
|
131
|
+
// stopping at it silently DROPS the role from the plan. So every candidate is tried.
|
|
132
|
+
const claim = (role, purpose, candidates) => {
|
|
133
|
+
if (!requestedRoles.includes(role)) return
|
|
134
|
+
for (const camera of candidates) {
|
|
135
|
+
if (camera === undefined || camera === null) continue
|
|
136
|
+
if (claimed.has(camera.id)) continue
|
|
137
|
+
claimed.add(camera.id)
|
|
138
|
+
determined.push({ role, camera, purpose })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The active view is the one statement a role-less scene can still make about
|
|
144
|
+
// itself, so it is resolved first and from the strongest source available.
|
|
145
|
+
const activeByName = cameras.find(camera => camera.id === spec.project?.activeCamera)
|
|
146
|
+
const activeByRole = cameras.find(camera => camera.role === 'active-camera')
|
|
147
|
+
const soloCamera = cameras.length === 1 ? cameras[0] : undefined
|
|
148
|
+
const active = activeByName ?? activeByRole ?? soloCamera
|
|
149
|
+
|
|
150
|
+
// ONE COPY OF THE SENTENCE. This call site used to spell the purpose out, and `rolePurpose`'s
|
|
151
|
+
// `case 'active-camera'` spelled it again — while the loop below SKIPS that role (it is claimed here,
|
|
152
|
+
// first, from the strongest source), so the copy inside `rolePurpose` could never run. A mutation that
|
|
153
|
+
// rewrote that case changed nothing at all, which is how the duplicate was found: a vocabulary written
|
|
154
|
+
// twice rots in the copy nobody exercises (D38/D43), and here the rotting copy was already dead.
|
|
155
|
+
claim('active-camera', rolePurpose('active-camera', subjectId), [active])
|
|
156
|
+
for (const role of requestedRoles) {
|
|
157
|
+
if (role === 'active-camera') continue
|
|
158
|
+
claim(role, rolePurpose(role, subjectId), cameras.filter(candidate => candidate.role === role))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @type {{ views: object[], notices: string[] }} */
|
|
162
|
+
const notices = []
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// HOW MANY FRAMES A REVIEW NEEDS. One frame is not enough, and the way it failed
|
|
166
|
+
// is worth stating exactly.
|
|
167
|
+
//
|
|
168
|
+
// A real project (r0018) had a completely broken "turntable": all seven tracks
|
|
169
|
+
// wrote `rotationEuler.z` only, so every part rotated about its OWN origin and at
|
|
170
|
+
// a quarter turn the case went edge-on while the markers and the crown stayed put
|
|
171
|
+
// and read as detached debris. A real vision review scored it 100 — because the
|
|
172
|
+
// review rendered ONE frame, at the MIDDLE of the range, and the middle of 1..90 is
|
|
173
|
+
// frame 45 = 180 degrees, the one angle where every part of a symmetric assembly
|
|
174
|
+
// maps onto itself.
|
|
175
|
+
//
|
|
176
|
+
// So the sample frames are spread EVENLY, endpoints included, and deliberately not
|
|
177
|
+
// taken from the keyframes: a linear track's keyframes are exactly where such a
|
|
178
|
+
// defect is invisible.
|
|
179
|
+
//
|
|
180
|
+
// Sampling every view at every frame would be the thorough answer and is the wrong
|
|
181
|
+
// one: the request image budget is 640 000 px (measured, runtime-audit §7.2) and the
|
|
182
|
+
// harness DOWNSAMPLES to fit it, so sixteen tiles arrive blurrier than four. Motion
|
|
183
|
+
// is most legible from the hero angle, so the active view is the one that carries
|
|
184
|
+
// the extra frames and the other roles keep their single, comparable pose.
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
const animatedSpan = animationSpan(spec)
|
|
187
|
+
const sampleFrames = input.frame !== undefined
|
|
188
|
+
? [normalizeFrame(input.frame, frames)]
|
|
189
|
+
: animatedSpan === null
|
|
190
|
+
? [frame]
|
|
191
|
+
: evenlySpaced(animatedSpan, ANIMATED_SAMPLE_FRAMES)
|
|
192
|
+
const poseFrame = sampleFrames[Math.floor((sampleFrames.length - 1) / 2)]
|
|
193
|
+
const heroIndex = determined.findIndex(entry => entry.role === 'active-camera')
|
|
194
|
+
|
|
195
|
+
/** The frames one planned view is rendered at. */
|
|
196
|
+
const framesFor = (entry, index) => {
|
|
197
|
+
// No animation, or the caller named the frame: one frame, exactly as before.
|
|
198
|
+
if (sampleFrames.length === 1) return [sampleFrames[0]]
|
|
199
|
+
// With roles, the active view carries the motion. Without them, the first entry
|
|
200
|
+
// does — the plan still has to sample something rather than go back to one frame.
|
|
201
|
+
const carries = heroIndex >= 0 ? index === heroIndex : index === 0
|
|
202
|
+
return carries ? sampleFrames : [poseFrame]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let views = determined.flatMap((entry, index) => framesFor(entry, index).map(sample => ({
|
|
206
|
+
// A view that appears once keeps its bare role as the id, because that id is what
|
|
207
|
+
// findings, sheets and `blender_preview_views` text have always keyed on.
|
|
208
|
+
id: framesFor(entry, index).length > 1 ? `${entry.role}@${sample}` : entry.role,
|
|
209
|
+
role: entry.role,
|
|
210
|
+
cameraId: entry.camera.id,
|
|
211
|
+
frame: sample,
|
|
212
|
+
label: viewCaption({ role: entry.role, cameraId: entry.camera.id, frame: sample }),
|
|
213
|
+
purpose: entry.purpose,
|
|
214
|
+
})))
|
|
215
|
+
|
|
216
|
+
if (sampleFrames.length > 1) {
|
|
217
|
+
notices.push(
|
|
218
|
+
`this scene animates, so the review samples ${sampleFrames.length} frames ` +
|
|
219
|
+
`(${sampleFrames.join(', ')}) spread evenly across frames ${animatedSpan[0]}–${animatedSpan[1]}: ` +
|
|
220
|
+
`the active view is rendered at each, the other views at frame ${poseFrame} so they stay comparable. ` +
|
|
221
|
+
'A single frame cannot tell a working animation from one that only looks right at that frame.',
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (soloCamera !== undefined && activeByName === undefined && activeByRole === undefined) {
|
|
226
|
+
notices.push('the scene declares one camera and no roles, so it is used as the active view')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The camera-id fallback exists for a scene that declares no roles — NOT for a
|
|
230
|
+
// caller who asked for specific roles and named ones the scene cannot fill. Answering
|
|
231
|
+
// "render me the top view" with four views called `camera-main` and friends would be
|
|
232
|
+
// the same class of lie as the alphabetical role guess this replaced, so an explicit
|
|
233
|
+
// request that cannot be honoured produces an EMPTY plan and the caller raises.
|
|
234
|
+
const askedForRoles = explicitRoles !== null
|
|
235
|
+
if (views.length === 0 && askedForRoles) {
|
|
236
|
+
notices.push(
|
|
237
|
+
`the requested role(s) ${requestedRoles.join(', ')} are not filled by any camera in this scene, and the ` +
|
|
238
|
+
`scene declares ${cameras.length} camera(s): ${cameras.map(camera => camera.id).join(', ')}`,
|
|
239
|
+
)
|
|
240
|
+
} else if (views.length === 0) {
|
|
241
|
+
// No role anywhere. Render the cameras the scene HAS, labelled with their own
|
|
242
|
+
// ids: a camera id is a fact, and a role invented from alphabetical order is not.
|
|
243
|
+
const fallback = [...cameras].sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0))
|
|
244
|
+
const cap = Number.isInteger(input.maxViews) && input.maxViews > 0 ? input.maxViews : VIEW_ROLES.length
|
|
245
|
+
for (const camera of fallback.slice(0, cap)) {
|
|
246
|
+
views.push({
|
|
247
|
+
id: camera.id,
|
|
248
|
+
role: null,
|
|
249
|
+
cameraId: camera.id,
|
|
250
|
+
frame,
|
|
251
|
+
label: viewCaption({ role: null, cameraId: camera.id, frame }),
|
|
252
|
+
purpose: 'a camera this scene declares; the scene assigns it no role, so the view is named after the camera',
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
notices.push(
|
|
256
|
+
`no camera in this scene declares a \`role\`, and no activeCamera is set, so the plan renders ` +
|
|
257
|
+
`${views.length} of ${cameras.length} camera(s) named after the cameras themselves. Set \`role\` on each ` +
|
|
258
|
+
`camera (active-camera / three-quarter / top / detail) to get the standard four-view plan.`,
|
|
259
|
+
)
|
|
260
|
+
if (fallback.length > cap) {
|
|
261
|
+
notices.push(`the remaining ${fallback.length - cap} camera(s) were left out to keep the review affordable: ` +
|
|
262
|
+
`${fallback.slice(cap).map(camera => camera.id).join(', ')}`)
|
|
263
|
+
}
|
|
264
|
+
} else if (views.length < requestedRoles.length) {
|
|
265
|
+
const missing = requestedRoles.filter(role => !views.some(view => view.role === role))
|
|
266
|
+
notices.push(
|
|
267
|
+
`no camera fills ${missing.length === 1 ? 'the role' : 'the roles'} ${missing.join(', ')}, so ` +
|
|
268
|
+
`${missing.length === 1 ? 'that view was' : 'those views were'} left out of this plan; ` +
|
|
269
|
+
`the review covers ${views.length} view(s) instead of ${requestedRoles.length}`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
// An explicit activeCamera or an explicit `role` gets no notice: the author already
|
|
273
|
+
// said it, and a plan that reports decisions the caller made on purpose trains
|
|
274
|
+
// everyone reading warnings to skip them.
|
|
275
|
+
|
|
276
|
+
const limit = Number.isInteger(input.maxViews) && input.maxViews > 0 ? input.maxViews : views.length
|
|
277
|
+
return { views: views.slice(0, limit), notices }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Which objects the renderer should measure by isolation.
|
|
282
|
+
*
|
|
283
|
+
* The subject is always tracked. The others matter because occlusion is only
|
|
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
|
|
287
|
+
* compute — the alternative, tracking everything, would multiply the render count
|
|
288
|
+
* of every view.
|
|
289
|
+
*
|
|
290
|
+
* @param {object} spec
|
|
291
|
+
* @param {string|null} subjectId
|
|
292
|
+
* @returns {string[]}
|
|
293
|
+
*/
|
|
294
|
+
export function trackedObjects(spec, subjectId) {
|
|
295
|
+
const entities = Array.isArray(spec?.entities) ? spec.entities : []
|
|
296
|
+
// Every declared PART is tracked regardless of size. The volume ranking below is a
|
|
297
|
+
// guess about what might hide the subject; a declaration is not a guess, and a small
|
|
298
|
+
// component that is missing is exactly the defect that a size ranking would skip.
|
|
299
|
+
const declared = subjectParts(spec)
|
|
300
|
+
// `environment` geometry is excluded, and that is a measurement decision rather than
|
|
301
|
+
// a tidiness one: the floor of a room occupies the subject's screen pixels from
|
|
302
|
+
// behind, and a wall does the same from the side. Tracking them would report a
|
|
303
|
+
// chair as occluded by the floor it stands on. Which objects may hide the subject
|
|
304
|
+
// is authored intent, so the spec's own tag decides it.
|
|
305
|
+
const visible = entities.filter(entity =>
|
|
306
|
+
entity.visible !== false && !(entity.tags ?? []).includes('environment'))
|
|
307
|
+
const ranked = [...visible].sort((left, right) => entityExtent(right) - entityExtent(left))
|
|
308
|
+
const tracked = []
|
|
309
|
+
if (subjectId !== null && subjectId !== undefined) tracked.push(subjectId)
|
|
310
|
+
for (const entityId of declared) {
|
|
311
|
+
if (!tracked.includes(entityId)) tracked.push(entityId)
|
|
312
|
+
}
|
|
313
|
+
for (const entity of ranked) {
|
|
314
|
+
if (tracked.length >= declared.length + MAX_TRACKED_OCCLUDERS + 1) break
|
|
315
|
+
if (tracked.includes(entity.id)) continue
|
|
316
|
+
tracked.push(entity.id)
|
|
317
|
+
}
|
|
318
|
+
return tracked
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The entities the scene declared part of the subject's own body.
|
|
323
|
+
*
|
|
324
|
+
* @param {object} spec
|
|
325
|
+
* @returns {string[]} ids, sorted so the set is stable across storage reordering.
|
|
326
|
+
*/
|
|
327
|
+
export function subjectParts(spec) {
|
|
328
|
+
const entities = Array.isArray(spec?.entities) ? spec.entities : []
|
|
329
|
+
return entities
|
|
330
|
+
.filter(entity => (entity.tags ?? []).includes(SUBJECT_PART_TAG))
|
|
331
|
+
.map(entity => entity.id)
|
|
332
|
+
.sort()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The subject of a shot: the entity the animation is about, and WHY it was chosen.
|
|
337
|
+
*
|
|
338
|
+
* THE CHOICE MUST NOT DEPEND ON ARRAY ORDER
|
|
339
|
+
* -----------------------------------------
|
|
340
|
+
* The first version took `entities.find(hero-product)`. Since `upsertById` keeps
|
|
341
|
+
* collections sorted by id, "find" means "alphabetically first", and a scene that
|
|
342
|
+
* tagged its case, dial, crown and four indices as `hero-product` resolved its
|
|
343
|
+
* subject to **`index-nine`** — a 2.5 mm marker. Two consecutive reviews then scored
|
|
344
|
+
* that marker's occlusion and proposed scaling it 5x and pointing every camera at it:
|
|
345
|
+
* arithmetically correct, and completely meaningless. Nothing crashed, and the
|
|
346
|
+
* findings were well-formed, which is exactly why it took a human reading the sheet
|
|
347
|
+
* to notice.
|
|
348
|
+
*
|
|
349
|
+
* The rule now has a stated order of evidence, and every step of it is a fact about
|
|
350
|
+
* the file rather than a fact about the alphabet:
|
|
351
|
+
*
|
|
352
|
+
* 1. what the ACTIVE camera aims at — a camera exists to frame something, and
|
|
353
|
+
* `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.
|
|
357
|
+
*
|
|
358
|
+
* Step 3 is deliberately "largest" rather than "first": a scene that tags four things
|
|
359
|
+
* hero is ambiguous, and the biggest of them is the only defensible reading.
|
|
360
|
+
*
|
|
361
|
+
* @param {object} spec
|
|
362
|
+
* @returns {{ id: string|null, source: string, candidates: string[] }}
|
|
363
|
+
*/
|
|
364
|
+
export function resolveSubject(spec) {
|
|
365
|
+
const entities = Array.isArray(spec?.entities) ? spec.entities : []
|
|
366
|
+
const visible = entities.filter(entity => entity.visible !== false)
|
|
367
|
+
|
|
368
|
+
// 1. What the shot is actually framed on.
|
|
369
|
+
const cameras = Array.isArray(spec?.cameras) ? spec.cameras : []
|
|
370
|
+
const active = cameras.find(camera => camera.id === spec?.project?.activeCamera)
|
|
371
|
+
?? cameras.find(camera => camera.role === 'active-camera')
|
|
372
|
+
if (active?.targetEntityId !== undefined) {
|
|
373
|
+
const aimed = visible.find(entity => entity.id === active.targetEntityId)
|
|
374
|
+
if (aimed !== undefined) {
|
|
375
|
+
return { id: aimed.id, source: `the active camera "${active.id}" aims at it`, candidates: [aimed.id] }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const tagged = visible.filter(entity => Array.isArray(entity.tags) && entity.tags.includes('hero-product'))
|
|
380
|
+
if (tagged.length === 1) {
|
|
381
|
+
return { id: tagged[0].id, source: 'it is the only entity tagged hero-product', candidates: [tagged[0].id] }
|
|
382
|
+
}
|
|
383
|
+
|
|
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: [] }
|
|
386
|
+
|
|
387
|
+
// Descending volume, and the id only breaks exact ties — so two entities of equal
|
|
388
|
+
// size resolve the same way on every machine and after every re-sort.
|
|
389
|
+
const ranked = [...pool].sort((left, right) => {
|
|
390
|
+
const byVolume = entityExtent(right) - entityExtent(left)
|
|
391
|
+
if (byVolume !== 0) return byVolume
|
|
392
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0
|
|
393
|
+
})
|
|
394
|
+
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
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The subject's id alone. Kept because most callers want only the id; use
|
|
406
|
+
* {@link resolveSubject} when the reason for the choice matters — which is whenever
|
|
407
|
+
* the answer is going to be shown to someone.
|
|
408
|
+
*
|
|
409
|
+
* @param {object} spec
|
|
410
|
+
* @returns {string|null}
|
|
411
|
+
*/
|
|
412
|
+
export function resolveSubjectId(spec) {
|
|
413
|
+
return resolveSubject(spec).id
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Compose the contact sheet and the review document for one round.
|
|
418
|
+
*
|
|
419
|
+
* @param {object} input
|
|
420
|
+
* @param {string} input.projectId
|
|
421
|
+
* @param {string} input.revision
|
|
422
|
+
* @param {string} input.digest
|
|
423
|
+
* @param {object[]} input.views - rendered view entries, each with `metrics`.
|
|
424
|
+
* @param {Record<string, Buffer>} input.pngs - view id -> PNG bytes.
|
|
425
|
+
* @param {string|null} [input.subjectId]
|
|
426
|
+
* @param {number} [input.iteration]
|
|
427
|
+
* @param {string} [input.sheetPath] - project-relative path recorded for the sheet.
|
|
428
|
+
* @returns {{ sheet: { png: Buffer, width: number, height: number, placements: object[] }, review: object }}
|
|
429
|
+
*/
|
|
430
|
+
export function buildVisualReview(input) {
|
|
431
|
+
const views = Array.isArray(input.views) ? input.views : []
|
|
432
|
+
if (views.length === 0) throw new Error('a visual review needs at least one rendered view')
|
|
433
|
+
|
|
434
|
+
const scored = scoreReview(views, { subjectId: input.subjectId ?? null })
|
|
435
|
+
|
|
436
|
+
const sheetViews = []
|
|
437
|
+
for (const view of views) {
|
|
438
|
+
const png = input.pngs?.[view.viewId]
|
|
439
|
+
if (!Buffer.isBuffer(png)) continue
|
|
440
|
+
sheetViews.push({ viewId: view.viewId, label: view.caption ?? view.viewId, png })
|
|
441
|
+
}
|
|
442
|
+
if (sheetViews.length === 0) throw new Error('no rendered bytes were available to compose a contact sheet')
|
|
443
|
+
|
|
444
|
+
const sheet = composeContactSheet({
|
|
445
|
+
views: sheetViews,
|
|
446
|
+
title: `${input.projectId} ${input.revision} SCORE ${scored.score}`,
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
const review = {
|
|
450
|
+
schemaVersion: VISUAL_REVIEW_VERSION,
|
|
451
|
+
projectId: input.projectId,
|
|
452
|
+
revision: input.revision,
|
|
453
|
+
digest: input.digest,
|
|
454
|
+
iteration: input.iteration ?? 0,
|
|
455
|
+
score: scored.score,
|
|
456
|
+
pass: scored.score >= 90,
|
|
457
|
+
perView: scored.perView,
|
|
458
|
+
issues: scored.issues,
|
|
459
|
+
viewCount: views.length,
|
|
460
|
+
subjectId: input.subjectId ?? null,
|
|
461
|
+
sheet: {
|
|
462
|
+
path: input.sheetPath ?? null,
|
|
463
|
+
width: sheet.width,
|
|
464
|
+
height: sheet.height,
|
|
465
|
+
columns: sheet.columns,
|
|
466
|
+
rows: sheet.rows,
|
|
467
|
+
placements: sheet.placements,
|
|
468
|
+
},
|
|
469
|
+
reported: [],
|
|
470
|
+
rejected: [],
|
|
471
|
+
}
|
|
472
|
+
return { sheet, review }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Why one standard role is in the plan, phrased for the model that reads it.
|
|
477
|
+
*
|
|
478
|
+
* @param {string} role
|
|
479
|
+
* @param {string|null} subjectId
|
|
480
|
+
* @returns {string}
|
|
481
|
+
*/
|
|
482
|
+
function rolePurpose(role, subjectId) {
|
|
483
|
+
switch (role) {
|
|
484
|
+
case 'three-quarter':
|
|
485
|
+
return 'a 45-degree reading of the subject where depth, silhouette and contact shadows are visible'
|
|
486
|
+
case 'top':
|
|
487
|
+
return 'top-down placement: what is beside, behind or on top of the subject'
|
|
488
|
+
case 'detail':
|
|
489
|
+
return subjectId === null
|
|
490
|
+
? 'a closer angle on the subject'
|
|
491
|
+
: `a closer angle on "${subjectId}" for surface, edge and material detail`
|
|
492
|
+
case 'active-camera':
|
|
493
|
+
return 'what the animation is actually seen through'
|
|
494
|
+
default:
|
|
495
|
+
return `the scene's "${role}" view`
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The frame span a scene actually animates over, or null when it does not animate.
|
|
501
|
+
*
|
|
502
|
+
* Taken from the tracks rather than from the project range on purpose: a project may
|
|
503
|
+
* declare 1..450 with motion only in 30..390, and sampling the declared range would
|
|
504
|
+
* spend three of four frames on frames that hold still.
|
|
505
|
+
*/
|
|
506
|
+
function animationSpan(spec) {
|
|
507
|
+
const tracks = Array.isArray(spec?.animationTracks) ? spec.animationTracks : []
|
|
508
|
+
const frames = []
|
|
509
|
+
for (const track of tracks) {
|
|
510
|
+
for (const keyframe of track.keyframes ?? []) {
|
|
511
|
+
if (Number.isInteger(keyframe?.frame)) frames.push(keyframe.frame)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (frames.length === 0) return null
|
|
515
|
+
const declared = frameRange(spec)
|
|
516
|
+
const start = Math.max(declared.start, Math.min(...frames))
|
|
517
|
+
const end = Math.min(declared.end, Math.max(...frames))
|
|
518
|
+
return end > start ? [start, end] : null
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* `count` whole frames spread evenly across [start, end], endpoints included.
|
|
523
|
+
*
|
|
524
|
+
* Endpoints included is deliberate: the first and last frame of a commercial are the
|
|
525
|
+
* two a human will judge, so a plan that skipped them would trade one blind spot for
|
|
526
|
+
* another. Interior samples are what catch a defect that hides at the extremes.
|
|
527
|
+
*/
|
|
528
|
+
function evenlySpaced([start, end], count) {
|
|
529
|
+
if (count <= 1 || end <= start) return [start]
|
|
530
|
+
const step = (end - start) / (count - 1)
|
|
531
|
+
const out = []
|
|
532
|
+
for (let index = 0; index < count; index += 1) {
|
|
533
|
+
const value = Math.round(start + index * step)
|
|
534
|
+
if (!out.includes(value)) out.push(value)
|
|
535
|
+
}
|
|
536
|
+
return out
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The declared frame range of a compiled spec. */
|
|
540
|
+
function frameRange(spec) { const start = Number.isFinite(spec?.project?.frameStart) ? spec.project.frameStart : 1
|
|
541
|
+
const end = Number.isFinite(spec?.project?.frameEnd) ? spec.project.frameEnd : start
|
|
542
|
+
return { start, end }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Clamp a requested frame into the range, defaulting to the middle. */
|
|
546
|
+
function normalizeFrame(requested, range) {
|
|
547
|
+
if (Number.isInteger(requested)) {
|
|
548
|
+
return Math.min(range.end, Math.max(range.start, requested))
|
|
549
|
+
}
|
|
550
|
+
return Math.floor((range.start + range.end) / 2)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* How big an entity is, for ranking and for measuring.
|
|
555
|
+
*
|
|
556
|
+
* THE SHARED DEFINITION, not a local one. This file used to compute its own volume,
|
|
557
|
+
* and that copy dispatched on which FIELDS a generator happened to have rather than on
|
|
558
|
+
* its shape — so a cylinder matched the `radius` branch and was scored as a sphere. A
|
|
559
|
+
* 36 mm watch dial then outranked the 44 mm case it sits in and became "the largest
|
|
560
|
+
* hero-tagged entity", i.e. the subject of the shot. The measurement was fine; the
|
|
561
|
+
* thing deciding what to point it at was not.
|
|
562
|
+
*
|
|
563
|
+
* `entityBoundingRadius` is the definition the camera aiming already uses, so the two
|
|
564
|
+
* agree by construction rather than by review.
|
|
565
|
+
*
|
|
566
|
+
* @param {object} entity
|
|
567
|
+
* @returns {number}
|
|
568
|
+
*/
|
|
569
|
+
function entityExtent(entity) {
|
|
570
|
+
// A bounding radius, not a volume: what makes an object worth tracking is how much
|
|
571
|
+
// of the FRAME it can cover. A thin partition has almost no volume and hides
|
|
572
|
+
// everything, which is exactly the occluder this ranking must not skip.
|
|
573
|
+
return entityBoundingRadius(entity)
|
|
574
|
+
}
|