From 52a25b40ed20ccaed7bd2fc76ff0c356235cf07a Mon Sep 17 00:00:00 2001 From: Ewald <129623145+MajoorWaldi@users.noreply.github.com> Date: Thu, 21 May 2026 08:10:34 +0200 Subject: [PATCH] Fix append video preview sync and docs --- README.md | 23 +------------- js/preview/adapters/imageops.js | 45 ++++++++++++++++++++++++++-- src/preview/adapters/imageops.ts | 51 ++++++++++++++++++++++++++++++-- 3 files changed, 91 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 008559a..365b713 100644 --- a/README.md +++ b/README.md @@ -283,28 +283,7 @@ Add borders around an image without scaling the source. PadOut always renders the padded area as solid black; `target_format` is only a UI helper that materializes explicit pad values. -**Outputs:** `IMAGE`, `MASK`, `IMAGEOPS_PADOUT_STITCHER` - ---- - -### ImageOps PadOut Stitch -Restore the original image into the protected PadOut region after an outpainting or image-edit node. - -Recommended flow: connect `PadOut.image` and `PadOut.mask` to the outpainting node, then connect `PadOut.stitcher` and the outpainting result to PadOut Stitch. - -**Inputs:** -- `outpainted` (IMAGE/VIDEO): Result returned by the image edit / outpainting node -- `stitcher` (IMAGEOPS_PADOUT_STITCHER, optional): Stitcher output from ImageOps PadOut -- `original` (IMAGE/VIDEO, optional): Fallback source media before PadOut -- `padout_mask` (MASK, optional): Fallback mask output from ImageOps PadOut - -**Parameters:** -- `original_region`: Fallback mode when no stitcher is connected; `black_is_original` for PadOut's default mask, or `white_is_original` if PadOut's mask was inverted -- `feather_radius`: Optional softened edge where the original is restored -- `invert_mask`: Invert the returned outpaint-area mask -- `bypass`: Return the outpainted image unchanged - -**Outputs:** `IMAGE`, `MASK` +**Outputs:** `IMAGE`, `MASK`, `INT` width, `INT` height --- diff --git a/js/preview/adapters/imageops.js b/js/preview/adapters/imageops.js index e3a6b0c..353cfd6 100644 --- a/js/preview/adapters/imageops.js +++ b/js/preview/adapters/imageops.js @@ -2,7 +2,7 @@ import { ops } from "../ops.js"; import { getCompSlots } from "../comp.js"; import { clampDrawDimension, makeSolidBackgroundCanvas } from "../draw.js"; import { getFrameSelectorOutputCount, getFrameSelectorSourceFrame, getUpstreamFrameCount, syncFrameSelectorWidgets } from "../nodes/frame-range.js"; -import { getJoinConnectedInputFrameCounts, getJoinSlots } from "../nodes/append.js"; +import { getJoinConnectedInputFrameCounts, getJoinSlots, readJoinTrims } from "../nodes/append.js"; import { getUpstreamNode } from "../graph.js"; import { isImageOpsClass, resolveImageOpsClassName } from "../shared/classes.js"; function getVhsVideoInfo(node) { @@ -33,6 +33,35 @@ function normalizeFrameIndex(tick, frameCount) { if (!Number.isFinite(frameCount) || frameCount <= 0) return roundedTick; return (roundedTick % frameCount + frameCount) % frameCount; } +function getWidgetNumber(node, name, fallback) { + const value = (node?.widgets ?? []).find((widget) => widget?.name === name)?.value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} +function collectBranchVideos(node, seen = /* @__PURE__ */ new Set()) { + if (!node || seen.has(node.id)) return []; + seen.add(node.id); + const videos = []; + const videoEl = node.__imageops_media?.videoEl; + if (videoEl) videos.push({ node, videoEl }); + for (let inputIndex = 0; inputIndex < (node.inputs ?? []).length; inputIndex++) { + if ((node.inputs?.[inputIndex]?.link ?? null) == null) continue; + videos.push(...collectBranchVideos(getUpstreamNode(node, inputIndex), seen)); + } + return videos; +} +async function seekAppendBranchFrame(node, sourceFrame, fallbackFrameCount) { + const videos = collectBranchVideos(node); + for (const { node: videoNode, videoEl } of videos) { + if (!Number.isFinite(videoEl.duration) || videoEl.duration <= 0) continue; + const info = getVhsVideoInfo(videoNode); + const forceRate = getWidgetNumber(videoNode, "force_rate", 0); + const durationRate = fallbackFrameCount > 0 ? fallbackFrameCount / videoEl.duration : 0; + const fps = forceRate > 0 ? forceRate : info?.fps && info.fps > 0 ? info.fps : durationRate > 0 ? durationRate : 24; + const targetTime = Math.max(0, Math.min(videoEl.duration - 1 / fps, Math.max(0, sourceFrame) / fps)); + await seekFrozenVideoFrame(videoEl, targetTime, 0.5 / fps); + } +} async function seekFrozenVideoFrame(videoEl, targetTime, tolerance) { try { videoEl.pause(); @@ -181,12 +210,19 @@ function imageOpsAdapter() { if (inputs.length === 0) return void 0; const infos = inputInfos ?? inputs.map((canvas, index) => ({ canvas, inputIndex: index, originSlot: null, upstreamNode: null })); const frameCounts = new Map(getJoinConnectedInputFrameCounts(node).map((entry) => [entry.inputIndex, Math.max(1, entry.frameCount)])); + const trimStarts = new Map(readJoinTrims(node).map((trim) => [trim.slot, Math.max(0, Math.round(trim.start))])); + const getSourceFrame = (info, localTick2) => { + const slot = String(node.inputs?.[info.inputIndex]?.name ?? ""); + return Math.max(0, Math.round((trimStarts.get(slot) ?? 0) + localTick2)); + }; if (bypass) { const first = infos[0] ?? null; if (!first) return inputs[0]; const firstCount = Math.max(1, frameCounts.get(first.inputIndex) ?? 1); const localTick2 = normalizeFrameIndex(tick ?? 0, firstCount); - return await renderInputAt?.(first, localTick2) ?? first.canvas; + const sourceFrame2 = getSourceFrame(first, localTick2); + await seekAppendBranchFrame(first.upstreamNode, sourceFrame2, firstCount); + return await renderInputAt?.(first, sourceFrame2) ?? first.canvas; } const totalFrames = infos.reduce((sum, info) => sum + Math.max(1, frameCounts.get(info.inputIndex) ?? 1), 0); let remaining = normalizeFrameIndex(tick ?? 0, totalFrames); @@ -202,7 +238,10 @@ function imageOpsAdapter() { remaining -= segmentFrames; } if (!selected) return inputs[0]; - return await renderInputAt?.(selected, localTick) ?? selected.canvas; + const selectedCount = Math.max(1, frameCounts.get(selected.inputIndex) ?? 1); + const sourceFrame = getSourceFrame(selected, localTick); + await seekAppendBranchFrame(selected.upstreamNode, sourceFrame, selectedCount); + return await renderInputAt?.(selected, sourceFrame) ?? selected.canvas; } else if (cls === "ImageOpsComp") { return ops.comp(ctx, canvasSize, node, inputs); } else if (cls === "ImageOpsDistort") { diff --git a/src/preview/adapters/imageops.ts b/src/preview/adapters/imageops.ts index ff8c24f..2de7d82 100644 --- a/src/preview/adapters/imageops.ts +++ b/src/preview/adapters/imageops.ts @@ -4,7 +4,7 @@ import { ops } from "../ops.js"; import { getCompSlots } from "../comp.js"; import { clampDrawDimension, makeSolidBackgroundCanvas } from "../draw.js"; import { getFrameSelectorOutputCount, getFrameSelectorSourceFrame, getUpstreamFrameCount, syncFrameSelectorWidgets } from "../nodes/frame-range.js"; -import { getJoinConnectedInputFrameCounts, getJoinSlots } from "../nodes/append.js"; +import { getJoinConnectedInputFrameCounts, getJoinSlots, readJoinTrims } from "../nodes/append.js"; import { getUpstreamNode } from "../graph.js"; import { isImageOpsClass, resolveImageOpsClassName } from "../shared/classes.js"; @@ -39,6 +39,41 @@ function normalizeFrameIndex(tick: number, frameCount: number): number { return ((roundedTick % frameCount) + frameCount) % frameCount; } +function getWidgetNumber(node: ComfyNode | null, name: string, fallback: number): number { + const value = (node?.widgets ?? []).find((widget) => widget?.name === name)?.value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function collectBranchVideos(node: ComfyNode | null, seen: Set = new Set()): { node: ComfyNode; videoEl: HTMLVideoElement }[] { + if (!node || seen.has(node.id)) return []; + seen.add(node.id); + + const videos: { node: ComfyNode; videoEl: HTMLVideoElement }[] = []; + const videoEl = (node as any).__imageops_media?.videoEl as HTMLVideoElement | undefined; + if (videoEl) videos.push({ node, videoEl }); + + for (let inputIndex = 0; inputIndex < (node.inputs ?? []).length; inputIndex++) { + if ((node.inputs?.[inputIndex]?.link ?? null) == null) continue; + videos.push(...collectBranchVideos(getUpstreamNode(node, inputIndex), seen)); + } + + return videos; +} + +async function seekAppendBranchFrame(node: ComfyNode | null, sourceFrame: number, fallbackFrameCount: number): Promise { + const videos = collectBranchVideos(node); + for (const { node: videoNode, videoEl } of videos) { + if (!Number.isFinite(videoEl.duration) || videoEl.duration <= 0) continue; + const info = getVhsVideoInfo(videoNode); + const forceRate = getWidgetNumber(videoNode, "force_rate", 0); + const durationRate = fallbackFrameCount > 0 ? fallbackFrameCount / videoEl.duration : 0; + const fps = forceRate > 0 ? forceRate : (info?.fps && info.fps > 0 ? info.fps : (durationRate > 0 ? durationRate : 24)); + const targetTime = Math.max(0, Math.min(videoEl.duration - 1 / fps, Math.max(0, sourceFrame) / fps)); + await seekFrozenVideoFrame(videoEl, targetTime, 0.5 / fps); + } +} + async function seekFrozenVideoFrame(videoEl: HTMLVideoElement, targetTime: number, tolerance: number): Promise { try { videoEl.pause(); } catch {} if (!Number.isFinite(targetTime)) return; @@ -196,13 +231,20 @@ export function imageOpsAdapter(): Adapter { const infos = inputInfos ?? inputs.map((canvas, index) => ({ canvas, inputIndex: index, originSlot: null, upstreamNode: null })); const frameCounts = new Map(getJoinConnectedInputFrameCounts(node).map((entry) => [entry.inputIndex, Math.max(1, entry.frameCount)])); + const trimStarts = new Map(readJoinTrims(node).map((trim) => [trim.slot, Math.max(0, Math.round(trim.start))])); + const getSourceFrame = (info: typeof infos[number], localTick: number): number => { + const slot = String(node.inputs?.[info.inputIndex]?.name ?? ""); + return Math.max(0, Math.round((trimStarts.get(slot) ?? 0) + localTick)); + }; if (bypass) { const first = infos[0] ?? null; if (!first) return inputs[0]; const firstCount = Math.max(1, frameCounts.get(first.inputIndex) ?? 1); const localTick = normalizeFrameIndex(tick ?? 0, firstCount); - return (await renderInputAt?.(first, localTick)) ?? first.canvas; + const sourceFrame = getSourceFrame(first, localTick); + await seekAppendBranchFrame(first.upstreamNode, sourceFrame, firstCount); + return (await renderInputAt?.(first, sourceFrame)) ?? first.canvas; } const totalFrames = infos.reduce((sum, info) => sum + Math.max(1, frameCounts.get(info.inputIndex) ?? 1), 0); @@ -221,7 +263,10 @@ export function imageOpsAdapter(): Adapter { } if (!selected) return inputs[0]; - return (await renderInputAt?.(selected, localTick)) ?? selected.canvas; + const selectedCount = Math.max(1, frameCounts.get(selected.inputIndex) ?? 1); + const sourceFrame = getSourceFrame(selected, localTick); + await seekAppendBranchFrame(selected.upstreamNode, sourceFrame, selectedCount); + return (await renderInputAt?.(selected, sourceFrame)) ?? selected.canvas; } else if (cls === "ImageOpsComp") { return ops.comp(ctx, canvasSize, node, inputs); } else if (cls === "ImageOpsDistort") {