@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.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Contact-sheet composition: many preview views into one image, with labels.
3
+ *
4
+ * WHY A SHEET AND NOT N IMAGES
5
+ * ----------------------------
6
+ * Measured in the M2 probe (`runtime-audit.md` §7.2.3): one 640x360 view costs 177
7
+ * vision tokens and a 2x2 sheet costs 369. Token cost is therefore NOT the reason
8
+ * to prefer a sheet — the reason is that the model's per-request pixel budget
9
+ * (640 000 px) downscales whatever is sent, and a sheet is the only way to answer a
10
+ * *relational* question ("is the subject off-centre, is anything in front of it")
11
+ * from one look. The numbers that decide detail questions are the measurements,
12
+ * which travel as text.
13
+ *
14
+ * The layout is chosen so the sheet survives that downscale with each tile at or
15
+ * above the resolution of the views that went into it: a 2x2 sheet of 640x360 views
16
+ * is composed at 2x and lands at roughly 1:1 after the model's own resize. A 3x3
17
+ * sheet would land below 1:1 and would make a partial occlusion look like no
18
+ * occlusion at all — which is worse than not showing it.
19
+ *
20
+ * Owner: DeepBlend Studio — M2
21
+ */
22
+
23
+ import { blendInto, createImage, decodePng, encodePng, fillRect } from './png.js'
24
+ import { drawText } from './bitmap-font.js'
25
+
26
+ /** Sheet background: a neutral mid-dark grey, so neither a black nor a white render
27
+ * appears to bleed into its neighbour. */
28
+ const BACKGROUND = [24, 24, 28, 255]
29
+ /** Cell caption band. Exported so a reader can identify a band without guessing. */
30
+ export const CAPTION_BAND_COLOR = [12, 12, 16, 255]
31
+ const CAPTION_BAND = CAPTION_BAND_COLOR
32
+ /** Cell caption text. */
33
+ const CAPTION_TEXT = [232, 232, 236, 255]
34
+ /** The border that separates two adjacent renders. */
35
+ const BORDER = [96, 96, 104, 255]
36
+
37
+ /** Layout constants, in output pixels. */
38
+ const MARGIN = 20
39
+ const GAP = 16
40
+ const CAPTION_HEIGHT = 30
41
+ const CAPTION_SCALE = 2
42
+ const BORDER_WIDTH = 2
43
+ /** Target sheet width before the layout is fitted to the grid. */
44
+ const TARGET_WIDTH = 1600
45
+
46
+ /**
47
+ * @typedef {object} SheetView
48
+ * @property {string} viewId - the id the tool and the measurements use.
49
+ * @property {string} [label] - caption text; defaults to the view id.
50
+ * @property {Buffer} png - the rendered PNG bytes.
51
+ */
52
+
53
+ /**
54
+ * @typedef {object} SheetPlacement
55
+ * @property {string} viewId
56
+ * @property {string} label - the caption as DRAWN (the font has one case).
57
+ * @property {number} column - 0-based column in the grid.
58
+ * @property {number} row - 0-based row in the grid.
59
+ * @property {[number, number, number, number]} box - normalized [left, top, right, bottom]
60
+ * of the IMAGE TILE, so a caller can talk about "the tile at ..." without pixels.
61
+ * @property {[number, number, number, number]} captionBox - normalized box of the
62
+ * caption band above the tile. Reported separately rather than left to be derived:
63
+ * a caller that recomputes the layout is a second implementation of it, and the one
64
+ * that drifts is always the copy nobody draws.
65
+ */
66
+
67
+ /**
68
+ * Compose a contact sheet.
69
+ *
70
+ * @param {object} input
71
+ * @param {SheetView[]} input.views - in reading order (left to right, then down).
72
+ * @param {number} [input.columns] - grid columns; defaults to a near-square grid.
73
+ * @param {number} [input.scale] - tile upscale factor; defaults to the largest
74
+ * power of two that keeps the sheet near {@link TARGET_WIDTH}.
75
+ * @param {string} [input.title] - drawn top-left above the grid when present.
76
+ * @returns {{ png: Buffer, width: number, height: number, columns: number, rows: number, placements: SheetPlacement[] }}
77
+ */
78
+ export function composeContactSheet(input) {
79
+ const views = input?.views ?? []
80
+ if (views.length === 0) throw new Error('a contact sheet needs at least one view')
81
+
82
+ const decoded = views.map(view => {
83
+ if (!Buffer.isBuffer(view.png)) throw new Error(`view "${view.viewId}" has no PNG bytes`)
84
+ return { view, image: decodePng(view.png) }
85
+ })
86
+
87
+ // The first view defines the tile size. Views in one sheet come from one render
88
+ // plan, so they share a resolution; taking the first and scaling the rest to fit
89
+ // keeps a stray view from reflowing the whole grid.
90
+ const tileWidth = decoded[0].image.width
91
+ const tileHeight = decoded[0].image.height
92
+ if (tileWidth <= 0 || tileHeight <= 0) throw new Error('the first view decoded to an empty image')
93
+
94
+ const columns = clampInteger(input.columns ?? Math.ceil(Math.sqrt(views.length)), 1, views.length)
95
+ const rows = Math.ceil(views.length / columns)
96
+ const scale = clampInteger(input.scale ?? defaultScale(columns, tileWidth), 1, 8)
97
+
98
+ const cellWidth = tileWidth * scale
99
+ const cellHeight = tileHeight * scale
100
+ const titleHeight = typeof input.title === 'string' && input.title.length > 0
101
+ ? CAPTION_HEIGHT
102
+ : 0
103
+
104
+ const width = MARGIN * 2 + columns * cellWidth + (columns - 1) * GAP
105
+ const height = MARGIN * 2 + titleHeight + rows * (CAPTION_HEIGHT + cellHeight) + (rows - 1) * GAP
106
+
107
+ const sheet = createImage(width, height, BACKGROUND)
108
+ if (titleHeight > 0) {
109
+ drawText(sheet, input.title, MARGIN, MARGIN - 4, CAPTION_TEXT, CAPTION_SCALE)
110
+ }
111
+
112
+ /** @type {SheetPlacement[]} */
113
+ const placements = []
114
+ decoded.forEach((entry, index) => {
115
+ const column = index % columns
116
+ const row = Math.floor(index / columns)
117
+ const left = MARGIN + column * (cellWidth + GAP)
118
+ const top = MARGIN + titleHeight + row * (CAPTION_HEIGHT + cellHeight + GAP)
119
+ const label = entry.view.label ?? entry.view.viewId
120
+
121
+ fillRect(sheet, { x: left, y: top, width: cellWidth, height: CAPTION_HEIGHT }, CAPTION_BAND)
122
+ drawText(sheet, label, left + 6, top + 8, CAPTION_TEXT, CAPTION_SCALE)
123
+
124
+ const imageTop = top + CAPTION_HEIGHT
125
+ // A border, not a gap: two renders of the same scene differ along their shared
126
+ // edge most of all, and an unseparated pair reads as one wide image.
127
+ fillRect(sheet, { x: left, y: imageTop, width: cellWidth, height: cellHeight }, BORDER)
128
+ blendInto(entry.image, sheet, {
129
+ x: left + BORDER_WIDTH,
130
+ y: imageTop + BORDER_WIDTH,
131
+ width: cellWidth - BORDER_WIDTH * 2,
132
+ height: cellHeight - BORDER_WIDTH * 2,
133
+ })
134
+
135
+ placements.push({
136
+ viewId: entry.view.viewId,
137
+ label: label.toUpperCase(),
138
+ column,
139
+ row,
140
+ captionBox: [
141
+ round(left / width),
142
+ round(top / height),
143
+ round((left + cellWidth) / width),
144
+ round((top + CAPTION_HEIGHT) / height),
145
+ ],
146
+ box: [
147
+ round(left / width),
148
+ round(imageTop / height),
149
+ round((left + cellWidth) / width),
150
+ round((imageTop + cellHeight) / height),
151
+ ],
152
+ })
153
+ })
154
+
155
+ return {
156
+ png: encodePng(sheet),
157
+ width,
158
+ height,
159
+ columns,
160
+ rows,
161
+ placements,
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The largest power of two that keeps a sheet near the target width.
167
+ *
168
+ * Measured against the REQUEST pixel budget, not against aesthetics: a sheet wider
169
+ * than the budget is downscaled by the model pipeline, so upscaling past the point
170
+ * where the downscale lands at 1:1 adds bytes and no detail.
171
+ *
172
+ * @param {number} columns
173
+ * @param {number} tileWidth
174
+ * @returns {number}
175
+ */
176
+ function defaultScale(columns, tileWidth) {
177
+ const perTile = TARGET_WIDTH / columns
178
+ let scale = 1
179
+ while (scale * 2 * tileWidth <= perTile && scale < 8) scale *= 2
180
+ return scale
181
+ }
182
+
183
+ /**
184
+ * Clamp an integer option into a range, tolerating absent or fractional input.
185
+ * @param {unknown} value
186
+ * @param {number} minimum
187
+ * @param {number} maximum
188
+ * @returns {number}
189
+ */
190
+ function clampInteger(value, minimum, maximum) {
191
+ const number = Number(value)
192
+ if (!Number.isFinite(number)) return minimum
193
+ return Math.min(maximum, Math.max(minimum, Math.trunc(number)))
194
+ }
195
+
196
+ /** Round to six decimals: enough to locate a tile, small enough to read. */
197
+ function round(value) {
198
+ return Math.round(value * 1e6) / 1e6
199
+ }
200
+
201
+ /** Role names shortened to what the 26-character caption band can hold. */
202
+ const CAPTION_ROLE_ALIASES = Object.freeze({
203
+ 'active-camera': 'ACTIVE',
204
+ 'three-quarter': '3Q',
205
+ top: 'TOP',
206
+ detail: 'DETAIL',
207
+ front: 'FRONT',
208
+ side: 'SIDE',
209
+ back: 'BACK',
210
+ })
211
+
212
+ /**
213
+ * A short caption for one view that fits the band.
214
+ *
215
+ * The long role names are shortened rather than truncated, because a truncated
216
+ * caption is the one thing a label must not be: `THREE-QUARTER:CAMERA-T~` tells a
217
+ * reviewer less than `3Q:CAMERA-TOP` while looking like it told them something.
218
+ *
219
+ * @param {object} view - a view entry from a render plan.
220
+ * @returns {string}
221
+ */
222
+ export function viewCaption(view) {
223
+ const role = typeof view.role === 'string' && view.role.length > 0 ? view.role : null
224
+ const camera = String(view.cameraId ?? view.viewId ?? 'view')
225
+ const frame = view.frame ?? null
226
+ const label = role === null ? camera : `${CAPTION_ROLE_ALIASES[role] ?? role}:${camera}`
227
+ const caption = frame === null ? label : `${label} F${frame}`
228
+ // Uppercased here rather than at draw time so the string a caller sees IS the string
229
+ // on the sheet. A caption that reads "3Q:camera-top" to a human and "3Q:CAMERA-TOP"
230
+ // to the font is two representations of one label, which is how a reviewer ends up
231
+ // quoting a view id that nothing else uses.
232
+ return caption.toUpperCase()
233
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Where a DeepBlend deployment keeps its state, and where it finds the Blender
3
+ * that the installer put there.
4
+ *
5
+ * WHY THIS IS SHARED AND NOT WRITTEN TWICE
6
+ * ----------------------------------------
7
+ * Before this module, "where is the store" was answered in four unrelated places,
8
+ * and they agreed only because a human kept them in sync:
9
+ *
10
+ * 1. `bundle/cordis.patch.yml` — three literal absolute paths;
11
+ * 2. `tools/create-demo-project.mjs` — `<repo>/.deepblend`, with a comment
12
+ * saying it is "configured with exactly the values the installed bundle
13
+ * patch composes, so the project this produces is the one a restarted
14
+ * profile finds";
15
+ * 3. `tools/m3-delivery-acceptance.mjs`, `apply-brief-content.mjs`,
16
+ * `visual-review-live-probe.mjs` — the same literal, again;
17
+ * 4. SPEC §17, which asks for `~/.dsh/deepblend/projects` — a location NONE of
18
+ * the three above used.
19
+ *
20
+ * That is the D38 shape for the sixth time: several copies of one fact, compared
21
+ * by nothing. It is also why the bundle could not be installed on any machine but
22
+ * the one it was written on — the literal paths were not configuration, they were
23
+ * this developer's home directory, and no amount of documentation makes another
24
+ * machine grow that directory.
25
+ *
26
+ * So the answer lives here, once, as pure functions of `{ env, home }` that a
27
+ * test can call with any environment it likes. The plugin schemas resolve their
28
+ * defaults through it, and the repository's own tools resolve theirs through it,
29
+ * which is what makes "the project this produces is the one a restarted profile
30
+ * finds" true by construction instead of by comment.
31
+ *
32
+ * Owner: DeepBlend Studio — M5 (portability)
33
+ */
34
+
35
+ import { homedir } from 'node:os'
36
+ import { isAbsolute, join, resolve } from 'node:path'
37
+
38
+ /** The directory DeepBlend owns under the DSH home. */
39
+ export const DEEPBLEND_STATE_DIRECTORY = 'deepblend'
40
+
41
+ /** Directory the managed Blender is installed into, relative to an install root. */
42
+ export const MANAGED_TOOLS_DIRECTORY = '.tools'
43
+
44
+ /**
45
+ * The pinned Blender, relative to the managed tools directory.
46
+ *
47
+ * macOS ships an app bundle; a Linux tarball puts a `blender` binary at the root.
48
+ * Both are listed because the *deployment* may be either, and a candidate that
49
+ * does not exist costs one `existsSync`.
50
+ */
51
+ export const MANAGED_BLENDER_RELATIVE_PATHS = [
52
+ 'Blender.app/Contents/MacOS/Blender',
53
+ 'blender',
54
+ ]
55
+
56
+ /**
57
+ * Expand a leading `~` the way SPEC §17 writes paths (`~/.dsh/deepblend/projects`).
58
+ *
59
+ * A `${HOME}`-style interpolation is deliberately NOT supported: the spec example
60
+ * uses `~`, and a second syntax would be a second thing to get wrong.
61
+ *
62
+ * @param {string} value
63
+ * @param {string} [home]
64
+ * @returns {string} an absolute path
65
+ */
66
+ export function expandHome(value, home = homedir()) {
67
+ if (value === '~') return home
68
+ if (value.startsWith('~/')) return join(home, value.slice(2))
69
+ return isAbsolute(value) ? value : resolve(value)
70
+ }
71
+
72
+ /**
73
+ * The DSH home this process belongs to.
74
+ *
75
+ * `DSH_HOME` first — that is the harness's own override and the one a test or a
76
+ * second deployment sets — then `~/.dsh`, which is what the harness itself falls
77
+ * back to.
78
+ *
79
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [options]
80
+ * @returns {string} absolute path
81
+ */
82
+ export function resolveDshHome(options = {}) {
83
+ const env = options.env ?? process.env
84
+ const home = options.home ?? homedir()
85
+ const configured = env.DSH_HOME
86
+ if (typeof configured === 'string' && configured.length > 0) return expandHome(configured, home)
87
+ return join(home, '.dsh')
88
+ }
89
+
90
+ /**
91
+ * The default workspace root: everything DeepBlend writes lives under it.
92
+ *
93
+ * SPEC §17 asks for `~/.dsh/deepblend/projects`; this is the `workspaceRoot` that
94
+ * makes that path mean something, and `<workspaceRoot>/projects` is the store.
95
+ *
96
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [options]
97
+ * @returns {string} absolute path
98
+ */
99
+ export function resolveDefaultWorkspaceRoot(options = {}) {
100
+ return join(resolveDshHome(options), DEEPBLEND_STATE_DIRECTORY)
101
+ }
102
+
103
+ /**
104
+ * The `workspaceRoot` a composition row asked for, or the deployment default.
105
+ *
106
+ * One function rather than an `??` at each call site, because the pair
107
+ * (`workspaceRoot`, `projectsRoot`) must agree: the host hands the provider a
108
+ * staging path inside the former, and the provider refuses anything outside its
109
+ * own copy of it (SPEC §15.2). Two `??` expressions in two packages is exactly
110
+ * how those two drift apart.
111
+ *
112
+ * @param {string|undefined} configured - the row's value, if it set one
113
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [options]
114
+ * @returns {string} absolute path
115
+ */
116
+ export function resolveWorkspaceRoot(configured, options = {}) {
117
+ if (typeof configured === 'string' && configured.trim().length > 0) {
118
+ return expandHome(configured.trim(), options.home ?? homedir())
119
+ }
120
+ return resolveDefaultWorkspaceRoot(options)
121
+ }
122
+
123
+ /**
124
+ * The `projectsRoot` a composition row asked for, or `<workspaceRoot>/projects`.
125
+ *
126
+ * @param {string|undefined} configured - the row's value, if it set one
127
+ * @param {string} workspaceRoot - the already-resolved workspace root
128
+ * @param {{ home?: string }} [options]
129
+ * @returns {string} absolute path
130
+ */
131
+ export function resolveProjectsRoot(configured, workspaceRoot, options = {}) {
132
+ if (typeof configured === 'string' && configured.trim().length > 0) {
133
+ return expandHome(configured.trim(), options.home ?? homedir())
134
+ }
135
+ return join(workspaceRoot, 'projects')
136
+ }
137
+
138
+ /**
139
+ * Candidate absolute paths for a Blender installed by `tools/install-blender.mjs`
140
+ * under any of `roots`, in the order they should be tried.
141
+ *
142
+ * @param {string[]} roots - directories that might contain a `.tools` directory
143
+ * @returns {string[]} absolute paths, possibly empty, possibly non-existent
144
+ */
145
+ export function managedBlenderCandidates(roots) {
146
+ const candidates = []
147
+ for (const root of roots) {
148
+ for (const relative of MANAGED_BLENDER_RELATIVE_PATHS) {
149
+ candidates.push(join(root, MANAGED_TOOLS_DIRECTORY, relative))
150
+ }
151
+ }
152
+ return candidates
153
+ }