|
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() |
|
} |
|
} |
|
}) |
|
|