@deepblend/dsh-blender-ui 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.
Files changed (3) hide show
  1. package/lib/client.js +3044 -114
  2. package/lib/index.js +126 -5
  3. package/package.json +2 -2
package/lib/index.js CHANGED
@@ -253,7 +253,17 @@ export default class BlenderUiHost extends Service {
253
253
  return
254
254
  }
255
255
 
256
+ let releaseSignal = () => {}
256
257
  try {
258
+ if (matched.route.id === 'recipes.preview') {
259
+ const preview = await this.ctx.blenderStudio.readRecipePreview({ id: matched.params.recipeId, version: matched.params.version, digest: url.searchParams.get('digest') })
260
+ response.setHeader('content-type', preview.contentType)
261
+ response.setHeader('x-content-type-options', 'nosniff')
262
+ response.setHeader('cache-control', 'no-store')
263
+ response.setHeader('content-length', String(preview.size))
264
+ response.end(request.method === 'HEAD' ? undefined : preview.bytes)
265
+ return
266
+ }
257
267
  if (matched.route.id === 'artifacts.open') {
258
268
  await this._sendArtifact(request, response, matched.params)
259
269
  return
@@ -265,11 +275,26 @@ export default class BlenderUiHost extends Service {
265
275
  this._sendWorkbenchPage(request, response)
266
276
  return
267
277
  }
268
- const body = await readRequestBody(request)
278
+ const cancellable = ['project.assets.upload', 'project.assets.preview', 'project.preview',
279
+ 'project.assetUploads.create', 'project.assetUploads.file', 'project.assetUploads.complete'].includes(matched.route.id)
280
+ let signal
281
+ if (cancellable) {
282
+ const controller = new AbortController()
283
+ const abort = () => controller.abort()
284
+ const closed = () => { if (!response.writableEnded) abort() }
285
+ request.once?.('aborted', abort)
286
+ response.once?.('close', closed)
287
+ if (request.aborted) abort()
288
+ releaseSignal = () => { request.off?.('aborted', abort); response.off?.('close', closed) }
289
+ signal = controller.signal
290
+ }
291
+ const body = ['project.referenceImage.upload', 'project.assets.upload', 'project.assetUploads.file'].includes(matched.route.id) ? {} : await readRequestBody(request)
269
292
  const result = await this._handlers[matched.route.id]({
270
293
  params: matched.params,
271
294
  query: Object.fromEntries(url.searchParams.entries()),
272
295
  body,
296
+ request,
297
+ signal,
273
298
  })
274
299
  this._sendJson(response, 200, { ok: true, route: matched.route.id, hostApiVersion: HOST_API_VERSION, ...result })
275
300
  } catch (cause) {
@@ -285,6 +310,8 @@ export default class BlenderUiHost extends Service {
285
310
  hostApiVersion: HOST_API_VERSION,
286
311
  error: error.toJSON(),
287
312
  })
313
+ } finally {
314
+ releaseSignal()
288
315
  }
289
316
  }
290
317
 
