Files
Ewald a4154ccf05 Fix P0 bugs, remove Append V2/Lens Artifacts, and fix crop reload reset
- Fix batch bypass tuple handling, memory budget device-awareness, grain
  RNG device, roto payload limits, preview temp-file cleanup, and route
  double-registration.
- Add native VIDEO output (fps/audio) to effect nodes that previously
  only returned IMAGE/MASK when fed a VIDEO input.
- Split the oversized src/preview/ops/geometry.ts and add a starting
  golden-fixture test harness (spherize, transform) comparing backend
  and frontend math.
- Remove ImageOpsAppendV2 (Autogrow) and ImageOpsLensArtifacts; inline
  Append's helper back into its single caller.
- Collapse the Color Correct curve widget behind a toggle so the node
  no longer grows too tall.
- Fix Crop/generic-node video preview playback running at the RAF rate
  instead of the source video's real fps.
- Fix Crop losing its saved output size after a workflow reload:
  syncCropWidgets recomputed height from width and a preset aspect
  ratio on every resync (including onConfigure on load), discarding
  the restored value. It now only refreshes aspect-ratio bookkeeping
  on a resync.
2026-09-30 12:49:42 +02:00

257 lines
14 KiB
Python

from __future__ import annotations
from comfy_api.latest import io, VideoFromList
import json
import re
from typing import Any
import torch
from ._helpers import _resize, _select_media_tensor
from ._preview import build_node_preview_result
from ._progress import start_progress
from .core.timeline import trim_indices
from .core.video_io import extract_video_media, media_to_video
from .core.memory import check_budget
from .core.media import ImageOpsMedia
from .frame_range import _slice_audio_for_indices
_JOIN_FIT_MODES = ['strict', 'resize_to_first', 'pad_to_max']
_FPS_RELATIVE_TOLERANCE = 0.001
def _sorted_clip_inputs(inputs: dict[str, Any]) -> list[tuple[int, Any]]:
clips: list[tuple[int, Any]] = []
legacy = [('image_a', 1), ('image_b', 2)]
for key, index in legacy:
if inputs.get(key) is not None:
clips.append((index, inputs[key]))
for key, value in inputs.items():
if value is None or not isinstance(key, str):
continue
match = re.fullmatch('image_(\\d+)', key)
if match:
clips.append((int(match.group(1)), value))
dedup: dict[int, Any] = {}
for index, value in clips:
dedup[index] = value
return sorted(dedup.items(), key=lambda item: item[0])
def _parse_trims(trims_json: str | dict | list | None) -> dict[int, tuple[int, int]]:
if isinstance(trims_json, (dict, list)):
parsed = trims_json
else:
raw = str(trims_json or '').strip()
if not raw:
parsed = {}
else:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = {}
source = parsed.get('clips', []) if isinstance(parsed, dict) else parsed
trims: dict[int, tuple[int, int]] = {}
if not isinstance(source, list):
return trims
for entry in source:
if not isinstance(entry, dict):
continue
slot = str(entry.get('slot') or '')
match = re.search('(\\d+)$', slot)
if not match:
continue
start_raw = entry.get('start', 0)
end_raw = entry.get('end', -1)
start = int(0 if start_raw is None else start_raw)
end = int(-1 if end_raw is None else end_raw)
trims[int(match.group(1))] = (start, end)
return trims
def _fps_matches(a: float, b: float) -> bool:
return abs(a - b) <= max(a, b) * _FPS_RELATIVE_TOLERANCE
def _coerce_channels(tensor: torch.Tensor, target_channels: int) -> torch.Tensor:
batch, h, w, channels = tensor.shape
if channels == target_channels:
return tensor
if target_channels >= 3 and channels == 1:
rgb = tensor.expand(-1, -1, -1, 3)
if target_channels == 4:
alpha = torch.ones((batch, h, w, 1), device=tensor.device, dtype=tensor.dtype)
return torch.cat([rgb, alpha], dim=-1)
return rgb
if target_channels == 4 and channels == 3:
alpha = torch.ones((batch, h, w, 1), device=tensor.device, dtype=tensor.dtype)
return torch.cat([tensor, alpha], dim=-1)
if target_channels == 3 and channels == 4:
return tensor[..., :3]
if channels < target_channels:
check_budget(batch, h, w, target_channels - channels, label='ImageOps Append (Coerce Channels)', device=tensor.device)
padding = torch.zeros((batch, h, w, target_channels - channels), device=tensor.device, dtype=tensor.dtype)
if target_channels >= 4 and channels <= 3:
padding[..., -1] = 1.0
return torch.cat([tensor, padding], dim=-1)
return tensor[..., :target_channels]
def _pad_to_size(source: torch.Tensor, target_w: int, target_h: int, pad_alpha: str = 'opaque') -> torch.Tensor:
batch, source_h, source_w, channels = source.shape
if source_w == target_w and source_h == target_h:
return source
check_budget(batch, target_h, target_w, channels, label='ImageOps Append (Pad to Size)', device=source.device)
out = torch.zeros((batch, target_h, target_w, channels), device=source.device, dtype=source.dtype)
if channels >= 4 and str(pad_alpha or 'opaque').strip().lower() != 'transparent':
out[..., 3] = 1.0
left = max(0, (target_w - source_w) // 2)
top = max(0, (target_h - source_h) // 2)
out[:, top:top + source_h, left:left + source_w, :] = source
return out
def _align_pair(image_a: torch.Tensor, image_b: torch.Tensor, fit_mode: str, pad_alpha: str = 'opaque') -> tuple[torch.Tensor, torch.Tensor]:
a_h, a_w = (int(image_a.shape[1]), int(image_a.shape[2]))
b_h, b_w = (int(image_b.shape[1]), int(image_b.shape[2]))
if a_w == b_w and a_h == b_h:
return (image_a, image_b)
mode = str(fit_mode or 'strict').strip().lower()
if mode == 'resize_to_first':
return (image_a, _resize(image_b, a_w, a_h, mode='bicubic', antialias=True))
if mode == 'pad_to_max':
target_w = max(a_w, b_w)
target_h = max(a_h, b_h)
return (_pad_to_size(image_a, target_w, target_h, pad_alpha), _pad_to_size(image_b, target_w, target_h, pad_alpha))
raise ValueError(f'ImageOps Append requires matching dimensions in strict mode. image_a is {a_w}x{a_h}, image_b is {b_w}x{b_h}. Use resize_to_first or pad_to_max to align them.')
class ImageOpsAppend(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(node_id='ImageOpsAppend', display_name='〽️ Image Ops Append', category='image/imageops', essentials_category='Video Tools', has_intermediate_output=True, search_aliases=['append', 'join', 'concat', 'concatenate', 'clips', 'sequence'], inputs=[io.Boolean.Input('bypass', default=False), io.Combo.Input('fit_mode', options=['strict', 'resize_to_first', 'pad_to_max'], default='strict', tooltip='How to align two clips before concatenating their frame batches.'), io.String.Input('trims_json', default='{"version":1,"clips":[]}', multiline=False, tooltip='Managed by the Append preview controls.'), io.MultiType.Input('image_1', types=[io.Image, io.Video], display_name='Images/Video 1', optional=True, extra_dict={'forceInput': True}), io.MultiType.Input('image_2', types=[io.Image, io.Video], display_name='Images/Video 2', optional=True, extra_dict={'forceInput': True}), io.Float.Input('image_fps', default=24.0, min=1.0, max=1000.0, step=0.001, optional=True, tooltip='Frame rate assumed for plain IMAGE clips (no VIDEO fps of their own) when checking that all clips share a frame rate.'), io.Combo.Input('pad_alpha', options=['opaque', 'transparent'], default='opaque', optional=True, tooltip='Alpha of the padding added by fit_mode=pad_to_max. opaque matches this node\'s historical behavior; transparent leaves the added margins see-through for compositing.')], outputs=[io.Image.Output('image', display_name='image'), io.Int.Output('frame_count', display_name='frame_count'), io.Int.Output('width', display_name='width'), io.Int.Output('height', display_name='height'), io.Video.Output('video', display_name='video', tooltip='Native VIDEO output. Carries the real audio/fps from the first connected VIDEO clip (not a plain IMAGE batch), if any.')])
@classmethod
def execute(cls, bypass=False, fit_mode='strict', trims_json='{"version":1,"clips":[]}', image_fps=24.0, pad_alpha='opaque', **inputs):
progress = start_progress()
clips = _sorted_clip_inputs(inputs)
if not clips:
raise ValueError('ImageOps Append needs at least one connected image/video input.')
if bool(bypass):
clips = clips[:1]
trims = _parse_trims(trims_json)
tensors: list[torch.Tensor] = []
clip_metadata: list[dict[str, int]] = []
has_media = False
fps = 24.0
sample_rate = 44100
audio_list = []
effective_fps: list[tuple[int, float]] = []
# Collect each clip's own trimmed VideoInput so the VIDEO output can be built
# via VideoFromList (which avoids a full re-encode when every clip is already
# a compatible VideoFromFile) instead of always re-encoding the concatenated
# tensor. Dropped to None the moment any clip can't participate; the IMAGE
# tensor output below is unaffected either way, since it always needs the
# decoded frames regardless of this.
native_video_sources: list | None = [] if str(fit_mode or 'strict').strip().lower() == 'strict' else None
for clip_index, value in clips:
video_media = extract_video_media(value)
if isinstance(value, ImageOpsMedia):
is_media = True
clip_audio, clip_fps, clip_sample_rate = value.audio, value.fps, getattr(value, 'sample_rate', 44100)
elif video_media is not None:
is_media = True
_, clip_fps, clip_audio, clip_sample_rate = video_media
else:
is_media = False
clip_audio, clip_fps, clip_sample_rate = None, float(image_fps), 44100
tensor = _select_media_tensor(value, None).float()
start, end = trims.get(clip_index, (0, -1))
source_count = int(tensor.shape[0])
indices = trim_indices(source_count, start, end, label=f'ImageOps Append (clip slot {clip_index})')
trimmed = tensor[indices]
tensors.append(trimmed)
effective_fps.append((clip_index, clip_fps if is_media else float(image_fps)))
if native_video_sources is not None:
is_native_video = not isinstance(value, ImageOpsMedia) and video_media is not None and callable(getattr(value, 'as_trimmed', None))
native_trimmed = value.as_trimmed(indices[0] / clip_fps, len(indices) / clip_fps) if is_native_video and clip_fps > 0 else None
if native_trimmed is not None:
native_video_sources.append(native_trimmed)
else:
native_video_sources = None
if is_media:
if not has_media:
fps = clip_fps
sample_rate = clip_sample_rate
has_media = True
if clip_audio is not None and clip_fps > 0:
trimmed_audio = _slice_audio_for_indices(clip_audio, indices, clip_fps, clip_sample_rate)
audio_list.append(trimmed_audio)
else:
audio_list.append(None)
else:
audio_list.append(None)
clip_metadata.append({'slot': int(clip_index), 'source_count': source_count, 'trimmed_count': int(trimmed.shape[0]), 'start': int(start), 'end': int(end), 'sample_rate': int(clip_sample_rate)})
if len(effective_fps) > 1:
ref_slot, ref_fps = effective_fps[0]
mismatched_fps = [
(slot, clip_fps, next(m['trimmed_count'] for m in clip_metadata if m['slot'] == slot) / clip_fps)
for slot, clip_fps in effective_fps[1:]
if not _fps_matches(clip_fps, ref_fps)
]
if mismatched_fps:
details = ', '.join(f'slot {slot}: {clip_fps:.3f} fps ({duration:.2f}s)' for slot, clip_fps, duration in mismatched_fps)
raise ValueError(
f'ImageOps Append requires matching frame rates. Output fps is {ref_fps:.3f} '
f'(from clip slot {ref_slot}); {details} do not match. Use clips with a matching '
f'frame rate, or set image_fps to declare the rate of plain IMAGE clips.'
)
max_channels = max((int(t.shape[3]) for t in tensors))
tensors = [_coerce_channels(t, max_channels) for t in tensors]
if len(tensors) == 1:
out_tensor = tensors[0]
else:
aligned = [tensors[0]]
for tensor in tensors[1:]:
first, current = _align_pair(aligned[0], tensor, fit_mode, pad_alpha)
if first is not aligned[0]:
aligned = [first] + [_align_pair(first, item, fit_mode, pad_alpha)[1] for item in aligned[1:]]
aligned.append(current)
out_tensor = torch.cat(aligned, dim=0)
if has_media:
# Concatenate audio
mismatched_slots = [
meta['slot'] for meta, audio in zip(clip_metadata, audio_list)
if audio is not None and meta['sample_rate'] != sample_rate
]
if mismatched_slots:
raise ValueError(
f'ImageOps Append cannot concatenate audio at different sample rates without resampling. '
f'Output sample rate is {sample_rate} Hz (from the first clip); '
f'clip slot(s) {mismatched_slots} use a different sample rate. '
f'Resample those clips to {sample_rate} Hz before Append, or trim out their audio.'
)
out_audio = None
if any(a is not None for a in audio_list):
chunks = []
for audio, tensor_clip in zip(audio_list, tensors):
if audio is not None:
chunks.append(audio)
else:
fc = tensor_clip.shape[0]
samples = int(round(fc * sample_rate / fps)) if fps > 0 else 0
valid_audio = next(a for a in audio_list if a is not None)
channels = valid_audio.shape[0]
chunks.append(torch.zeros((channels, samples), device=valid_audio.device, dtype=valid_audio.dtype))
if chunks:
out_audio = torch.cat(chunks, dim=-1)
out = ImageOpsMedia(frames=out_tensor, fps=fps, audio=out_audio, sample_rate=sample_rate)
if native_video_sources is not None and len(native_video_sources) == len(clips):
video_out = VideoFromList(native_video_sources)
else:
video_out = media_to_video(out_tensor, fps, out_audio, sample_rate)
else:
out = out_tensor
video_out = media_to_video(out_tensor, float(image_fps), None, 44100)
progress.finish()
frame_count = int(out_tensor.shape[0])
height = int(out_tensor.shape[1])
width = int(out_tensor.shape[2])
return build_node_preview_result(out_tensor, (out, frame_count, width, height, video_out), prefix='imageops_append', metadata={'imageops_append_frame_count': [frame_count], 'imageops_append_clip_counts': [clip_metadata]})