diff --git a/CHANGELOG.md b/CHANGELOG.md index 444934ee4..4582342f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Change Log for SD.Next -## Update for 2026-07-18 +## Update for 2026-07-19 - **Compute** - torch: update to `2.13.0` for CUDA, ROCm, IPEX @@ -11,8 +11,9 @@ - sdnq attention optimizations - sdnq separate dit/te settings - **Features** - - SeedVR enhanced support - - Propagate server tracebacks to client + - seedvr: enhanced upscaler support + - process: read video properties metadata + - logs: propagate server tracebacks to client - **Fixes** - upscaler auto-refresh to catch chainner upscalers that are not loaded on first attempt - lora support diffusers trainer diff --git a/extensions-builtin/sdnext-modernui b/extensions-builtin/sdnext-modernui index 937554e88..9868ac66d 160000 --- a/extensions-builtin/sdnext-modernui +++ b/extensions-builtin/sdnext-modernui @@ -1 +1 @@ -Subproject commit 937554e88f653a5ca3f32890fa1956d49da3e947 +Subproject commit 9868ac66d0874d659217b01b07cc7e4235ee79f7 diff --git a/modules/api/process.py b/modules/api/process.py index 47dc76003..1a5027714 100644 --- a/modules/api/process.py +++ b/modules/api/process.py @@ -292,7 +292,7 @@ class APIProcess: reqDict, script_args = self.set_upscalers(req) reqDict['image'] = helpers.decode_base64_to_image(reqDict['image']) with self.queue_lock: - result = postprocessing.run_extras(extras_mode=0, image_folder="", input_dir="", output_dir="", save_output=False, script_args=script_args, **reqDict) + result = postprocessing.run_extras(extras_mode=0, image_folder="", input_dir="", output_dir="", video="", save_output=False, script_args=script_args, **reqDict) return models.ResProcessImage(image=helpers.encode_pil_to_base64(result[0][0]), html_info=result[1]) def extras_batch_images_api(self, req: models.ReqProcessBatch): @@ -301,5 +301,5 @@ class APIProcess: image_list = reqDict.pop('imageList', []) image_folder = [helpers.decode_base64_to_image(x.data) for x in image_list] with self.queue_lock: - result = postprocessing.run_extras(extras_mode=1, image_folder=image_folder, image="", input_dir="", output_dir="", save_output=False, script_args=script_args, **reqDict) + result = postprocessing.run_extras(extras_mode=1, image_folder=image_folder, image="", input_dir="", output_dir="", video="", save_output=False, script_args=script_args, **reqDict) return models.ResProcessBatch(images=list(map(helpers.encode_pil_to_base64, result[0])), html_info=result[1]) diff --git a/modules/control/util.py b/modules/control/util.py index fccfa0abc..f456b55f6 100644 --- a/modules/control/util.py +++ b/modules/control/util.py @@ -161,6 +161,8 @@ def blend(images): def decode_fourcc(cc): + if cc is None: + return None cc_bytes = int(cc).to_bytes(4, byteorder=sys.byteorder) # convert code to a bytearray cc_str = cc_bytes.decode() # decode byteaarray to a string return cc_str diff --git a/modules/postprocessing.py b/modules/postprocessing.py index cbdf55ca6..15ef99e5b 100644 --- a/modules/postprocessing.py +++ b/modules/postprocessing.py @@ -9,12 +9,19 @@ from modules.shared import opts from modules.paths import resolve_output_path -def run_postprocessing(extras_mode, image, image_folder: list[tempfile.NamedTemporaryFile], input_dir, output_dir, show_extras_results, *args, save_output: bool = True): +def run_postprocessing(extras_mode, + image, + image_folder: list[tempfile.NamedTemporaryFile], + input_dir, + output_dir, + extras_video, + show_extras_results, + *args, + save_output: bool = True): devices.torch_gc() shared.state.begin('Extras') image_data = [] image_names = [] - image_fullnames = [] image_ext = [] outputs = [] params = {} @@ -32,7 +39,6 @@ def run_postprocessing(extras_mode, image, image_folder: list[tempfile.NamedTemp log.error(f'Failed to open image: file="{img.name}" {e}') continue fn, ext = os.path.splitext(img.orig_name) - image_fullnames.append(img.name) image_data.append(image) image_names.append(fn) image_ext.append(ext) @@ -47,11 +53,12 @@ def run_postprocessing(extras_mode, image, image_folder: list[tempfile.NamedTemp except Exception as e: log.error(f'Failed to open image: file="{fn}" {e}') continue - image_fullnames.append(fn) image_data.append(image) image_names.append(fn) image_ext.append(None) log.debug(f'Process: mode=folder inputs={input_dir} files={len(image_list)} images={len(image_data)}') + elif extras_mode == 3: + log.error(f'Process: mode=video file="{extras_video}" not implemented yet') else: image_data.append(image) image_names.append(None) @@ -60,6 +67,7 @@ def run_postprocessing(extras_mode, image, image_folder: list[tempfile.NamedTemp outpath = output_dir else: outpath = resolve_output_path(opts.outdir_samples, opts.outdir_extras_samples) + processed_images = [] for image, name, ext in zip(image_data, image_names, image_ext, strict=False): # pylint: disable=redefined-argument-from-local log.debug(f'Process: image={image} {args}') @@ -67,6 +75,12 @@ def run_postprocessing(extras_mode, image, image_folder: list[tempfile.NamedTemp if shared.state.interrupted: log.debug('Postprocess interrupted') break + if isinstance(image, str): + try: + image = Image.open(image) + except Exception as e: + log.error(f'Failed to open image: file="{image}" {e}') + continue if image is None: continue shared.state.textinfo = name @@ -100,7 +114,7 @@ def run_postprocessing(extras_mode, image, image_folder: list[tempfile.NamedTemp return outputs, info, params -def run_extras(extras_mode, resize_mode, image, image_folder, input_dir, output_dir, show_extras_results, upscaling_resize, upscaling_resize_w, upscaling_resize_h, upscaling_crop, extras_upscaler_1, extras_upscaler_2, extras_upscaler_2_visibility, save_output: bool = True, script_args: dict | None = None): +def run_extras(extras_mode, resize_mode, image, image_folder, input_dir, output_dir, video, show_extras_results, upscaling_resize, upscaling_resize_w, upscaling_resize_h, upscaling_crop, extras_upscaler_1, extras_upscaler_2, extras_upscaler_2_visibility, save_output: bool = True, script_args: dict | None = None): """old handler for API""" merged = { @@ -120,4 +134,4 @@ def run_extras(extras_mode, resize_mode, image, image_folder, input_dir, output_ merged.setdefault(name, {}).update(kvs or {}) args = scripts_manager.scripts_postproc.create_args_for_run(merged) - return run_postprocessing(extras_mode, image, image_folder, input_dir, output_dir, show_extras_results, *args, save_output=save_output) + return run_postprocessing(extras_mode, image, image_folder, input_dir, output_dir, video, show_extras_results, *args, save_output=save_output) diff --git a/modules/ui_postprocessing.py b/modules/ui_postprocessing.py index d3fa49e42..6776f67ed 100644 --- a/modules/ui_postprocessing.py +++ b/modules/ui_postprocessing.py @@ -1,3 +1,4 @@ +import os import gradio as gr from modules import scripts_manager, shared, ui_common, postprocessing, call_queue, generation_parameters_copypaste from modules.logger import log @@ -12,9 +13,26 @@ def submit_info(image): return infotext_to_html(geninfo), info, geninfo -def submit_process(tab_index, extras_image, image_batch, extras_batch_input_dir, extras_batch_output_dir, show_extras_results, save_output, *script_inputs): +def submit_video(video): + if not video or not isinstance(video, str) or not os.path.isfile(video): + return '', '', '' + from modules.video import get_video_info, get_video_metadata + info = get_video_info(video) + metadata = get_video_metadata(video) + if metadata: + info['metadata'] = metadata + text = '' + html = '' + html = [f'{k}: {v}' for k, v in info.items()] + html = '
'.join(html) + text = [f'{k}: {v}' for k, v in info.items()] + text = ', '.join(text) + return html, '', text + + +def submit_process(tab_index, extras_image, image_batch, extras_batch_input_dir, extras_batch_output_dir, extras_video, show_extras_results, save_output, *script_inputs): from modules.ui_common import infotext_to_html - result_images, geninfo, _js_info = postprocessing.run_postprocessing(tab_index, extras_image, image_batch, extras_batch_input_dir, extras_batch_output_dir, show_extras_results, *script_inputs, save_output=save_output) + result_images, geninfo, _js_info = postprocessing.run_postprocessing(tab_index, extras_image, image_batch, extras_batch_input_dir, extras_batch_output_dir, extras_video, show_extras_results, *script_inputs, save_output=save_output) return result_images, geninfo, infotext_to_html(geninfo) @@ -25,14 +43,15 @@ def create_ui(): with gr.Column(variant='compact'): with gr.Tabs(elem_id="mode_extras"): with gr.Tab('Process Image', id="single_image", elem_id="extras_single_tab") as tab_single: - with gr.Row(): - extras_image = gr.Image(label="Source", interactive=True, type="pil", elem_id="extras_image") + extras_image = gr.Image(label="Source", interactive=True, type="pil", elem_id="extras_image") with gr.Tab('Process Batch', id="batch_process", elem_id="extras_batch_process_tab") as tab_batch: image_batch = gr.Files(label="Batch process", interactive=True, elem_id="extras_image_batch") with gr.Tab('Process Folder', id="batch_from_directory", elem_id="extras_batch_directory_tab") as tab_batch_dir: extras_batch_input_dir = gr.Textbox(label="Input directory", **shared.hide_dirs, placeholder="A directory on the same machine where the server is running.", elem_id="extras_batch_input_dir") extras_batch_output_dir = gr.Textbox(label="Output directory", **shared.hide_dirs, placeholder="Leave blank to save images to the default path.", elem_id="extras_batch_output_dir") show_extras_results = gr.Checkbox(label='Show result images', value=True, elem_id="extras_show_extras_results") + with gr.Tab('Process Video', id="process_video", elem_id="extras_process_video_tab") as tab_process_video: + extras_video = gr.Video(label="Input Video", show_label=False, interactive=True, elem_id="extras_video") with gr.Row(): save_output = gr.Checkbox(label='Save output', value=True, elem_id="extras_save_output") @@ -60,7 +79,11 @@ def create_ui(): tab_single.select(fn=lambda: 0, inputs=[], outputs=[tab_index]) tab_batch.select(fn=lambda: 1, inputs=[], outputs=[tab_index]) tab_batch_dir.select(fn=lambda: 2, inputs=[], outputs=[tab_index]) + tab_process_video.select(fn=lambda: 3, inputs=[], outputs=[tab_index]) + extras_image.change(fn=submit_info, inputs=[extras_image], outputs=[html_info_formatted, exif_info, generation_info]) + extras_video.change(fn=submit_video, inputs=[extras_video], outputs=[html_info_formatted, exif_info, generation_info]) + submit.click( _js="submit_postprocessing", fn=call_queue.wrap_gradio_gpu_call(submit_process, extra_outputs=[None, ''], name='Postprocess'), @@ -70,6 +93,7 @@ def create_ui(): image_batch, extras_batch_input_dir, extras_batch_output_dir, + extras_video, show_extras_results, save_output, *script_inputs, diff --git a/modules/video.py b/modules/video.py index 0a255d8e3..f94324a0b 100644 --- a/modules/video.py +++ b/modules/video.py @@ -105,7 +105,10 @@ def get_video_params(filepath: str, capture: bool = False): raise RuntimeError(msg) frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) fps = round(video.get(cv2.CAP_PROP_FPS), 2) - duration = round(float(frames) / fps, 2) + if fps > 0: + duration = round(float(frames) / fps, 2) + else: + duration = 0 w, h = int(video.get(cv2.CAP_PROP_FRAME_WIDTH)), int(video.get(cv2.CAP_PROP_FRAME_HEIGHT)) codec = decode_fourcc(video.get(cv2.CAP_PROP_FOURCC)) frame = None @@ -115,3 +118,92 @@ def get_video_params(filepath: str, capture: bool = False): frame = Image.fromarray(frame) video.release() return frames, fps, duration, w, h, codec, frame + + +def get_video_info(filepath: str): + import cv2 + from modules.control.util import decode_fourcc + + try: + info = { + 'file': os.path.basename(filepath), + 'size': os.path.getsize(filepath), + 'container': os.path.splitext(filepath)[1].lower().lstrip('.'), + } + except Exception as e: + log.error(f'Video probe failed: path="{filepath}" {e}') + return {} + + def get_prop(video, name: str, label: str | None, cast: type = float): + prop_id = getattr(cv2, name, None) + if prop_id is None: + return None + try: + value = video.get(prop_id) + if value is None: + return None + value = cast(value) + if value == 0: + return None + if label is not None: + info[label] = value + return value + except Exception: + return None + + video = cv2.VideoCapture(filepath) + try: + if not video.isOpened(): + msg = f'Video open failed: path="{filepath}"' + info['error'] = msg + log.error(msg) + return info + try: + backend = video.getBackendName() + if backend: + info['backend'] = backend + except Exception: + pass + + frames = get_prop(video, 'CAP_PROP_FRAME_COUNT', 'frames', int) or 0 + fps = get_prop(video, 'CAP_PROP_FPS', 'fps', float) or 0 + _width = get_prop(video, 'CAP_PROP_FRAME_WIDTH', 'width', int) or 0 + _height = get_prop(video, 'CAP_PROP_FRAME_HEIGHT', 'height', int) or 0 + _bitrate = get_prop(video, 'CAP_PROP_BITRATE', 'bitrate_kbps', float) + _orientation = get_prop(video, 'CAP_PROP_ORIENTATION_META', 'rotation', float) + + if frames > 0 and fps > 0: + info['duration'] = round(float(frames) / fps, 3) + + fourcc = get_prop(video, 'CAP_PROP_FOURCC', None, int) or 0 + info['codec'] = decode_fourcc(fourcc) + + pix = get_prop(video, 'CAP_PROP_CODEC_PIXEL_FORMAT', None, int) or 0 + info['format'] = decode_fourcc(pix) + + sar_num = get_prop(video, 'CAP_PROP_SAR_NUM', None, int) or 1 + sar_den = get_prop(video, 'CAP_PROP_SAR_DEN', None, int) or 1 + if (sar_num > 0 and sar_den > 0) and (sar_num != 1 or sar_den != 1): + info['sar'] = f'{sar_num}/{sar_den}' + + audio_streams = get_prop(video, 'CAP_PROP_AUDIO_TOTAL_STREAMS', 'audio_streams', int) or 0 + audio_channels = get_prop(video, 'CAP_PROP_AUDIO_TOTAL_CHANNELS', 'audio_channels', int) or 0 + audio_sample_rate = get_prop(video, 'CAP_PROP_AUDIO_SAMPLES_PER_SECOND', 'audio_sample_rate', int) or 0 + if audio_streams > 0 or audio_channels > 0 or audio_sample_rate > 0: + info['audio'] = f'{audio_streams}x{audio_channels}x{audio_sample_rate}' + + return info + except Exception as e: + log.error(f'Video probe failed: path="{filepath}" {e}') + return info + finally: + video.release() + + +def get_video_metadata(video): + if not video or not isinstance(video, str) or not os.path.isfile(video): + return {} + from modules.video_models.video_utils import check_av + av = check_av() + with av.open(video, mode="r") as container: + return dict(container.metadata)