Fix append video preview sync and docs

This commit is contained in:
Ewald
2026-05-21 08:10:34 +02:00
parent 980e2933a8
commit 52a25b40ed
3 changed files with 91 additions and 28 deletions
+1 -22
View File
@@ -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
---
+42 -3
View File
@@ -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") {
+48 -3
View File
@@ -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<number> = 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<void> {
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<void> {
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") {