Files

585 lines
25 KiB
JavaScript

import { app } from '../../../scripts/app.js'
import { api } from '../../../scripts/api.js'
function chainCallback(object, property, callback) {
if (!object) return;
if (property in object && object[property]) {
const original = object[property];
object[property] = function () {
const r = original.apply(this, arguments);
return callback.apply(this, arguments) ?? r;
};
} else {
object[property] = callback;
}
}
async function getAuthHeader() {
try {
const authStore = await api.getAuthStore();
return authStore ? await authStore.getAuthHeader() : null;
} catch {
return null;
}
}
async function uploadFile(file, progressCallback) {
const body = new FormData();
const newFile = new File([file], file.name, {
type: file.type,
lastModified: file.lastModified,
});
body.append('image', newFile);
const url = api.apiURL('/upload/image');
const resp = await new Promise((resolve) => {
const req = new XMLHttpRequest();
req.upload.onprogress = (e) => progressCallback?.(e.loaded / e.total);
req.onload = () => resolve(req);
req.open('post', url, true);
getAuthHeader().then((headers) => {
headers ??= {};
for (const key in headers) req.setRequestHeader(key, headers[key]);
req.send(body);
});
});
if (resp.status !== 200) {
alert(resp.status + ' - ' + resp.statusText);
return null;
}
return resp;
}
function fitHeight(node) {
node.setSize([node.size[0], node.computeSize([node.size[0], node.size[1]])[1]]);
node?.graph?.setDirtyCanvas(true);
}
function addUploadWidget(nodeType, widgetName) {
const accept = ['video/webm', 'video/mp4', 'video/x-matroska', 'video/quicktime', 'video/x-msvideo', 'image/gif'];
chainCallback(nodeType.prototype, 'onNodeCreated', function() {
const node = this;
const pathWidget = this.widgets.find((w) => w.name === widgetName);
const fileInput = document.createElement('input');
const doUpload = async (file) => {
const resp = await uploadFile(file, (p) => node.progress = p);
node.progress = undefined;
if (!resp) return false;
const filename = JSON.parse(resp.responseText).name;
pathWidget.options.values.push(filename);
pathWidget.value = filename;
pathWidget.callback?.(filename);
return true;
};
Object.assign(fileInput, {
type: 'file',
accept: accept.join(','),
style: 'display: none',
onchange: async () => {
if (fileInput.files.length) await doUpload(fileInput.files[0]);
}
});
document.body.append(fileInput);
chainCallback(this, 'onRemoved', () => fileInput.remove());
const uploadWidget = this.addWidget('button', 'choose video to upload', '', () => {
app.canvas.node_widget = null;
fileInput.click();
});
uploadWidget.options.serialize = false;
this.onDragOver = (e) => !!e?.dataTransfer?.types?.includes?.('Files');
this.onDragDrop = async function(e) {
if (!e?.dataTransfer?.types?.includes?.('Files')) return false;
const item = e.dataTransfer?.files?.[0];
if (item && accept.includes(item.type)) return await doUpload(item);
return false;
};
});
}
function addVideoPreview(nodeType) {
chainCallback(nodeType.prototype, 'onNodeCreated', function() {
const element = document.createElement('div');
const previewNode = this;
const previewWidget = this.addDOMWidget('videopreview', 'preview', element, {
serialize: false,
hideOnZoom: false,
getValue() { return element.value; },
setValue(v) { element.value = v; },
});
previewWidget.computeSize = function(width) {
if (this.aspectRatio && !this.parentEl.hidden) {
let height = (previewNode.size[0] - 20) / this.aspectRatio + 10;
if (!(height > 0)) height = 0;
this.computedHeight = height + 10;
return [width, height];
}
return [width, -4];
};
const isOverlayEvent = (e) => {
const t = e?.target;
return !!(t && t.closest && t.closest('.vls-overlay'));
};
element.addEventListener('contextmenu', (e) => {
if (isOverlayEvent(e)) return;
e.preventDefault();
return app.canvas._mousedown_callback(e);
}, true);
element.addEventListener('pointerdown', (e) => {
if (isOverlayEvent(e)) return;
e.preventDefault();
return app.canvas._mousedown_callback(e);
}, true);
element.addEventListener('mousewheel', (e) => {
if (isOverlayEvent(e)) return;
e.preventDefault();
return app.canvas._mousewheel_callback(e);
}, true);
element.addEventListener('pointermove', (e) => {
if (isOverlayEvent(e)) return;
e.preventDefault();
return app.canvas._mousemove_callback(e);
}, true);
element.addEventListener('pointerup', (e) => {
if (isOverlayEvent(e)) return;
e.preventDefault();
return app.canvas._mouseup_callback(e);
}, true);
element.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
app.dragOverNode = this;
});
previewWidget.value = { hidden: false, paused: false, params: {}, muted: true };
previewWidget.parentEl = document.createElement('div');
previewWidget.parentEl.className = 'vhs_preview';
previewWidget.parentEl.style.width = '100%';
previewWidget.parentEl.style.position = 'relative';
previewWidget.parentEl.style.overflow = 'hidden';
element.appendChild(previewWidget.parentEl);
previewWidget.videoEl = document.createElement('video');
previewWidget.videoEl.controls = false;
previewWidget.videoEl.loop = true;
previewWidget.videoEl.muted = true;
previewWidget.videoEl.playsInline = true;
previewWidget.videoEl.style.width = '100%';
previewWidget.videoEl.style.display = 'block';
previewWidget.videoEl.addEventListener('loadedmetadata', () => {
previewWidget.aspectRatio = previewWidget.videoEl.videoWidth / previewWidget.videoEl.videoHeight;
fitHeight(this);
});
previewWidget.videoEl.addEventListener('error', () => {
previewWidget.parentEl.hidden = true;
fitHeight(this);
});
previewWidget.videoEl.onmouseenter = async () => {
if (previewWidget.value.hidden || previewWidget.value.paused) return;
previewWidget.videoEl.muted = false;
try {
await previewWidget.videoEl.play();
} catch {
previewWidget.videoEl.muted = true;
}
};
previewWidget.videoEl.onmouseleave = () => { previewWidget.videoEl.muted = true; };
previewWidget.imgEl = document.createElement('img');
previewWidget.imgEl.style.width = '100%';
previewWidget.imgEl.style.display = 'block';
previewWidget.imgEl.hidden = true;
previewWidget.imgEl.onload = () => {
previewWidget.aspectRatio = previewWidget.imgEl.naturalWidth / previewWidget.imgEl.naturalHeight;
fitHeight(this);
};
previewWidget.parentEl.appendChild(previewWidget.videoEl);
previewWidget.parentEl.appendChild(previewWidget.imgEl);
let timeout = null;
this.updateParameters = (params, forceUpdate) => {
if (!previewWidget.value.params) {
if (typeof(previewWidget.value) !== 'object') previewWidget.value = { hidden: false, paused: false };
previewWidget.value.params = {};
}
if (!Object.entries(params).some(([k, v]) => previewWidget.value.params[k] !== v)) return;
Object.assign(previewWidget.value.params, params);
if (timeout) clearTimeout(timeout);
if (forceUpdate) previewWidget.updateSource();
else timeout = setTimeout(() => previewWidget.updateSource(), 100);
};
previewWidget.updateSource = function () {
if (this.value.params == undefined) return;
const params = { ...this.value.params, timestamp: Date.now() };
this.parentEl.hidden = this.value.hidden;
if (params.format?.split('/')[0] === 'video' || params.format?.split('/')[1] === 'gif' || params.format === 'folder') {
this.videoEl.autoplay = !this.value.paused && !this.value.hidden;
this.videoEl.src = api.apiURL('/view?' + new URLSearchParams(params));
this.videoEl.hidden = false;
this.imgEl.hidden = true;
} else if (params.format?.split('/')[0] === 'image') {
this.imgEl.src = api.apiURL('/view?' + new URLSearchParams(params));
this.videoEl.hidden = true;
this.imgEl.hidden = false;
}
};
previewWidget.callback = previewWidget.updateSource;
});
}
function addCharacterOverlay(nodeType) {
chainCallback(nodeType.prototype, 'onNodeCreated', function () {
this._characterCount = 0;
this._characterItems = [];
this._characterDragState = null;
this._characterResizeState = null;
this._speakerNames = ['A', 'B', 'C', 'D'];
const toRelativePositionText = (xPct, yPct) => {
const v = yPct < 33.333 ? 'Top' : (yPct < 66.666 ? 'Center' : 'Bottom');
const h = xPct < 33.333 ? 'left' : (xPct < 66.666 ? 'center' : 'right');
if (h === 'center' && v === 'Center') return 'Center character';
return `${v} ${h} character`;
};
const getItemSceneCenterPct = (item) => {
const overlayRect = this._characterOverlayEl?.getBoundingClientRect();
const labelRect = item?.el?.getBoundingClientRect();
if (!overlayRect || !labelRect || overlayRect.width <= 0 || overlayRect.height <= 0) {
return { xPct: item?.xPct ?? 0, yPct: item?.yPct ?? 0 };
}
const centerXPx = (labelRect.left - overlayRect.left) + (labelRect.width / 2);
const centerYPx = (labelRect.top - overlayRect.top) + (labelRect.height / 2);
return {
xPct: (centerXPx / overlayRect.width) * 100,
yPct: (centerYPx / overlayRect.height) * 100,
};
};
const syncSpeakerOutputs = () => {
const widgetMap = [
this.widgets?.find((w) => w.name === 'speaker_a_position'),
this.widgets?.find((w) => w.name === 'speaker_b_position'),
this.widgets?.find((w) => w.name === 'speaker_c_position'),
this.widgets?.find((w) => w.name === 'speaker_d_position'),
];
for (let i = 0; i < 4; i += 1) {
const widget = widgetMap[i];
if (!widget) continue;
const item = this._characterItems[i];
const value = item
? (() => {
const c = getItemSceneCenterPct(item);
return toRelativePositionText(c.xPct, c.yPct);
})()
: `Speaker ${this._speakerNames[i]} not set`;
widget.value = value;
widget.callback?.(value);
}
};
const clampItemInsideOverlay = (item) => {
const overlayRect = this._characterOverlayEl?.getBoundingClientRect();
const itemRect = item?.el?.getBoundingClientRect();
if (!overlayRect || !itemRect || overlayRect.width <= 0 || overlayRect.height <= 0) return;
const maxX = Math.max(0, overlayRect.width - itemRect.width);
const maxY = Math.max(0, overlayRect.height - itemRect.height);
const xPx = (item.xPct / 100) * overlayRect.width;
const yPx = (item.yPct / 100) * overlayRect.height;
const clampedXPx = Math.min(Math.max(0, xPx), maxX);
const clampedYPx = Math.min(Math.max(0, yPx), maxY);
item.xPct = (clampedXPx / Math.max(1, overlayRect.width)) * 100;
item.yPct = (clampedYPx / Math.max(1, overlayRect.height)) * 100;
item.el.style.left = `${item.xPct}%`;
item.el.style.top = `${item.yPct}%`;
};
const addCharacterWidget = this.addWidget('button', 'Add speaking character', '', () => {
if (this._characterCount >= 4) return;
const previewWidget = this.widgets?.find((w) => w.name === 'videopreview');
const parentEl = previewWidget?.parentEl;
if (!parentEl) return;
if (!this._characterOverlayEl) {
const overlay = document.createElement('div');
overlay.className = 'vls-overlay';
overlay.style.position = 'absolute';
overlay.style.inset = '0';
overlay.style.pointerEvents = 'none';
overlay.style.touchAction = 'none';
overlay.style.overflow = 'hidden';
overlay.style.zIndex = '5';
parentEl.appendChild(overlay);
this._characterOverlayEl = overlay;
}
this._characterCount += 1;
const label = document.createElement('div');
label.classList.add('vls-overlay');
const speakerName = this._speakerNames[this._characterCount - 1];
label.textContent = `Speaker ${speakerName}`;
label.style.position = 'absolute';
label.style.left = '10%';
label.style.top = `${10 + (this._characterCount - 1) * 8}%`;
label.style.padding = '4px 8px';
label.style.background = 'rgba(0, 0, 0, 0.65)';
label.style.color = '#fff';
label.style.border = '1px solid rgba(255,255,255,0.45)';
label.style.borderRadius = '6px';
label.style.fontSize = '12px';
label.style.fontFamily = 'monospace';
label.style.cursor = 'move';
label.style.userSelect = 'none';
label.style.pointerEvents = 'auto';
label.style.touchAction = 'none';
label.style.whiteSpace = 'nowrap';
label.style.maxWidth = '100%';
label.style.boxSizing = 'border-box';
label.ondragstart = () => false;
this._characterOverlayEl.appendChild(label);
const item = { el: label, xPct: 10, yPct: 10 + (this._characterCount - 1) * 8, scale: 1.0 };
this._characterItems.push(item);
syncSpeakerOutputs();
const resizeHandle = document.createElement('div');
resizeHandle.style.position = 'absolute';
resizeHandle.style.width = '10px';
resizeHandle.style.height = '10px';
resizeHandle.style.right = '-5px';
resizeHandle.style.bottom = '-5px';
resizeHandle.style.borderRadius = '50%';
resizeHandle.style.background = 'rgba(255,255,255,0.95)';
resizeHandle.style.border = '1px solid rgba(0,0,0,0.7)';
resizeHandle.style.cursor = 'nwse-resize';
resizeHandle.style.pointerEvents = 'auto';
label.appendChild(resizeHandle);
const onPointerMove = (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (!this._characterDragState) return;
const { overlayRect, targetItem, offsetX, offsetY } = this._characterDragState;
const currentRect = targetItem.el.getBoundingClientRect();
const maxX = Math.max(0, overlayRect.width - currentRect.width);
const maxY = Math.max(0, overlayRect.height - currentRect.height);
const xPx = Math.min(Math.max(0, ev.clientX - overlayRect.left - offsetX), maxX);
const yPx = Math.min(Math.max(0, ev.clientY - overlayRect.top - offsetY), maxY);
targetItem.xPct = (xPx / Math.max(1, overlayRect.width)) * 100;
targetItem.yPct = (yPx / Math.max(1, overlayRect.height)) * 100;
targetItem.el.style.left = `${targetItem.xPct}%`;
targetItem.el.style.top = `${targetItem.yPct}%`;
clampItemInsideOverlay(targetItem);
syncSpeakerOutputs();
};
const onPointerUp = (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (this._characterDragState?.targetItem?.el && ev.pointerId !== undefined) {
try { this._characterDragState.targetItem.el.releasePointerCapture(ev.pointerId); } catch {}
}
if (this._characterDragState?.targetItem) clampItemInsideOverlay(this._characterDragState.targetItem);
this._characterDragState = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
};
label.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (ev.pointerId !== undefined) {
try { label.setPointerCapture(ev.pointerId); } catch {}
}
const overlayRect = this._characterOverlayEl.getBoundingClientRect();
const labelRect = label.getBoundingClientRect();
this._characterDragState = {
overlayRect,
targetItem: item,
offsetX: ev.clientX - labelRect.left,
offsetY: ev.clientY - labelRect.top,
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
});
const onResizeMove = (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (!this._characterResizeState) return;
const state = this._characterResizeState;
const dx = ev.clientX - state.startX;
const dy = ev.clientY - state.startY;
const delta = (dx + dy) * 0.005;
const nextScale = Math.min(4.0, Math.max(0.5, state.startScale + delta));
state.targetItem.scale = nextScale;
state.targetItem.el.style.transform = `scale(${nextScale})`;
state.targetItem.el.style.transformOrigin = 'top left';
clampItemInsideOverlay(state.targetItem);
syncSpeakerOutputs();
};
const onResizeUp = (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (this._characterResizeState?.handleEl && ev.pointerId !== undefined) {
try { this._characterResizeState.handleEl.releasePointerCapture(ev.pointerId); } catch {}
}
if (this._characterResizeState?.targetItem) clampItemInsideOverlay(this._characterResizeState.targetItem);
this._characterResizeState = null;
window.removeEventListener('pointermove', onResizeMove);
window.removeEventListener('pointerup', onResizeUp);
};
resizeHandle.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (ev.pointerId !== undefined) {
try { resizeHandle.setPointerCapture(ev.pointerId); } catch {}
}
this._characterResizeState = {
targetItem: item,
startX: ev.clientX,
startY: ev.clientY,
startScale: item.scale ?? 1.0,
handleEl: resizeHandle,
};
window.addEventListener('pointermove', onResizeMove);
window.addEventListener('pointerup', onResizeUp);
});
});
addCharacterWidget.options.serialize = false;
const deleteCharacterWidget = this.addWidget('button', 'Delete speaker', '', () => {
if (this._characterItems.length === 0) return;
const last = this._characterItems.pop();
last?.el?.remove();
this._characterCount = Math.max(0, this._characterCount - 1);
syncSpeakerOutputs();
});
deleteCharacterWidget.options.serialize = false;
chainCallback(this, 'onResize', () => {
for (const item of this._characterItems) clampItemInsideOverlay(item);
syncSpeakerOutputs();
});
syncSpeakerOutputs();
});
}
function addPreviewOptions(nodeType) {
chainCallback(nodeType.prototype, 'getExtraMenuOptions', function(_, options) {
const previewWidget = this.widgets.find((w) => w.name === 'videopreview');
if (!previewWidget) return;
let url = null;
if (previewWidget.videoEl?.hidden === false && previewWidget.videoEl.src) {
url = api.apiURL('/view?' + new URLSearchParams(previewWidget.value.params));
url = url.replace('%2503d', '001');
} else if (previewWidget.imgEl?.hidden === false && previewWidget.imgEl.src) {
url = previewWidget.imgEl.src;
}
const extra = [];
if (url) {
extra.push({ content: 'Open preview', callback: () => window.open(url, '_blank') });
extra.push({
content: 'Save preview',
callback: () => {
const a = document.createElement('a');
a.href = url;
a.setAttribute('download', previewWidget.value.params.filename || 'video');
document.body.append(a);
a.click();
requestAnimationFrame(() => a.remove());
},
});
}
const pauseText = (previewWidget.value.paused ? 'Resume' : 'Pause') + ' preview';
if (previewWidget.videoEl?.hidden === false) {
extra.push({
content: pauseText,
callback: () => {
if (previewWidget.value.paused) previewWidget.videoEl?.play();
else previewWidget.videoEl?.pause();
previewWidget.value.paused = !previewWidget.value.paused;
},
});
}
const showText = (previewWidget.value.hidden ? 'Show' : 'Hide') + ' preview';
extra.push({
content: showText,
callback: () => {
if (!previewWidget.videoEl.hidden && !previewWidget.value.hidden) previewWidget.videoEl.pause();
else if (previewWidget.value.hidden && !previewWidget.videoEl.hidden && !previewWidget.value.paused) previewWidget.videoEl.play();
previewWidget.value.hidden = !previewWidget.value.hidden;
previewWidget.parentEl.hidden = previewWidget.value.hidden;
fitHeight(this);
},
});
const muteText = (previewWidget.value.muted ? 'Unmute' : 'Mute') + ' preview';
extra.push({ content: muteText, callback: () => { previewWidget.value.muted = !previewWidget.value.muted; } });
if (options.length > 0 && options[0] != null && extra.length > 0) extra.push(null);
options.unshift(...extra);
});
}
app.registerExtension({
name: 'Comfy.LoadVideoSequentially.UI',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== 'LoadVideoSequentially') return;
addUploadWidget(nodeType, 'video');
addVideoPreview(nodeType);
addCharacterOverlay(nodeType);
addPreviewOptions(nodeType);
chainCallback(nodeType.prototype, 'onNodeCreated', function () {
const videoWidget = this.widgets.find((w) => w.name === 'video');
chainCallback(videoWidget, 'callback', (value) => {
if (!value) return;
const extIdx = value.lastIndexOf('.');
const ext = extIdx >= 0 ? value.slice(extIdx + 1).toLowerCase() : 'mp4';
const format = (['gif', 'webp', 'avif'].includes(ext) ? 'image' : 'video') + '/' + ext;
this.updateParameters({ filename: value, type: 'input', format }, true);
});
videoWidget?.callback?.(videoWidget.value);
});
chainCallback(nodeType.prototype, 'onExecuted', function (message) {
const turnWidget = this.widgets?.find((w) => w.name === 'turn');
if (!turnWidget) return;
if (!Array.isArray(message) && message?.turn_used !== undefined) {
const currentTurn = Number(message.turn_used);
if (!Number.isNaN(currentTurn)) {
turnWidget.value = currentTurn + 1;
turnWidget.callback?.(turnWidget.value);
}
return;
}
const current = Number(turnWidget.value);
if (!Number.isNaN(current)) {
turnWidget.value = current + 1;
turnWidget.callback?.(turnWidget.value);
}
});
},
});