import { app } from '../../../scripts/app.js' import { api } from '../../../scripts/api.js' import { $el } from '../../../scripts/ui.js' import { loadExternalScript, get_position_style } from './common.js' function setCameraOrbit (modelview, distant, angles, screenNumber) { //2.1 20 // const angles = { // 1: -20.0, // 2: -17.9, // 3: -15.8, // 4: -13.7, // 5: -11.6, // 6: -9.5, // 7: -7.4, // 8: -5.3, // 9: -3.2, // 10: -1.1, // 11: 1.1, // 12: 3.2, // 13: 5.3, // 14: 7.4, // 15: 9.5, // 16: 11.6, // 17: 13.7, // 18: 15.8, // 19: 17.9, // 20: 20.0 // }; // 12 3.6 // const angles = { // 1: -20.0, // 2: -16.4, // 3: -12.7, // 4: -9.1, // 5: -5.5, // 6: -1.8, // 7: 1.8, // 8: 5.5, // 9: 9.1, // 10: 12.7, // 11: 16.4, // 12: 20.0 // } const angle = angles[screenNumber] let co=modelview.cameraOrbit.split(" ") if (angle !== undefined) { modelview.cameraOrbit = `${angle}deg ${co[1]} ${distant}m` console.log(screenNumber, angle) } else { console.error('Invalid screen number') } } 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)) // window[key] = value } async function uploadImage_ (blob, fileType = '.svg', filename) { // const blob = await (await fetch(src)).blob(); 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 }) // console.log(resp) let data = await resp.json() return data } async function uploadImage (blob, fileType = '.svg', filename) { let data = await uploadImage_(blob, fileType, filename) 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) // 在这里可以将base64数据用于进一步处理或显示图片 } reader.readAsDataURL(blob) }) .catch(error => { console.log('发生错误:', error) }) }) } 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.setAttribute('data-index',name.index) 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) } function inputFileClick (isFileURL = false, isGlb = false) { return new Promise((res, rej) => { // 创建一个input元素 var input = document.createElement('input') input.type = 'file' input.accept = isGlb ? '.glb' : 'image/*' // 监听input的change事件 input.addEventListener('change', function () { // 获取上传的文件 var file = input.files[0] if (isFileURL) { res(URL.createObjectURL(file)) return } // 创建一个FileReader对象来读取文件 var reader = new FileReader() // 监听FileReader的load事件 reader.addEventListener('load', async () => { let base64 = reader.result input.remove() res(base64) }) // 读取文件 reader.readAsDataURL(file) }) // 触发input的点击事件 input.click() }) } app.registerExtension({ name: 'Mixlab.3D.3DImage', async getCustomWidgets (app) { return { THREED (node, inputName, inputData, app) { // console.log('##node', node, inputName, inputData) const widget = { type: inputData[0], // the type, CHEESE name: inputName, // the name, slice size: [128, 88], // a default size draw (ctx, node, width, y) {}, computeSize (...args) { return [128, 88] // a method to compute the current size of the widget }, async serializeValue (nodeId, widgetIndex) { let d = getLocalData('_mixlab_3d_image') // console.log('serializeValue', node) if (d && d[node.id]) { let { url, bg, material, images } = 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) } if (images) { data.images = images } return JSON.parse(JSON.stringify(data)) } else { return {} } } } node.addCustomWidget(widget) return widget } } }, async init () { await loadExternalScript('/mixlab/app/lib/model-viewer.min.js', 'module') }, async beforeRegisterNodeDef (nodeType, nodeData, app) { if (nodeType.comfyClass == '3DImage') { console.log('nodeType.comfyClass', nodeType.comfyClass) 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 - 122, 88, node.size[1], 44) ) } } 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('button') ip.className = `${'comfy-multiline-input'} ${placeholder}` div.style = `display: flex; align-items: center; margin: 6px 8px; margin-top: 0;` ip.style = `outline: none; border: none; padding: 4px; width: 100px;cursor: pointer; height: 32px;` ip.innerText = placeholder div.appendChild(ip) let that = this ip.addEventListener('click', async event => { let fileURL = await inputFileClick(true, true) // console.log('文件URL: ', fileURL) let html = `
Variant:
Material:
Material:
` 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') const ddcap_distant = preview.querySelector('.ddcap_distant') const total_images = preview.querySelector('.total_images') const ddcap_range = preview.querySelector('.ddcap_range') const ddCap = preview.querySelector('.ddcap') const sleep = (t = 1000) => { return new Promise((res, rej) => { return setTimeout(() => { res(t) }, t) }) } async function captureImage (isUrl = true) { let base64Data = modelViewerVariants.toDataURL() const contentType = getContentTypeFromBase64(base64Data) const blob = await base64ToBlobFromURL(base64Data, contentType) if (isUrl) return await uploadImage(blob, '.png') return await uploadImage_(blob, '.png') } async function captureImages ( ddcap_range = 1, total_images = 12, distant = 0.23 ) { // 初始 角度 var center = modelViewerVariants.getBoundingBoxCenter().toString() modelViewerVariants.cameraTarget = center const startAngle = -((total_images - 1) / 2) * ddcap_range const angles = {} for (let i = 0; i < total_images; i++) { angles[i + 1] = startAngle + i * ddcap_range } console.log(angles) let frames = [] modelViewerVariants.removeAttribute('camera-controls') for (let i = 0; i < total_images; i++) { setCameraOrbit(modelViewerVariants, distant, angles, i + 1) // modelViewerVariants.cameraOrbit = `${currentAngle}deg ${initialCameraOrbit[1]} ${initialCameraOrbit[2]}` await sleep(1000) // console.log(`Capturing image at angle: ${currentAngle}deg`) let file = await captureImage(false) frames.push(file) // currentAngle += angleIncrement } await sleep(1000) // 恢复到初始旋转角度 // modelViewerVariants.cameraOrbit = initialCameraOrbit.join(' ') modelViewerVariants.setAttribute('camera-controls', '') return frames } ddCap.addEventListener('click', async e => { const distant = Number(ddcap_distant.value), // 23m totalImages = Number(total_images.value), angleIncrement = Number(ddcap_range.value) console.log(angleIncrement, totalImages) let images = await captureImages( angleIncrement, totalImages, distant ) let dd = getLocalData(key) dd[that.id].images = images setLocalDataOfWin(key, dd) }) ddcap_distant.addEventListener('input', async e => { // console.log(ddcap_distant.value) const center = modelViewerVariants.getBoundingBoxCenter().toString() modelViewerVariants.cameraTarget = center; const initialCameraOrbit = modelViewerVariants.cameraOrbit.split(' ') modelViewerVariants.cameraOrbit = `${initialCameraOrbit[2]} ${initialCameraOrbit[1]} ${ddcap_distant.value}m` modelViewerVariants.setAttribute('camera-controls', '') }) // ddcap_range_top.addEventListener('input', async e => { // // console.log(ddcap_range.value) // const initialCameraOrbit = // modelViewerVariants.cameraOrbit.split(' ') // modelViewerVariants.cameraOrbit = `${initialCameraOrbit[0]} ${ddcap_range_top.value}deg ${initialCameraOrbit[2]}` // modelViewerVariants.setAttribute('camera-controls', '') // }) if (modelViewerVariants) { modelViewerVariants.style.width = `${that.size[0] - 48}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) } // Adds a default 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) // const fileBlob = new Blob([e.target.result], { type: file.type }); let url = await captureImage() let bg_blob = await base64ToBlobFromURL( 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN88uXrPQAFwwK/6xJ6CQAAAABJRU5ErkJggg==' ) let url_bg = await uploadImage(bg_blob, '.png') // console.log('url_bg',url_bg) 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') // console.log('材质贴图', tUrl, thumbUrl) 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 => { // console.log(selectMaterial.value) 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 const updateBgData = (id, key, url, w, h) => { let dd = getLocalData(key) // console.log(dd[that.id],url) if (!dd[id]) dd[id] = { url: '', bg: url } dd[id] = { ...dd[id], bg: url, bg_w: w, bg_h: h } setLocalDataOfWin(key, dd) } bg.addEventListener('click', async () => { //更新bg updateBgData(that.id, key, '', 0, 0) preview.style.backgroundImage = 'none' let base64 = await inputFileClick(false, false) // 将读取的文件内容设置为div的背景 preview.style.backgroundImage = 'url(' + base64 + ')' const contentType = getContentTypeFromBase64(base64) const blob = await base64ToBlobFromURL(base64, contentType) // const fileBlob = new Blob([e.target.result], { type: file.type }); let bg_url = await uploadImage(blob, '.png') let bg_img = await createImage(base64) //更新bg updateBgData( that.id, key, bg_url, bg_img.naturalWidth, bg_img.naturalHeight ) // 更新尺寸 let w = that.size[0] - 128, 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` }) 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) // console.log(dd[that.id],bg_url) if (dd[that.id]) { const { bg_w, bg_h } = dd[that.id] if (bg_h && bg_w) { let w = that.size[0] - 48, h = (w * bg_h) / bg_w if (modelViewerVariants) { modelViewerVariants.style.width = `${w}px` modelViewerVariants.style.height = `${h}px` } preview.style.width = `${w}px` } } }) 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] let w = that.size[0] - 128 preview.style.width = `${w}px` console.log('更新尺寸', w) if (modelViewerVariants) { modelViewerVariants.style.width = `${w}px` modelViewerVariants.style.height = `${Math.round( that.size[1] * 0.8 )}px` } if (bg_h && bg_w) { let h = (w * bg_h) / bg_w if (modelViewerVariants) { modelViewerVariants.style.height = `${h}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.isVirtualNode = true 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) { // Fires every time a node is constructed // You can modify widgets/add handlers/etc here const sleep = (t = 1000) => { return new Promise((res, rej) => { setTimeout(() => res(1), t) }) } if (node.type === '3DImage') { // await sleep(0) let widget = node.widgets.filter(w => w.name === 'upload-preview')[0] let dd = getLocalData('_mixlab_3d_image') let id = node.id // console.log('3dImage load', node.widgets[0], node.widgets) if (!dd[id]) return let { url, bg } = dd[id] if (!url) return // let base64 = await parseImage(url) let pre = widget.div.querySelector('.preview') pre.style.width = `${node.size[0] - 24}px` pre.innerHTML = ` ${url ? `` : ''} ` pre.style.backgroundImage = 'url(' + bg + ')' const uploadWidget = node.widgets.filter(w => w.name == 'upload')[0] uploadWidget.value = await uploadWidget.serializeValue() } } })