@@ -390,6 +417,14 @@ export default class BlenderUiHost extends Service {
390
417
  * @returns {number}
391
418
  */
392
419
  export function statusForError(error) {
420
+ if (error?.code === 'UI_HOST_API_STALE') return 503
421
+ const referenceStatuses = { ASSET_REQUEST_INVALID: 400, ASSET_CONTENT_MISMATCH: 415, ASSET_TOO_LARGE: 413,
422
+ ASSET_FORMAT_UNAVAILABLE: 415, SCENE_VALIDATION_FAILED: 422, SCENE_SPEC_INVALID: 422,
423
+ PATH_SEGMENT_INVALID: 400, ASSET_HASH_MISMATCH: 409, ASSET_SOURCE_NOT_FOUND: 404 }
424
+ if (referenceStatuses[error.code]) return referenceStatuses[error.code]
425
+ if (error.code === 'RECIPE_NOT_FOUND') return 404
426
+ if (error.code === 'RECIPE_CHANGED' || error.code === 'RECIPE_ID_CONFLICT') return 409
427
+ if (error.code?.startsWith('RECIPE_')) return 400
393
428
  switch (error.code) {
394
429
  case BlenderErrorCode.PROJECT_NOT_FOUND:
395
430
  case BlenderErrorCode.REVISION_NOT_FOUND:
@@ -401,6 +436,7 @@ export function statusForError(error) {
401
436
  case BlenderErrorCode.SCENE_PATCH_INVALID:
402
437
  return 400
403
438
  case BlenderErrorCode.RENDER_JOB_CONFLICT:
439
+ case BlenderErrorCode.REVISION_CONFLICT:
404
440
  return 409
405
441
  default:
406
442
  return 500
@@ -458,6 +494,13 @@ async function readRequestBody(request) {
458
494
  */
459
495
  export function createHandlers(ctx) {
460
496
  const studio = () => ctx.blenderStudio
497
+ const assetStudio = method => {
498
+ const service = studio(), version = service?.hostApiVersion?.()
499
+ if (typeof service?.[method] !== 'function' || typeof version === 'number' && version < 5) {
500
+ throw new BlenderError('UI_HOST_API_STALE', 'The running Blender Host does not support the asset library. Restart with Host API 5 or newer.')
501
+ }
502
+ return service
503
+ }
461
504
  const approvalThreshold = () => studio().config?.requireApprovalAboveFrames ?? Number.POSITIVE_INFINITY
462
505
 
463
506
  return {
@@ -583,6 +626,7 @@ export function createHandlers(ctx) {
583
626
  const projectId = query.projectId ?? listed.projects[0]?.projectId ?? null
584
627
  return {
585
628
  panelId: UI_PANEL_ID,
629
+ recipeCatalog: studio().listRecipes?.() ?? { recipes: [], errors: [] },
586
630
  projects: listed.projects.map(record => buildProjectView(record)),
587
631
  projectsRoot: listed.projectsRoot,
588
632
  selected: projectId === null
@@ -591,6 +635,9 @@ export function createHandlers(ctx) {
591
635
  }
592
636
  },
593
637
 
638
+ 'recipes.list': async () => studio().listRecipes(),
639
+ 'recipes.preview': async ({ params, query }) => studio().readRecipePreview({ id: params.recipeId, version: params.version, digest: query.digest }),
640
+
594
641
  'projects.list': async () => {
595
642
  const listed = await studio().listProjects()
596
643
  return { projects: listed.projects.map(record => buildProjectView(record)), projectsRoot: listed.projectsRoot }
@@ -601,6 +648,7 @@ export function createHandlers(ctx) {
601
648
  title: body.title,
602
649
  goal: body.goal,
603
650
  sceneSpec: body.sceneSpec,
651
+ recipe: body.recipe,
604
652
  projectId: body.projectId,
605
653
  saveCheckpoint: true,
606
654
  renderPreview: body.renderPreview === true,
@@ -619,6 +667,7 @@ export function createHandlers(ctx) {
619
667
  revision: detail.revision,
620
668
  digest: detail.digest,
621
669
  compiled: detail.compiledSpec ?? null,
670
+ assetParts: detail.assetParts ?? [],
622
671
  }),
623
672
  }
624
673
  },
@@ -661,7 +710,13 @@ export function createHandlers(ctx) {
661
710
  return { previews: sets, artifactBase: `${UI_ROUTE_PREFIX}/artifacts/${encodeURIComponent(params.projectId)}/` }
662
711
  },
663
712
 
664
- 'project.preview': async ({ params, body }) => {
713
+ 'project.preview': async ({ params, body, signal }) => {
714
+ if (body.mode !== undefined) {
715
+ const version = studio()?.hostApiVersion?.()
716
+ if (!Number.isFinite(version) || version < 6) throw new BlenderError('UI_HOST_API_STALE', 'Fixed-view inspections require Blender Host API 6 or newer. Restart the Host.')
717
+ if (!['beauty', 'clay'].includes(body.mode)) throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, 'Unknown inspection mode.')
718
+ requiredRevision(body.revision)
719
+ }
665
720
  // `renderViews` answers with the rendered PNG bytes in `pngs`, which is not
666
721
  // lossless JSON and is not what the browser needs: the panel displays the
667
722
  // images through the artifact route, so the response carries the paths,
@@ -669,6 +724,9 @@ export function createHandlers(ctx) {
669
724
  const result = await studio().renderViews({
670
725
  projectId: params.projectId,
671
726
  revision: body.revision,
727
+ ...(body.mode === undefined ? {} : { mode: body.mode }),
728
+ ...(body.views === undefined ? {} : { views: Array.isArray(body.views) ? body.views.map(view => ({ id: view?.id, cameraId: view?.cameraId, frame: view?.frame })) : body.views }),
729
+ signal,
672
730
  samples: numberOrUndefined(body.samples),
673
731
  maxViews: numberOrUndefined(body.maxViews),
674
732
  reason: body.reason ?? 'preview rendered from the workbench UI',
@@ -676,6 +734,9 @@ export function createHandlers(ctx) {
676
734
  return {
677
735
  preview: {
678
736
  projectId: params.projectId,
737
+ ...(result.mode ? { mode: result.mode } : {}),
738
+ ...(result.sourceRevision ? { sourceRevision: result.sourceRevision } : {}),
739
+ ...(result.sourceDigest ? { sourceDigest: result.sourceDigest } : {}),
679
740
  revision: result.revision ?? body.revision ?? null,
680
741
  digest: result.digest ?? null,
681
742
  profile: result.profile ?? null,
@@ -698,6 +759,56 @@ export function createHandlers(ctx) {
698
759
  }
699
760
  },
700
761
 
762
+ 'project.referenceImage.upload': async ({ params, query, request }) => studio().uploadReferenceImage({
763
+ projectId: params.projectId, name: query.name, mediaType: request?.headers?.['content-type'], stream: request,
764
+ }),
765
+
766
+ 'project.assets.list': async ({ params, query }) => assetStudio('listAssets').listAssets({
767
+ projectId: params.projectId, ...(query.revision ? { revision: requiredRevision(query.revision) } : {}),
768
+ }),
769
+ 'project.assets.upload': async ({ params, query, request, signal }) => assetStudio('uploadAsset').uploadAsset({
770
+ projectId: params.projectId, name: query.name, mediaType: request?.headers?.['content-type'],
771
+ ...(query.license === undefined ? {} : { license: query.license }), stream: request, signal,
772
+ }),
773
+ 'project.assetUploads.create': async ({ params, body, signal }) => assetStudio('createAssetUpload').createAssetUpload({
774
+ projectId: params.projectId, entrypoint: body.entrypoint,
775
+ files: Array.isArray(body.files) ? body.files.map(file => ({ path: file?.path, bytes: file?.bytes })) : body.files,
776
+ ...(body.license === undefined ? {} : { license: body.license }), signal,
777
+ }),
778
+ 'project.assetUploads.get': async ({ params }) => assetStudio('getAssetUpload').getAssetUpload({
779
+ projectId: params.projectId, uploadId: params.uploadId,
780
+ }),
781
+ 'project.assetUploads.file': async ({ params, request, signal }) => assetStudio('uploadAssetFile').uploadAssetFile({
782
+ projectId: params.projectId, uploadId: params.uploadId, fileId: params.fileId, stream: request, signal,
783
+ }),
784
+ 'project.assetUploads.complete': async ({ params, signal }) => assetStudio('completeAssetUpload').completeAssetUpload({
785
+ projectId: params.projectId, uploadId: params.uploadId, signal,
786
+ }),
787
+ 'project.assetUploads.cancel': async ({ params }) => assetStudio('cancelAssetUpload').cancelAssetUpload({
788
+ projectId: params.projectId, uploadId: params.uploadId,
789
+ }),
790
+ 'project.assets.preview': async ({ params, body, signal }) => assetStudio('previewAsset').previewAsset({
791
+ projectId: params.projectId, assetId: params.assetId, sha256: body.sha256, assetPath: body.assetPath, signal,
792
+ }),
793
+
794
+ 'project.review': async ({ params, body }) => {
795
+ const revision = requiredRevision(body.revision)
796
+ const result = await studio().visualReview({ projectId: params.projectId, revision, consultReviewer: true,
797
+ width: 640, height: 480, samples: 16 })
798
+ return { revision: result.revision ?? revision,
799
+ review: buildQaView({ projectId: params.projectId, revision, review: result }).visual }
800
+ },
801
+
802
+ 'project.autofix': async ({ params, body }) => {
803
+ const revision = requiredRevision(body.revision)
804
+ const maxIterations = body.maxIterations ?? 1
805
+ if (!Number.isInteger(maxIterations) || maxIterations < 1 || maxIterations > 3) {
806
+ throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, 'maxIterations must be an integer from 1 to 3.')
807
+ }
808
+ return { run: await studio().visualLoop({ projectId: params.projectId, revision, autoFix: true,
809
+ maxIterations, width: 640, height: 480, samples: 16 }) }
810
+ },
811
+
701
812
  'project.patch': async ({ params, body }) => {
702
813
  // The patch is passed through verbatim: the Host validates it against the
703
814
  // ScenePatch schema and the base revision, and it is the only writer.
@@ -710,6 +821,7 @@ export function createHandlers(ctx) {
710
821
  const result = await studio().restoreRevision({
711
822
  projectId: params.projectId,
712
823
  revision: body.revision,
824
+ expectedCurrentRevision: body.expectedCurrentRevision,
713
825
  reason: body.reason ?? 'restored from the workbench UI',
714
826
  actor: body.actor ?? 'ui',
715
827
  })
@@ -748,7 +860,8 @@ export function createHandlers(ctx) {
748
860
  frameStart: numberOrUndefined(body.frameStart),
749
861
  frameEnd: numberOrUndefined(body.frameEnd),
750
862
  samples: numberOrUndefined(body.samples),
751
- profile: body.profile,
863
+ // Keep the HTTP field stable while using the Host's actual parameter.
864
+ profileName: body.profile,
752
865
  reason: body.reason ?? 'started from the workbench UI',
753
866
  }
754
867
  // A resume is a different host entry point, and the UI must not have to
@@ -763,7 +876,7 @@ export function createHandlers(ctx) {
763
876
  // sitting right there in the panel.
764
877
  const result = resumeJobId === null
765
878
  ? await studio().startFinalRender(request)
766
- : await studio().resumeRenderJob({ ...request, jobId: resumeJobId })
879
+ : await studio().resumeRenderJob({ projectId: params.projectId, jobId: resumeJobId })
767
880
  return { job: result, resumed: resumeJobId !== null }
768
881
  },
769
882
  }
@@ -798,7 +911,7 @@ async function buildProjectState(studio, projectId, revision, threshold) {
798
911
  project: buildProjectView({
799
912
  projectId,
800
913
  title: overview.title,
801
- goal: record?.goal ?? null,
914
+ goal: scene.spec?.project?.goal ?? '',
802
915
  currentRevision: overview.currentRevision,
803
916
  revisionCount: overview.revisionCount,
804
917
  createdAt: record?.createdAt ?? null,
@@ -811,6 +924,7 @@ async function buildProjectState(studio, projectId, revision, threshold) {
811
924
  revision: scene.revision,
812
925
  digest: scene.digest,
813
926
  compiled: scene.compiledSpec ?? null,
927
+ assetParts: scene.assetParts ?? [],
814
928
  }),
815
929
  revisions: overview.revisions ?? [],
816
930
  jobs: jobs.jobs.map(job => buildJobView(job, { threshold })),
@@ -842,3 +956,10 @@ function numberOrUndefined(value) {
842
956
  * @returns {Record<string, unknown>}
843
957
  */
844
958
  export { buildSettingsCard }
959
+
960
+ function requiredRevision(value) {
961
+ if (typeof value !== 'string' || !/^r[0-9]+$/.test(value)) {
962
+ throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, 'Select a saved revision before reviewing it.')
963
+ }
964
+ return value
965
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.2.20",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@deepblend/dsh-blender-contracts": "0.2.20"
22
+ "@deepblend/dsh-blender-contracts": "0.3.0"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {