Files
1hew-ComfyUI-1hewNodes/web/js/core/media_utils.js
T
1hew f492675b25 v3.10.0 chore(release): 添加 Image Alpha Join 并修复媒体预览
补充 Image Solid 256x256 预设,并修复 Load Image / Load Video / Load Video to Image 的预览显示、交互与旧工作流兼容问题。
2026-04-16 02:55:21 +08:00

845 lines
24 KiB
JavaScript

const managedVideos = new Set();
let resumeHooksInstalled = false;
function installResumeHooks() {
if (resumeHooksInstalled) {
return;
}
resumeHooksInstalled = true;
const resumeAll = () => {
for (const videoEl of managedVideos) {
if (!videoEl) {
continue;
}
if (videoEl.dataset.comfy1hewUserPaused === "1") {
continue;
}
const p = videoEl.play();
if (p && typeof p.catch === "function") {
p.catch(() => {});
}
}
};
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
return;
}
resumeAll();
});
window.addEventListener("focus", resumeAll);
window.addEventListener("pageshow", resumeAll);
}
export function applyLoopedHoverAudioPreview(videoEl) {
if (!videoEl || videoEl.dataset.comfy1hewPreviewApplied === "1") {
return;
}
videoEl.dataset.comfy1hewPreviewApplied = "1";
installResumeHooks();
managedVideos.add(videoEl);
videoEl.autoplay = true;
videoEl.loop = true;
videoEl.muted = true;
videoEl.playsInline = true;
videoEl.controls = false;
videoEl.preload = "auto";
const safePlay = () => {
if (videoEl.dataset.comfy1hewUserPaused === "1") {
return;
}
if (videoEl.dataset.comfy1hewFrameAccurate === "1") {
return;
}
const p = videoEl.play();
if (p && typeof p.catch === "function") {
p.catch(() => {});
}
};
safePlay();
videoEl.addEventListener("loadeddata", safePlay);
videoEl.addEventListener("canplay", safePlay);
videoEl.addEventListener("playing", () => managedVideos.add(videoEl));
videoEl.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (videoEl.dataset.comfy1hewFrameAccurate === "1") {
if (videoEl.dataset.comfy1hewUserPaused === "1") {
videoEl.dataset.comfy1hewUserPaused = "0";
if (videoEl.dataset.comfy1hewForceMute !== "1") {
videoEl.muted = false;
videoEl.volume = 1.0;
}
} else {
videoEl.dataset.comfy1hewUserPaused = "1";
videoEl.muted = true;
}
return;
}
if (videoEl.paused) {
videoEl.dataset.comfy1hewUserPaused = "0";
if (videoEl.dataset.comfy1hewForceMute !== "1") {
videoEl.muted = false;
videoEl.volume = 1.0;
}
safePlay();
} else {
videoEl.dataset.comfy1hewUserPaused = "1";
videoEl.pause();
}
});
videoEl.addEventListener("pointerenter", () => {
if (videoEl.dataset.comfy1hewUserPaused === "1") {
return;
}
if (videoEl.dataset.comfy1hewForceMute === "1") {
videoEl.muted = true;
} else {
videoEl.muted = false;
videoEl.volume = 1.0;
}
if (videoEl.dataset.comfy1hewFrameAccurate === "1") {
return;
}
safePlay();
});
videoEl.addEventListener("pointerleave", () => {
videoEl.muted = true;
});
}
export async function collectDropPayload(e) {
const readAllEntries = async (dirEntry) => {
const reader = dirEntry.createReader();
const entries = [];
while (true) {
const batch = await new Promise((resolve) => reader.readEntries(resolve));
if (!batch || batch.length === 0) break;
entries.push(...batch);
}
return entries;
};
const walkEntry = async (entry) => {
if (!entry) return [];
if (entry.isFile) {
const file = await new Promise((resolve) => entry.file(resolve));
const relativePath = (entry.fullPath || file.name).replace(/^\/+/, "").trim();
return [{ file, relativePath }];
}
if (entry.isDirectory) {
const entries = await readAllEntries(entry);
const out = [];
for (const e2 of entries) {
const sub = await walkEntry(e2);
out.push(...sub);
}
return out;
}
return [];
};
const items = e?.dataTransfer?.items;
if (items && items.length > 0) {
const out = [];
let hasDirectory = false;
for (const item of items) {
const entry = item?.webkitGetAsEntry?.();
if (!entry) continue;
if (entry.isDirectory) hasDirectory = true;
const sub = await walkEntry(entry);
out.push(...sub);
}
return { pairs: out, hasDirectory };
}
const files = Array.from(e?.dataTransfer?.files || []);
return {
pairs: files.map((f) => ({ file: f, relativePath: f.name })),
hasDirectory: false,
};
}
function getTargetNodesForSave(app, currentNode) {
const canvas = app?.canvas;
const selected = canvas?.selected_nodes || {};
const selection = Object.values(selected);
if (selection.length > 0 && selection.includes(currentNode)) {
return selection;
}
return [currentNode];
}
function downloadUrl(url, filename) {
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
export function addSaveMediaMenuOption(
options,
{
app,
currentNode,
content,
getMediaElFromNode,
filenamePrefix,
filenameExt,
},
) {
if (!Array.isArray(options) || !app || !currentNode || !getMediaElFromNode) {
return;
}
const mediaEl = getMediaElFromNode(currentNode);
if (!mediaEl || !mediaEl.src) {
return;
}
options.push({
content,
callback: () => {
const targets = getTargetNodesForSave(app, currentNode);
for (const node of targets) {
const el = getMediaElFromNode(node);
if (el && el.src) {
downloadUrl(
el.src,
`${filenamePrefix}_${node.id}_${Date.now()}.${filenameExt}`,
);
}
}
},
});
}
export function addCopyMediaFrameMenuOption(
options,
{
content,
getWidth,
getHeight,
drawToCanvas,
copyErrorMessage,
prepareErrorMessage,
},
) {
if (!Array.isArray(options)) {
return;
}
if (
typeof getWidth !== "function"
|| typeof getHeight !== "function"
|| typeof drawToCanvas !== "function"
) {
return;
}
const width = getWidth();
const height = getHeight();
if (!width || !height) {
return;
}
options.push({
content,
callback: () => {
try {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
drawToCanvas(ctx, canvas);
canvas.toBlob((blob) => {
if (blob) {
try {
const item = new ClipboardItem({ "image/png": blob });
navigator.clipboard.write([item]);
} catch (err) {
console.error(copyErrorMessage, err);
}
}
}, "image/png");
} catch (err) {
console.error(prepareErrorMessage, err);
}
},
});
}
function estimatePreviewTopOffset(node, widget) {
try {
if (node.widgets && node.widgets.length > 0) {
const widgetCount = node.widgets.filter(
(currentWidget) =>
currentWidget !== widget && currentWidget.type !== "hidden",
).length;
let estimatedTop = 30 + widgetCount * 26 + 30;
if (estimatedTop < 130) estimatedTop = 130;
return estimatedTop;
}
} catch {}
return 130;
}
function getDesiredNodeHeight(node, widget, desiredWidgetHeight) {
if (Number.isFinite(widget?.last_y)) {
return widget.last_y + desiredWidgetHeight;
}
try {
const computed = node.computeSize?.([
node.size[0],
node.size[1],
]);
if (
Array.isArray(computed) &&
computed.length >= 2 &&
Number.isFinite(computed[1])
) {
return computed[1];
}
} catch {}
return estimatePreviewTopOffset(node, widget) + desiredWidgetHeight;
}
function bindPreviewResizeHandler(node, {
prevWidthKey,
userResizedKey,
internalResizeFlagKey,
updateLayout,
}) {
const originalOnResize = node.onResize;
node.onResize = function (size) {
const result = originalOnResize
? originalOnResize.apply(this, arguments)
: undefined;
try {
const nextWidth = Array.isArray(size) ? size[0] : node.size?.[0];
const prevWidth = node[prevWidthKey];
node[prevWidthKey] = nextWidth;
if (
!node[internalResizeFlagKey]
&& Number.isFinite(nextWidth)
&& Number.isFinite(prevWidth)
&& Math.abs(nextWidth - prevWidth) > 1
) {
node[userResizedKey] = true;
}
updateLayout();
} catch {}
return result;
};
}
function setPreviewVisibility(container, {
visible,
height = 0,
app,
}) {
container.style.height = `${height}px`;
container.style.display = visible ? "flex" : "none";
app?.graph?.setDirtyCanvas?.(true, true);
}
function getAvailablePreviewHeight(node, widget, aspectRatio, maxPreviewHeight) {
let availableHeight;
if (Number.isFinite(widget?.last_y)) {
availableHeight = node.size[1] - widget.last_y - 15;
} else {
const width = node.size[0];
availableHeight = width * aspectRatio + 20;
}
if (maxPreviewHeight && isFinite(maxPreviewHeight)) {
availableHeight = Math.min(availableHeight, maxPreviewHeight);
}
if (availableHeight < 0) {
return 0;
}
return availableHeight;
}
function getCappedAspectRatio(aspectRatio, maxAspectRatio = null) {
if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) {
return null;
}
if (maxAspectRatio && isFinite(maxAspectRatio)) {
return Math.min(aspectRatio, maxAspectRatio);
}
return aspectRatio;
}
function syncPreviewAspectRatio(widget, {
width,
height,
maxAspectRatio = null,
}) {
if (!width || !height) {
return null;
}
const nextAspectRatio = getCappedAspectRatio(height / width, maxAspectRatio);
if (nextAspectRatio) {
widget.aspectRatio = nextAspectRatio;
}
return nextAspectRatio;
}
function resizeNodeToPreviewContent({
node,
widget,
aspectRatio,
maxPreviewHeight,
allowShrink = false,
internalResizeFlagKey,
adjustDesiredHeight,
}) {
if (!aspectRatio) {
return;
}
const width = node.size[0];
let desiredWidgetHeight = width * aspectRatio + 20;
if (maxPreviewHeight && isFinite(maxPreviewHeight)) {
desiredWidgetHeight = Math.min(desiredWidgetHeight, maxPreviewHeight);
}
let desiredHeight = getDesiredNodeHeight(
node,
widget,
desiredWidgetHeight,
);
if (typeof adjustDesiredHeight === "function") {
desiredHeight = adjustDesiredHeight(desiredHeight);
}
if (
(allowShrink && Math.abs(node.size[1] - desiredHeight) > 1)
|| (!allowShrink && node.size[1] + 1 < desiredHeight)
) {
node[internalResizeFlagKey] = true;
try {
node.setSize([node.size[0], desiredHeight]);
} finally {
node[internalResizeFlagKey] = false;
}
}
}
function requestPreviewAutoSize({
node,
width,
height,
autoSizeKey,
autoSizeToContent,
allowShrink = false,
force = false,
}) {
if (!width || !height) {
return;
}
const key = `${width}x${height}`;
if (!force && node[autoSizeKey] === key) {
return;
}
node[autoSizeKey] = key;
setTimeout(() => autoSizeToContent({ allowShrink }), 0);
}
function updatePreviewContainerLayout({
app,
container,
previewStrategy,
}) {
if (container.dataset.comfy1hewForceHidden === "1") {
setPreviewVisibility(container, { visible: false, app });
return;
}
if (!previewStrategy.getAspectRatio()) {
previewStrategy.syncAspectRatioFromMedia();
}
if (!previewStrategy.getAspectRatio()) {
const placeholderState =
typeof previewStrategy.getPlaceholderState === "function"
? previewStrategy.getPlaceholderState()
: null;
if (placeholderState) {
setPreviewVisibility(container, { ...placeholderState, app });
return;
}
setPreviewVisibility(container, { visible: false, app });
return;
}
setPreviewVisibility(container, {
...previewStrategy.getVisibleState(),
app,
});
}
export function installImagePreviewLayout({
app,
node,
imageWidget,
container,
imageEl,
allWidget,
fileWidget,
}) {
const getMaxAspectRatio = () =>
typeof imageWidget._comfy1hew_maxAspectRatio === "number"
? imageWidget._comfy1hew_maxAspectRatio
: null;
const getMaxPreviewHeight = () => {
const maxPreviewHeight =
typeof imageWidget._comfy1hew_maxPreviewHeight === "number"
? imageWidget._comfy1hew_maxPreviewHeight
: null;
if (
node._comfy1hewImagePreviewUserResized
&& !(allWidget && allWidget.value)
) {
return null;
}
return maxPreviewHeight;
};
const previewStrategy = {
getAspectRatio() {
return getCappedAspectRatio(
imageWidget.aspectRatio,
getMaxAspectRatio(),
);
},
syncAspectRatioFromMedia() {
return syncPreviewAspectRatio(imageWidget, {
width: imageEl?.naturalWidth,
height: imageEl?.naturalHeight,
maxAspectRatio: getMaxAspectRatio(),
});
},
getPlaceholderState() {
const hasSrc = Boolean(
imageEl?.src && String(imageEl.src).trim() !== "",
);
const stillDecoding =
hasSrc
&& (!imageEl.naturalWidth || !imageEl.naturalHeight);
const filePathTrim = String(fileWidget?.value ?? "").trim();
const awaitingFileLoad = Boolean(filePathTrim && !hasSrc);
const holdPlaceholder =
stillDecoding
|| awaitingFileLoad
|| Number.isFinite(node._comfy1hewPreserveFrameHeightUntilPreview);
if (!holdPlaceholder) {
return null;
}
return {
visible: true,
height: computePlaceholderPreviewHeight(),
};
},
getVisibleState() {
const availableHeight = getAvailablePreviewHeight(
node,
imageWidget,
this.getAspectRatio(),
getMaxPreviewHeight(),
);
return {
visible: true,
height: availableHeight,
};
},
};
const autoSizeToContent = ({ allowShrink = false } = {}) => {
const aspectRatio = previewStrategy.getAspectRatio();
resizeNodeToPreviewContent({
node,
widget: imageWidget,
aspectRatio,
maxPreviewHeight: getMaxPreviewHeight(),
allowShrink,
internalResizeFlagKey: "_comfy1hewInternalPreviewResize",
adjustDesiredHeight: (desiredHeight) => {
let nextDesiredHeight = desiredHeight;
if (allWidget && allWidget.value) {
nextDesiredHeight = Math.min(nextDesiredHeight, 420);
}
const preserve = node._comfy1hewPreserveFrameHeightUntilPreview;
if (
Number.isFinite(preserve)
&& Number.isFinite(nextDesiredHeight)
&& nextDesiredHeight < preserve
) {
nextDesiredHeight = preserve;
}
return nextDesiredHeight;
},
});
};
const requestAutoSize = ({ allowShrink = false, force = false } = {}) => {
requestPreviewAutoSize({
node,
width: imageEl.naturalWidth,
height: imageEl.naturalHeight,
autoSizeKey: "_comfy1hewImageAutoSizeKey",
autoSizeToContent,
allowShrink,
force,
});
};
node._comfy1hewLastPreviewNodeWidth ??= node.size?.[0];
const computePlaceholderPreviewHeight = () => {
let placeholderH = 100;
if (Number.isFinite(imageWidget?.last_y)) {
placeholderH = Math.max(
0,
node.size[1] - imageWidget.last_y - 15,
);
} else if (Array.isArray(node.size) && node.size[1] > 140) {
const baseGuess = 130;
placeholderH = Math.max(
40,
Math.min(220, node.size[1] - baseGuess),
);
}
if (placeholderH < 40) {
placeholderH = 100;
}
return placeholderH;
};
const updateLayout = () => {
// 复制节点 / 刚设置 src 时:DOM 上可能已有 <img> 尺寸,但 widget.aspectRatio 尚未写入。
// 若此时走「无比例则 display:none」,会出现整段预览被关掉再打开的可见闪烁。
updatePreviewContainerLayout({
app,
container,
previewStrategy,
});
};
bindPreviewResizeHandler(node, {
prevWidthKey: "_comfy1hewLastPreviewNodeWidth",
userResizedKey: "_comfy1hewImagePreviewUserResized",
internalResizeFlagKey: "_comfy1hewInternalPreviewResize",
updateLayout,
});
const ensurePreviewLayout = ({
allowShrink = false,
forceAutoSize = false,
} = {}) => {
if (container.dataset.comfy1hewForceHidden === "1") {
updateLayout();
return;
}
previewStrategy.syncAspectRatioFromMedia();
requestAutoSize({ allowShrink, force: forceAutoSize });
updateLayout();
};
node.updateImageLayout = updateLayout;
node._comfy1hewEnsurePreviewLayout = ensurePreviewLayout;
return { autoSizeToContent, requestAutoSize, updateLayout, ensurePreviewLayout };
}
export function installVideoPreviewLayout({
app,
node,
videoWidget,
container,
videoEl,
}) {
const getMaxPreviewHeight = () => {
const maxPreviewHeight =
typeof videoWidget._comfy1hew_maxPreviewHeight === "number"
? videoWidget._comfy1hew_maxPreviewHeight
: null;
if (node._comfy1hewVideoPreviewUserResized) {
return null;
}
return maxPreviewHeight;
};
const autoSizeToContent = ({ allowShrink = false } = {}) => {
if (!videoWidget.aspectRatio) {
return;
}
const width = node.size[0];
let desiredWidgetHeight = width * videoWidget.aspectRatio + 20;
const maxPreviewHeight = getMaxPreviewHeight();
if (maxPreviewHeight && isFinite(maxPreviewHeight)) {
desiredWidgetHeight = Math.min(desiredWidgetHeight, maxPreviewHeight);
}
let desiredHeight;
if (Number.isFinite(videoWidget.last_y)) {
desiredHeight = videoWidget.last_y + desiredWidgetHeight;
} else {
try {
const computed = node.computeSize?.([
node.size[0],
node.size[1],
]);
if (
Array.isArray(computed) &&
computed.length >= 2 &&
Number.isFinite(computed[1])
) {
desiredHeight = computed[1];
}
} catch {}
if (!Number.isFinite(desiredHeight)) {
desiredHeight = estimatePreviewTopOffset(node, videoWidget) + desiredWidgetHeight;
}
}
if (
(allowShrink && Math.abs(node.size[1] - desiredHeight) > 1)
|| (!allowShrink && node.size[1] + 1 < desiredHeight)
) {
node._comfy1hewInternalVideoPreviewResize = true;
try {
node.setSize([node.size[0], desiredHeight]);
} finally {
node._comfy1hewInternalVideoPreviewResize = false;
}
}
};
const requestAutoSize = ({ allowShrink = false, force = false } = {}) => {
const fallbackWidth = Number(node?._comfy1hewVideoInfo?.width) || 0;
const fallbackHeight = Number(node?._comfy1hewVideoInfo?.height) || 0;
const width = videoEl.videoWidth || fallbackWidth;
const height = videoEl.videoHeight || fallbackHeight;
if (!width || !height) {
return;
}
const key = `${width}x${height}`;
if (!force && node._comfy1hewVideoAutoSizeKey === key) {
return;
}
node._comfy1hewVideoAutoSizeKey = key;
setTimeout(() => autoSizeToContent({ allowShrink }), 0);
};
node._comfy1hewLastPreviewVideoNodeWidth ??= node.size?.[0];
const updateLayout = () => {
if (container.dataset.comfy1hewForceHidden === "1") {
container.style.height = "0px";
container.style.display = "none";
return;
}
if (!videoWidget.aspectRatio) {
container.style.height = "0px";
container.style.display = "none";
return;
}
container.style.display = "flex";
let availableHeight;
if (Number.isFinite(videoWidget?.last_y)) {
availableHeight = node.size[1] - videoWidget.last_y - 15;
} else {
const width = node.size[0];
availableHeight = width * videoWidget.aspectRatio + 20;
}
const maxPreviewHeight = getMaxPreviewHeight();
if (maxPreviewHeight && isFinite(maxPreviewHeight)) {
availableHeight = Math.min(availableHeight, maxPreviewHeight);
}
if (availableHeight < 0) {
availableHeight = 0;
}
container.style.height = `${availableHeight}px`;
app.graph.setDirtyCanvas(true, true);
};
const originalOnResize = node.onResize;
node.onResize = function (size) {
const r2 = originalOnResize
? originalOnResize.apply(this, arguments)
: undefined;
try {
const nextWidth = Array.isArray(size) ? size[0] : node.size?.[0];
const prevWidth = node._comfy1hewLastPreviewVideoNodeWidth;
node._comfy1hewLastPreviewVideoNodeWidth = nextWidth;
if (
!node._comfy1hewInternalVideoPreviewResize
&& Number.isFinite(nextWidth)
&& Number.isFinite(prevWidth)
&& Math.abs(nextWidth - prevWidth) > 1
) {
node._comfy1hewVideoPreviewUserResized = true;
}
updateLayout();
} catch {}
return r2;
};
const ensurePreviewLayout = ({
allowShrink = false,
forceAutoSize = false,
} = {}) => {
if (container.dataset.comfy1hewForceHidden === "1") {
updateLayout();
return;
}
if (videoEl.videoWidth && videoEl.videoHeight) {
videoWidget.aspectRatio = videoEl.videoHeight / videoEl.videoWidth;
} else {
const fallbackWidth = Number(node?._comfy1hewVideoInfo?.width) || 0;
const fallbackHeight = Number(node?._comfy1hewVideoInfo?.height) || 0;
if (fallbackWidth > 0 && fallbackHeight > 0) {
videoWidget.aspectRatio = fallbackHeight / fallbackWidth;
}
}
requestAutoSize({ allowShrink, force: forceAutoSize });
updateLayout();
};
node.updateVideoLayout = updateLayout;
node._comfy1hewEnsurePreviewLayout = ensurePreviewLayout;
return { autoSizeToContent, requestAutoSize, updateLayout, ensurePreviewLayout };
}