| import { app } from '../../../scripts/app.js' |
| import { api } from '../../../scripts/api.js' |
| import { $el } from '../../../scripts/ui.js' |
|
|
| const getLocalData = key => { |
| let data = {} |
| try { |
| data = JSON.parse(localStorage.getItem(key)) || {} |
| } catch (error) { |
| return {} |
| } |
| return data |
| } |
| function getContentTypeFromBase64 (base64Data) { |
| const regex = /^data:(.+);base64,/ |
| const matches = base64Data.match(regex) |
| if (matches && matches.length >= 2) { |
| return matches[1] |
| } |
| return null |
| } |
| function base64ToBlobFromURL (base64URL, contentType) { |
| return fetch(base64URL).then(response => response.blob()) |
| } |
| const setLocalDataOfWin = (key, value) => { |
| localStorage.setItem(key, JSON.stringify(value)) |
| |
| } |
| async function uploadImage (blob, fileType = '.svg', filename) { |
| |
| const body = new FormData() |
| body.append( |
| 'image', |
| new File([blob], (filename || new Date().getTime()) + fileType) |
| ) |
|
|
| const resp = await api.fetchApi('/upload/image', { |
| method: 'POST', |
| body |
| }) |
|
|
| |
| let data = await resp.json() |
| let { name, subfolder } = data |
| let src = api.apiURL( |
| `/view?filename=${encodeURIComponent( |
| name |
| )}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}` |
| ) |
|
|
| return src |
| } |
|
|
| function createImage (url) { |
| let im = new Image() |
| return new Promise((res, rej) => { |
| im.onload = () => res(im) |
| im.src = url |
| }) |
| } |
|
|
| const parseImage = url => { |
| return new Promise((res, rej) => { |
| fetch(url) |
| .then(response => response.blob()) |
| .then(blob => { |
| const reader = new FileReader() |
| reader.onloadend = () => { |
| const base64data = reader.result |
| res(base64data) |
| |
| } |
| reader.readAsDataURL(blob) |
| }) |
| .catch(error => { |
| console.log('发生错误:', error) |
| }) |
| }) |
| } |
|
|
| function get_position_style (ctx, widget_width, y, node_height) { |
| const MARGIN = 4 |
|
|
| |
| const elRect = ctx.canvas.getBoundingClientRect() |
| const transform = new DOMMatrix() |
| .scaleSelf( |
| elRect.width / ctx.canvas.width, |
| elRect.height / ctx.canvas.height |
| ) |
| .multiplySelf(ctx.getTransform()) |
| .translateSelf(MARGIN, MARGIN + y) |
|
|
| return { |
| transformOrigin: '0 0', |
| transform: transform, |
| left: `0`, |
| top: `0`, |
| cursor: 'pointer', |
| position: 'absolute', |
| maxWidth: `${widget_width - MARGIN * 2}px`, |
| |
| width: `${widget_width - MARGIN * 2}px`, |
| |
| |
| display: 'flex', |
| flexDirection: 'column', |
| |
| justifyContent: 'space-around' |
| } |
| } |
|
|
| async function extractMaterial ( |
| modelViewerVariants, |
| selectMaterial, |
| material_img |
| ) { |
| |
| const materialsNames = [] |
| for ( |
| let index = 0; |
| index < modelViewerVariants.model.materials.length; |
| index++ |
| ) { |
| let m = modelViewerVariants.model.materials[index] |
| let thumbUrl |
| try { |
| thumbUrl = |
| await m.pbrMetallicRoughness.baseColorTexture.texture.source.createThumbnail( |
| 1024, |
| 1024 |
| ) |
| } catch (error) {} |
| if (thumbUrl) |
| materialsNames.push({ |
| value: m.name, |
| text: `#${index} ${m.name}`, |
| index, |
| thumbUrl |
| }) |
| } |
|
|
| selectMaterial.innerHTML = '' |
| material_img.innerHTML = '' |
|
|
| for (let index = 0; index < materialsNames.length; index++) { |
| const name = materialsNames[index] |
| const option = document.createElement('option') |
| option.value = name.thumbUrl |
| option.textContent = name.text |
| option.setAttribute('data-index', index) |
| selectMaterial.appendChild(option) |
| let img = new Image() |
| img.src = name.thumbUrl |
| |
| img.style.width = '40px' |
| material_img.appendChild(img) |
| if (index == 0) { |
| material_img.setAttribute('src', name.thumbUrl) |
| } |
| } |
| } |
|
|
| async function changeMaterial ( |
| modelViewerVariants, |
| targetMaterial, |
| newImageUrl |
| ) { |
| const targetTexture = await modelViewerVariants.createTexture(newImageUrl) |
| |
| targetMaterial.pbrMetallicRoughness.baseColorTexture.setTexture(targetTexture) |
| } |
|
|
| app.registerExtension({ |
| name: 'Mixlab.3D.3DImage', |
| async getCustomWidgets (app) { |
| return { |
| THREED (node, inputName, inputData, app) { |
| |
| const widget = { |
| type: inputData[0], |
| name: inputName, |
| size: [128, 88], |
| draw (ctx, node, width, y) {}, |
| computeSize (...args) { |
| return [128, 88] |
| }, |
| async serializeValue (nodeId, widgetIndex) { |
| let d = getLocalData('_mixlab_3d_image') |
| |
| if (d && d[node.id]) { |
| let { url, bg, material } = d[node.id] |
| let data = {} |
| if (url) { |
| data.image = await parseImage(url) |
| } |
| if (bg) { |
| data.bg_image = await parseImage(bg) |
| if (!data.bg_image.match('data:image/')) { |
| delete data.bg_image |
| } |
| } |
|
|
| if (material) { |
| data.material = await parseImage(material) |
| } |
|
|
| return JSON.parse(JSON.stringify(data)) |
| } else { |
| return {} |
| } |
| } |
| } |
| node.addCustomWidget(widget) |
| return widget |
| } |
| } |
| }, |
|
|
| async beforeRegisterNodeDef (nodeType, nodeData, app) { |
| if (nodeType.comfyClass == '3DImage') { |
| const orig_nodeCreated = nodeType.prototype.onNodeCreated |
| nodeType.prototype.onNodeCreated = async function () { |
| orig_nodeCreated?.apply(this, arguments) |
|
|
| const uploadWidget = this.widgets.filter(w => w.name == 'upload')[0] |
|
|
| const widget = { |
| type: 'div', |
| name: 'upload-preview', |
| draw (ctx, node, widget_width, y, widget_height) { |
| Object.assign( |
| this.div.style, |
| get_position_style(ctx, widget_width, 88, node.size[1]) |
| ) |
| } |
| } |
|
|
| widget.div = $el('div', {}) |
| widget.div.style.width = `120px` |
|
|
| document.body.appendChild(widget.div) |
|
|
| const inputDiv = (key, placeholder, preview) => { |
| let div = document.createElement('div') |
| const ip = document.createElement('input') |
| ip.type = 'file' |
| ip.className = `${'comfy-multiline-input'} ${placeholder}` |
| div.style = `display: flex; |
| align-items: center; |
| margin: 6px 8px; |
| margin-top: 0;` |
| ip.placeholder = placeholder |
| |
|
|
| ip.style = `outline: none; |
| border: none; |
| padding: 4px; |
| width: 60%;cursor: pointer; |
| height: 32px;` |
| const label = document.createElement('label') |
| label.style = 'font-size: 10px;min-width:32px' |
| label.innerText = placeholder |
| div.appendChild(label) |
| div.appendChild(ip) |
|
|
| let that = this, |
| filename = new Date().getTime() |
|
|
| ip.addEventListener('change', async event => { |
| const file = event.target.files[0] |
| const reader = new FileReader() |
| filename = new Date().getTime() |
| |
| reader.onload = async e => { |
| const fileURL = URL.createObjectURL(file) |
| |
| let html = `<model-viewer src="${fileURL}" |
| min-field-of-view="0deg" max-field-of-view="180deg" |
| shadow-intensity="1" |
| camera-controls |
| touch-action="pan-y"> |
| |
| <div class="controls"> |
| <div>Variant: <select class="variant"></select></div> |
| <div>Material: <select class="material"></select></div> |
| <div>Material: <div class="material_img"> </div></div> |
| <div><button class="bg">BG</button></div> |
| <div><button class="export">Export GLB</button></div> |
| |
| </div></model-viewer>` |
|
|
| preview.innerHTML = html |
| if (that.size[1] < 400) { |
| that.setSize([that.size[0], that.size[1] + 300]) |
| app.canvas.draw(true, true) |
| } |
|
|
| const modelViewerVariants = preview.querySelector('model-viewer') |
| const select = preview.querySelector('.variant') |
| const selectMaterial = preview.querySelector('.material') |
| const material_img = preview.querySelector('.material_img') |
| const bg = preview.querySelector('.bg') |
| const exportGLB = preview.querySelector('.export') |
|
|
| if (modelViewerVariants) { |
| modelViewerVariants.style.width = `${that.size[0] - 24}px` |
| modelViewerVariants.style.height = `${that.size[1] - 48}px` |
| } |
|
|
| modelViewerVariants.addEventListener('load', async () => { |
| const names = modelViewerVariants.availableVariants |
|
|
| |
| for (const name of names) { |
| const option = document.createElement('option') |
| option.value = name |
| option.textContent = name |
| select.appendChild(option) |
| } |
| |
| if (names.length === 0) { |
| const option = document.createElement('option') |
| option.value = 'default' |
| option.textContent = 'Default' |
| select.appendChild(option) |
| } |
|
|
| |
| extractMaterial( |
| modelViewerVariants, |
| selectMaterial, |
| material_img |
| ) |
| }) |
|
|
| let timer = null |
| const delay = 500 |
|
|
| async function checkCameraChange () { |
| let dd = getLocalData(key) |
| let base64Data = modelViewerVariants.toDataURL() |
|
|
| const contentType = getContentTypeFromBase64(base64Data) |
|
|
| const blob = await base64ToBlobFromURL(base64Data, contentType) |
|
|
| |
| let url = await uploadImage(blob, '.png') |
| |
|
|
| let bg_blob = await base64ToBlobFromURL( |
| 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN88uXrPQAFwwK/6xJ6CQAAAABJRU5ErkJggg==' |
| ) |
| let url_bg = await uploadImage(bg_blob, '.png') |
| |
|
|
| if (!dd[that.id]) { |
| dd[that.id] = { url, bg: url_bg } |
| } else { |
| dd[that.id] = { ...dd[that.id], url } |
| } |
|
|
| |
| let thumbUrl = material_img.getAttribute('src') |
| if (thumbUrl) { |
| let tb = await base64ToBlobFromURL(thumbUrl) |
| let tUrl = await uploadImage(tb, '.png') |
| |
| dd[that.id].material = tUrl |
| } |
|
|
| setLocalDataOfWin(key, dd) |
| } |
|
|
| function startTimer () { |
| if (timer) clearTimeout(timer) |
| timer = setTimeout(checkCameraChange, delay) |
| } |
|
|
| modelViewerVariants.addEventListener('camera-change', startTimer) |
|
|
| select.addEventListener('input', async event => { |
| modelViewerVariants.variantName = |
| event.target.value === 'default' ? null : event.target.value |
| |
| await extractMaterial( |
| modelViewerVariants, |
| selectMaterial, |
| material_img |
| ) |
| checkCameraChange() |
| }) |
|
|
| selectMaterial.addEventListener('input', event => { |
| |
| material_img.setAttribute('src', selectMaterial.value) |
|
|
| if (selectMaterial.getAttribute('data-new-material')) { |
| let index = |
| ~~selectMaterial.selectedOptions[0].getAttribute( |
| 'data-index' |
| ) |
| changeMaterial( |
| modelViewerVariants, |
| modelViewerVariants.model.materials[index], |
| selectMaterial.getAttribute('data-new-material') |
| ) |
| } |
|
|
| checkCameraChange() |
| }) |
|
|
| bg.addEventListener('click', () => { |
| |
| var input = document.createElement('input') |
| input.type = 'file' |
|
|
| |
| input.addEventListener('change', function () { |
| |
| var file = input.files[0] |
|
|
| |
| var reader = new FileReader() |
|
|
| |
| reader.addEventListener('load', async () => { |
| let base64 = reader.result |
| |
| preview.style.backgroundImage = 'url(' + base64 + ')' |
|
|
| const contentType = getContentTypeFromBase64(base64) |
|
|
| const blob = await base64ToBlobFromURL(base64, contentType) |
|
|
| |
| let bg_url = await uploadImage(blob, '.png') |
| let bg_img = await createImage(base64) |
|
|
| let dd = getLocalData(key) |
| |
| if (!dd[that.id]) dd[that.id] = { url: '', bg: bg_url } |
| dd[that.id] = { |
| ...dd[that.id], |
| bg: bg_url, |
| bg_w: bg_img.naturalWidth, |
| bg_h: bg_img.naturalHeight |
| } |
|
|
| setLocalDataOfWin(key, dd) |
|
|
| |
| let w = that.size[0] - 24, |
| h = (w * bg_img.naturalHeight) / bg_img.naturalWidth |
|
|
| if (modelViewerVariants) { |
| modelViewerVariants.style.width = `${w}px` |
| modelViewerVariants.style.height = `${h}px` |
| } |
| preview.style.width = `${w}px` |
| }) |
|
|
| |
| reader.readAsDataURL(file) |
| }) |
|
|
| |
| input.click() |
| }) |
|
|
| exportGLB.addEventListener('click', async () => { |
| const glTF = await modelViewerVariants.exportScene() |
| const file = new File([glTF], 'export.glb') |
| const link = document.createElement('a') |
| link.download = file.name |
| link.href = URL.createObjectURL(file) |
| link.click() |
| }) |
|
|
| uploadWidget.value = await uploadWidget.serializeValue() |
|
|
| |
| let dd = getLocalData(key) |
| |
| if (dd[that.id]) { |
| const { bg_w, bg_h } = dd[that.id] |
| if (bg_h && bg_w) { |
| let w = that.size[0] - 24, |
| h = (w * bg_h) / bg_w |
|
|
| if (modelViewerVariants) { |
| modelViewerVariants.style.width = `${w}px` |
| modelViewerVariants.style.height = `${h}px` |
| } |
| preview.style.width = `${w}px` |
| } |
| } |
| } |
|
|
| |
| reader.readAsDataURL(file) |
| }) |
| return div |
| } |
|
|
| let preview = document.createElement('div') |
| preview.className = 'preview' |
| preview.style = `margin-top: 12px;display: flex; |
| justify-content: center; |
| align-items: center;background-repeat: no-repeat;background-size: contain;` |
|
|
| let upload = inputDiv('_mixlab_3d_image', '3D Model', preview) |
|
|
| widget.div.appendChild(upload) |
| widget.div.appendChild(preview) |
| this.addCustomWidget(widget) |
|
|
| const onResize = this.onResize |
| let that = this |
| this.onResize = function () { |
| let modelViewerVariants = preview.querySelector('model-viewer') |
|
|
| |
| let dd = getLocalData('_mixlab_3d_image') |
| |
| if (dd[that.id]) { |
| const { bg_w, bg_h } = dd[that.id] |
| if (bg_h && bg_w) { |
| let w = that.size[0] - 24, |
| h = (w * bg_h) / bg_w |
|
|
| if (modelViewerVariants) { |
| modelViewerVariants.style.width = `${w}px` |
| modelViewerVariants.style.height = `${h}px` |
| } |
| preview.style.width = `${w}px` |
| } |
| } |
|
|
| return onResize?.apply(this, arguments) |
| } |
|
|
| const onRemoved = this.onRemoved |
| this.onRemoved = () => { |
| upload.remove() |
| preview.remove() |
| widget.div.remove() |
| return onRemoved?.() |
| } |
|
|
| if (this.onResize) { |
| this.onResize(this.size) |
| } |
| |
| this.serialize_widgets = false |
| } |
|
|
| const onExecuted = nodeType.prototype.onExecuted |
| nodeType.prototype.onExecuted = function (message) { |
| const r = onExecuted?.apply?.(this, arguments) |
|
|
| let div = this.widgets.filter(d => d.div)[0]?.div |
| console.log('Test', this.widgets) |
|
|
| let material = message.material[0] |
| if (material) { |
| const { filename, subfolder, type } = material |
| let src = api.apiURL( |
| `/view?filename=${encodeURIComponent( |
| filename |
| )}&type=${type}&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}` |
| ) |
|
|
| const modelViewerVariants = div.querySelector('model-viewer') |
|
|
| const selectMaterial = div.querySelector('.material') |
|
|
| let index = |
| ~~selectMaterial.selectedOptions[0].getAttribute('data-index') |
|
|
| selectMaterial.setAttribute('data-new-material', src) |
|
|
| changeMaterial( |
| modelViewerVariants, |
| modelViewerVariants.model.materials[index], |
| src |
| ) |
| } |
|
|
| this.onResize?.(this.size) |
|
|
| return r |
| } |
| } |
| }, |
| async loadedGraphNode (node, app) { |
| |
| |
| const sleep = (t = 1000) => { |
| return new Promise((res, rej) => { |
| setTimeout(() => res(1), t) |
| }) |
| } |
| if (node.type === '3DImage') { |
| |
| let widget = node.widgets.filter(w => w.name === 'upload-preview')[0] |
|
|
| let dd = getLocalData('_mixlab_3d_image') |
|
|
| let id = node.id |
| |
| if (!dd[id]) return |
|
|
| let { url, bg } = dd[id] |
| if (!url) return |
| |
|
|
| let pre = widget.div.querySelector('.preview') |
| pre.style.width = `${node.size[0]}px` |
| pre.innerHTML = ` |
| ${url ? `<img src="${url}" style="width:100%"/>` : ''} |
| ` |
| pre.style.backgroundImage = 'url(' + bg + ')' |
|
|
| const uploadWidget = node.widgets.filter(w => w.name == 'upload')[0] |
| uploadWidget.value = await uploadWidget.serializeValue() |
| } |
| } |
| }) |
|
|