From 52fcb3a1fda4ab40be1c885ae74e72366a7097b7 Mon Sep 17 00:00:00 2001 From: 28allday Date: Tue, 19 May 2026 08:23:13 +0100 Subject: [PATCH] Add image-sequence to ProRes encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JPG/PNG/TIFF/EXR/DPX frame folders can now be queued and encoded to .mov alongside regular video files. A new "Add image sequence" entry point picks a folder, auto-detects sequences inside, and queues each as its own job. Frame rate is set globally in the settings pane (default 24 fps, presets + free-form spinner) and persists across launches. ffmpeg input switches to the image2 demuxer (-framerate / -start_number / -i ) with no audio mapping and no hwaccel. EXR/DPX also get -vsync passthrough. Progress parsing is unchanged — we set job.duration = frame_count / fps so the existing out_time_us math is correct for sequences. Output naming reuses the suffix rule with the stripped stem (shot_####.png -> shot_prores_hq.mov). --- nocoder/config.py | 3 +- nocoder/data.py | 18 ++++ nocoder/encoder.py | 202 +++++++++++++++++++++++++++++++++++---- nocoder/queue_pane.py | 49 +++++++++- nocoder/sequence_scan.py | 116 ++++++++++++++++++++++ nocoder/settings_pane.py | 156 +++++++++++++++++++++++++++++- nocoder/window.py | 122 ++++++++++++++++++++++- 7 files changed, 634 insertions(+), 32 deletions(-) create mode 100644 nocoder/sequence_scan.py diff --git a/nocoder/config.py b/nocoder/config.py index 68f090d..a7d7da4 100644 --- a/nocoder/config.py +++ b/nocoder/config.py @@ -13,7 +13,8 @@ Schema (all keys optional): "naming": "suffix" | "keep", "audio_bits": 16 | 24, "auto_reveal": false, - "cpu_pane_expanded": true + "cpu_pane_expanded": true, + "sequence_fps": 24.0 } """ from __future__ import annotations diff --git a/nocoder/data.py b/nocoder/data.py index e4003d0..44960fa 100644 --- a/nocoder/data.py +++ b/nocoder/data.py @@ -55,6 +55,24 @@ def is_video_path(path: str) -> bool: return any(lower.endswith(ext) for ext in VIDEO_EXTENSIONS) +# Image-sequence frame formats accepted by the "Add image sequence folder…" +# entry point. ffmpeg's image2 demuxer handles all five natively; EXR/DPX +# dither to 10-bit ProRes (lossless for 8-bit JPG/PNG, lossy headroom-wise +# for floating-point EXR). +SEQUENCE_EXTENSIONS: frozenset[str] = frozenset({ + ".jpg", ".jpeg", + ".png", + ".tif", ".tiff", + ".exr", + ".dpx", +}) + + +def is_sequence_frame_path(path: str) -> bool: + lower = path.lower() + return any(lower.endswith(ext) for ext in SEQUENCE_EXTENSIONS) + + # Subdirectory names to SKIP when recursively walking a dropped folder or # camera card. The names match case-insensitively. # diff --git a/nocoder/encoder.py b/nocoder/encoder.py index 8aca2d0..ea54f12 100644 --- a/nocoder/encoder.py +++ b/nocoder/encoder.py @@ -116,6 +116,46 @@ class Metadata: return "—" +@dataclass +class SequenceSpec: + """A detected image sequence (group of numbered frames in a folder). + + Built by `sequence_scan.scan_folder()`. Drives the ffmpeg image2-demuxer + input form: `-framerate FPS -start_number N -i /%0Pd`. + """ + dir: str # absolute folder containing the frames + prefix: str # stem before the digit run; "" if frames are pure digits + ext: str # ".png" / ".exr" / ... (lowercase, leading dot) + padding: int # 4 for shot_0001.png; 0 for unpadded + start_frame: int + frame_count: int # frames actually found in the group + expected_frames: int # max - min + 1; > frame_count means there are gaps + fps: float # at queue time, from settings.sequence_fps + + @property + def pattern_basename(self) -> str: + digits = f"%0{self.padding}d" if self.padding > 0 else "%d" + return f"{self.prefix}{digits}{self.ext}" + + @property + def pattern_path(self) -> str: + return str(Path(self.dir) / self.pattern_basename) + + @property + def stripped_stem(self) -> str: + # Output naming base: trim trailing _ . - left after stripping digits. + # Pure-digit frames (prefix == "") fall back to the folder name. + return self.prefix.rstrip("_.-") or Path(self.dir).name + + @property + def first_frame_path(self) -> str: + if self.padding > 0: + name = f"{self.prefix}{self.start_frame:0{self.padding}d}{self.ext}" + else: + name = f"{self.prefix}{self.start_frame}{self.ext}" + return str(Path(self.dir) / name) + + def probe_metadata(path: str) -> Metadata: """Run ffprobe synchronously. Callers should invoke from a worker thread. @@ -178,6 +218,55 @@ def probe_metadata(path: str) -> Metadata: return meta +def probe_sequence_metadata(spec: SequenceSpec) -> Metadata: + """Build a Metadata for an image sequence without running probe_metadata. + + probe_metadata expects a single demuxable file; the image2-demuxer pattern + can't be ffprobed directly. We synthesise duration/fps from the spec, then + ffprobe just the first frame for width/height/pix_fmt (so the alpha flag + is accurate when the user picks 4444+alpha against a real RGBA EXR/PNG). + """ + duration = spec.frame_count / spec.fps if spec.fps > 0 else 0.0 + meta = Metadata( + duration=duration, + codec=spec.ext.lstrip(".").upper(), + fps=spec.fps, + audio_stream_indexes=[], + ) + first = spec.first_frame_path + try: + proc = subprocess.run( + [ + FFPROBE, "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height,pix_fmt", + "-of", "json", + first, + ], + capture_output=True, text=True, timeout=10, check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return meta + if proc.returncode != 0 or not proc.stdout: + return meta + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError: + return meta + streams = data.get("streams") or [] + if not streams: + return meta + stream = streams[0] + try: + meta.width = int(stream.get("width") or 0) + meta.height = int(stream.get("height") or 0) + except (TypeError, ValueError): + pass + pix_fmt = (stream.get("pix_fmt") or "").lower() + meta.alpha = _pix_fmt_has_alpha(pix_fmt) + return meta + + # Concrete pixel-format tokens that carry an alpha channel. The earlier # heuristic (`"a" in pix_fmt.split("p", 1)[0]`) misfired on grayscale formats # because "gray" contains the letter 'a'. @@ -218,6 +307,13 @@ def _human_codec(name: str) -> str: return _CODEC_NAMES.get(name.lower(), name.upper() if name else "") +def _format_fps(fps: float) -> str: + """ffmpeg-friendly fps string — int if whole, else 3-decimal.""" + if fps == int(fps): + return str(int(fps)) + return f"{fps:.3f}".rstrip("0").rstrip(".") + + def build_command( src: str, out: str, @@ -226,6 +322,7 @@ def build_command( encoder: str, audio_indexes: Optional[list[int]] = None, audio_bits: int = 16, + sequence: Optional[SequenceSpec] = None, ) -> list[str]: """Assemble the ffmpeg command list for a single encode. @@ -239,6 +336,10 @@ def build_command( audio_indexes is None → fallback `-map 0:a:0?` (first audio, optional) for ad-hoc callers who haven't probed yet + + `sequence`, when set, switches the input to the image2 demuxer + (`-framerate FPS -start_number N -i `) and forces no-audio. The + `src` arg is ignored for sequences; pass the first-frame path or "" for it. """ profile = PROFILES_BY_ID[profile_id] pix_fmt = pick_pixel_format(profile_id, alpha) @@ -246,23 +347,38 @@ def build_command( FFMPEG, "-hide_banner", "-loglevel", "error", "-y", "-nostdin", ] - hw = get_hwaccel() - if hw: - # ffmpeg silently falls back to CPU decode for codecs the GPU can't - # handle (MJPEG, ProRes input, etc.), so unconditional -hwaccel is safe. - cmd += ["-hwaccel", hw] - cmd += ["-i", src, "-map", "0:v:0"] - - if audio_indexes is None: - # No probe info → safe fallback (first known audio, optional). - cmd += ["-map", "0:a:0?"] - has_audio = True - elif audio_indexes: - for idx in audio_indexes: - cmd += ["-map", f"0:{idx}"] - has_audio = True - else: + if sequence is not None: + # image2 demuxer: deterministic frame ordering via %0Nd, no hwaccel + # (it's decoder-side, irrelevant for still-image input), no audio. + # -vsync passthrough on EXR/DPX keeps ffmpeg from dropping/duplicating + # frames when the file mtimes look unusual. + if sequence.ext in (".exr", ".dpx"): + cmd += ["-vsync", "passthrough"] + cmd += [ + "-framerate", _format_fps(sequence.fps), + "-start_number", str(sequence.start_frame), + "-i", sequence.pattern_path, + "-map", "0:v:0", + ] has_audio = False + else: + hw = get_hwaccel() + if hw: + # ffmpeg silently falls back to CPU decode for codecs the GPU can't + # handle (MJPEG, ProRes input, etc.), so unconditional -hwaccel is safe. + cmd += ["-hwaccel", hw] + cmd += ["-i", src, "-map", "0:v:0"] + + if audio_indexes is None: + # No probe info → safe fallback (first known audio, optional). + cmd += ["-map", "0:a:0?"] + has_audio = True + elif audio_indexes: + for idx in audio_indexes: + cmd += ["-map", f"0:{idx}"] + has_audio = True + else: + has_audio = False if encoder == "ks": cmd += ["-c:v", "prores_ks", "-profile:v", profile.id, "-pix_fmt", pix_fmt] @@ -285,16 +401,40 @@ def build_command( return cmd -def format_preview_command(src_name: str, out_path: str, profile_id: str, alpha: bool, audio_bits: int = 16) -> str: +def format_preview_command( + src_name: str, + out_path: str, + profile_id: str, + alpha: bool, + audio_bits: int = 16, + sequence: Optional[SequenceSpec] = None, +) -> str: """Pretty multi-line preview for the ffmpeg command box. Uses prores_ks always. The real command maps each known audio stream by absolute index; the preview shows `0:a?` (glob-all) for brevity — the runtime behaviour is equivalent when every audio stream is known-codec. + + When `sequence` is provided, renders the image2-demuxer form (no hwaccel, + no audio map / codec) using shlex-quoted pattern path. """ profile = PROFILES_BY_ID[profile_id] pix_fmt = pick_pixel_format(profile_id, alpha) alpha_flag = " -alpha_bits 16" if (alpha and profile.pid >= 4) else "" + if sequence is not None: + vsync_line = " -vsync passthrough \\\n" if sequence.ext in (".exr", ".dpx") else "" + return ( + "ffmpeg -hide_banner -y \\\n" + + vsync_line + + f" -framerate {_format_fps(sequence.fps)} \\\n" + + f" -start_number {sequence.start_frame} \\\n" + + f" -i {shlex.quote(sequence.pattern_path)} \\\n" + " -map 0:v:0 \\\n" + f" -c:v prores_ks -profile:v {profile.id} \\\n" + f" -pix_fmt {pix_fmt}{alpha_flag} \\\n" + " -movflags +use_metadata_tags \\\n" + f" {shlex.quote(out_path)}" + ) hw = get_hwaccel() hw_line = f" -hwaccel {hw} \\\n" if hw else "" audio_codec = "pcm_s24le" if audio_bits == 24 else "pcm_s16le" @@ -311,9 +451,19 @@ def format_preview_command(src_name: str, out_path: str, profile_id: str, alpha: ) -def plan_output_path(src: str, out_dir: str, naming: str, profile_id: str) -> str: - """Output path rules from prowrap-yad.sh: keep vs suffix, with ' (N)' disambiguation.""" - stem = Path(src).stem +def plan_output_path( + src: str, + out_dir: str, + naming: str, + profile_id: str, + stem_override: Optional[str] = None, +) -> str: + """Output path rules from prowrap-yad.sh: keep vs suffix, with ' (N)' disambiguation. + + `stem_override`, when given, replaces the filename-derived stem. Used for + image-sequence jobs where the "name" comes from a SequenceSpec, not a file. + """ + stem = stem_override if stem_override is not None else Path(src).stem if naming == "suffix": base = f"{stem}_prores_{profile_id}" else: @@ -344,6 +494,12 @@ class EncodeJob: # build_command can map each known audio stream by absolute index. # Empty list = silent video; None = no probe info (safe fallback applies). audio_stream_indexes: Optional[list[int]] = None + # When set, this is an image-sequence job, not a single-file job. `src` + # still carries the first-frame path (load-bearing for `_copy_mtime` and + # the orphan marker), but the ffmpeg input form switches to image2. + # `duration` should be set to `frame_count / fps` by the caller so the + # existing -progress parser produces correct percentages. + sequence: Optional[SequenceSpec] = None cancel_event: threading.Event = field(default_factory=threading.Event) _proc: Optional[subprocess.Popen] = None @@ -363,7 +519,11 @@ def run_encode(job: EncodeJob, profile_id: str, alpha: bool, encoder: str, audio job.on_done(False, "cancelled") return - cmd = build_command(job.src, job.out, profile_id, alpha, encoder, job.audio_stream_indexes, audio_bits) + cmd = build_command( + job.src, job.out, profile_id, alpha, encoder, + job.audio_stream_indexes, audio_bits, + sequence=job.sequence, + ) try: proc = subprocess.Popen( cmd, diff --git a/nocoder/queue_pane.py b/nocoder/queue_pane.py index 3590859..0ef2120 100644 --- a/nocoder/queue_pane.py +++ b/nocoder/queue_pane.py @@ -30,7 +30,7 @@ gi.require_version("GObject", "2.0") from gi.repository import GdkPixbuf, GLib, GObject, Gdk, Gio, Gtk from .data import VIDEO_EXTENSIONS, format_bytes, format_duration -from .encoder import Metadata +from .encoder import Metadata, SequenceSpec @dataclass @@ -43,11 +43,21 @@ class FileEntry: status: str = "queued" # queued | encoding | done | failed progress: float = 0.0 # 0..1 error: Optional[str] = None + # When set, this entry is an image-sequence job. `path` still carries the + # first-frame path (load-bearing for dedupe, mtime copy, etc.); the + # `display_name` and the ffmpeg input form derive from the spec. + sequence: Optional[SequenceSpec] = None @property def name(self) -> str: return os.path.basename(self.path) + @property + def display_name(self) -> str: + if self.sequence is None: + return self.name + return f"{self.sequence.stripped_stem}{self.sequence.ext} ×{self.sequence.frame_count}" + class QueuePane(Gtk.Box): __gtype_name__ = "NoCoderQueuePane" @@ -55,6 +65,7 @@ class QueuePane(Gtk.Box): __gsignals__ = { "add-files-requested": (GObject.SignalFlags.RUN_LAST, None, ()), "add-folder-requested": (GObject.SignalFlags.RUN_LAST, None, ()), + "add-sequence-folder-requested": (GObject.SignalFlags.RUN_LAST, None, ()), "clear-requested": (GObject.SignalFlags.RUN_LAST, None, ()), "files-dropped": (GObject.SignalFlags.RUN_LAST, None, (object,)), "selection-changed": (GObject.SignalFlags.RUN_LAST, None, (str,)), @@ -122,6 +133,18 @@ class QueuePane(Gtk.Box): add_folder.connect("clicked", lambda _b: self.emit("add-folder-requested")) self._action_bar.append(add_folder) + add_sequence = Gtk.Button() + add_sequence.add_css_class("muted-btn") + add_sequence.set_child(_icon_label("image-x-generic-symbolic", "Add image sequence")) + add_sequence.set_tooltip_text( + "Pick a folder of numbered image frames (JPG/PNG/TIFF/EXR/DPX). " + "Frame rate comes from the Sequence FPS setting." + ) + add_sequence.connect( + "clicked", lambda _b: self.emit("add-sequence-folder-requested") + ) + self._action_bar.append(add_sequence) + spacer = Gtk.Box() spacer.set_hexpand(True) self._action_bar.append(spacer) @@ -196,6 +219,17 @@ class QueuePane(Gtk.Box): secondary.set_child(_icon_label("folder-symbolic", "Add folder")) secondary.connect("clicked", lambda _b: self.emit("add-folder-requested")) buttons.append(secondary) + + sequence_btn = Gtk.Button() + sequence_btn.add_css_class("muted-btn") + sequence_btn.set_child(_icon_label("image-x-generic-symbolic", "Image sequence")) + sequence_btn.set_tooltip_text( + "Pick a folder of numbered image frames (JPG/PNG/TIFF/EXR/DPX)." + ) + sequence_btn.connect( + "clicked", lambda _b: self.emit("add-sequence-folder-requested") + ) + buttons.append(sequence_btn) drop.append(buttons) hint = Gtk.Label() @@ -560,7 +594,7 @@ def _icon_label(icon_name: str, text: str) -> Gtk.Widget: def _populate_row(row: Gtk.ListBoxRow, entry: FileEntry, widgets: dict) -> None: - widgets["name"].set_text(entry.name) + widgets["name"].set_text(entry.display_name) # Meta meta_box: Gtk.Box = widgets["meta_box"] _clear_children(meta_box) @@ -575,6 +609,17 @@ def _populate_row(row: Gtk.ListBoxRow, entry: FileEntry, widgets: dict) -> None: parts.append((format_duration(entry.meta.duration), None)) if entry.meta.alpha: parts.append(("α", "alpha-mark")) + if entry.sequence is not None: + # Surface the frame count + any gap warning. The frame_count vs + # expected_frames divergence is informational — ffmpeg will halt at + # the first missing frame, so the user wants to know up front. + spec = entry.sequence + if spec.frame_count == spec.expected_frames: + parts.append((f"{spec.frame_count} frames", None)) + else: + parts.append( + (f"{spec.frame_count}/{spec.expected_frames} frames", "alpha-mark") + ) if not parts: lbl = Gtk.Label(label="probing…", xalign=0) meta_box.append(lbl) diff --git a/nocoder/sequence_scan.py b/nocoder/sequence_scan.py new file mode 100644 index 0000000..122d36a --- /dev/null +++ b/nocoder/sequence_scan.py @@ -0,0 +1,116 @@ +"""Detect image sequences in a folder. + +The "Add image sequence folder…" entry point picks a folder and we look for +groups of numbered frames inside it. A sequence is a set of files sharing a +filename prefix and extension, differing only in a trailing digit run, with +consistent zero-padding (so `shot_001.png` and `shot_0001.png` in the same +folder are treated as two distinct sequences — that matches ffmpeg's `%0Nd` +semantics). + +We don't require contiguity: a group with gaps still encodes (`expected_frames +> frame_count` signals the gap so the UI can warn). ffmpeg will halt at the +first missing frame at encode time. +""" +from __future__ import annotations + +import os +import re +from pathlib import Path + +from .data import SEQUENCE_EXTENSIONS +from .encoder import SequenceSpec + +# Anchor on the *last* digit run in the stem so `scene_01_v2_0001.png` becomes +# (prefix="scene_01_v2_", digits="0001"), not (prefix="scene_", digits="01"). +_TRAILING_DIGITS_RE = re.compile(r"^(.*?)(\d+)$") + + +def scan_folder(directory: str, fps: float) -> list[SequenceSpec]: + """Return all image sequences found directly in `directory` (no recursion). + + Hidden files (leading dot) and unsupported extensions are ignored. Groups + with fewer than 2 frames are dropped (a stray `thumb.jpg` next to a video + folder shouldn't queue itself as a one-frame sequence). + """ + try: + entries = os.listdir(directory) + except OSError: + return [] + + # group_key -> {"frames": [(num_int, name)], "prefix": str, "ext": str, "padding": int} + groups: dict[tuple[str, str, int], dict] = {} + for name in entries: + if name.startswith("."): + continue + full = os.path.join(directory, name) + if not os.path.isfile(full): + continue + ext = Path(name).suffix.lower() + if ext not in SEQUENCE_EXTENSIONS: + continue + stem = Path(name).stem + m = _TRAILING_DIGITS_RE.match(stem) + if not m: + continue + prefix, digits = m.group(1), m.group(2) + padding = len(digits) + key = (prefix, ext, padding) + try: + num = int(digits) + except ValueError: + continue + g = groups.setdefault( + key, + {"frames": [], "prefix": prefix, "ext": ext, "padding": padding}, + ) + g["frames"].append((num, name)) + + specs: list[SequenceSpec] = [] + for g in groups.values(): + frames = g["frames"] + if len(frames) < 2: + continue + frames.sort(key=lambda t: t[0]) + nums = [n for n, _ in frames] + start = nums[0] + end = nums[-1] + specs.append( + SequenceSpec( + dir=os.path.abspath(directory), + prefix=g["prefix"], + ext=g["ext"], + padding=g["padding"], + start_frame=start, + frame_count=len(frames), + expected_frames=end - start + 1, + fps=fps, + ) + ) + + specs.sort(key=lambda s: (s.prefix, s.ext, s.padding)) + return specs + + +def sum_frame_sizes(spec: SequenceSpec) -> int: + """Total bytes of all frames in the sequence (best-effort).""" + total = 0 + try: + for name in os.listdir(spec.dir): + if name.startswith("."): + continue + stem = Path(name).stem + if Path(name).suffix.lower() != spec.ext: + continue + m = _TRAILING_DIGITS_RE.match(stem) + if not m: + continue + prefix, digits = m.group(1), m.group(2) + if prefix != spec.prefix or len(digits) != spec.padding: + continue + try: + total += os.path.getsize(os.path.join(spec.dir, name)) + except OSError: + continue + except OSError: + return 0 + return total diff --git a/nocoder/settings_pane.py b/nocoder/settings_pane.py index db72877..9a5dbf9 100644 --- a/nocoder/settings_pane.py +++ b/nocoder/settings_pane.py @@ -17,7 +17,14 @@ from gi.repository import GObject, Gtk, Pango from .config import load_config from .data import PROFILES, PROFILES_BY_ID -from .encoder import format_preview_command +from .encoder import SequenceSpec, format_preview_command + + +def _fmt_fps_preset(v: float) -> str: + """Compact preset label: 24 / 25 / 23.976 / 29.97.""" + if v == int(v): + return f"{int(v)} fps" + return f"{v:g} fps" def _resolve_theme_hex(widget: Gtk.Widget, name: str, fallback: str) -> str: @@ -41,7 +48,10 @@ def _resolve_theme_hex(widget: Gtk.Widget, name: str, fallback: str) -> str: class Settings: - __slots__ = ("profile", "alpha", "naming", "out_dir", "audio_bits", "auto_reveal") + __slots__ = ( + "profile", "alpha", "naming", "out_dir", "audio_bits", + "auto_reveal", "sequence_fps", + ) def __init__( self, @@ -51,6 +61,7 @@ class Settings: out_dir: str = "", audio_bits: int = 16, auto_reveal: bool = False, + sequence_fps: float = 24.0, ) -> None: self.profile = profile self.alpha = alpha @@ -63,11 +74,15 @@ class Settings: # batch completes. Convenient for one-shot transcodes; off by default # so the app doesn't surprise users mid-workflow. self.auto_reveal = auto_reveal + # Applied to every image-sequence job added via "Add image sequence + # folder…". 24 matches cinematic default; common alternates exposed + # as a preset dropdown next to the spin button. + self.sequence_fps = sequence_fps def snapshot(self) -> "Settings": return Settings( self.profile, self.alpha, self.naming, self.out_dir, - self.audio_bits, self.auto_reveal, + self.audio_bits, self.auto_reveal, self.sequence_fps, ) def to_persistable(self) -> dict: @@ -83,6 +98,7 @@ class Settings: "out_dir": self.out_dir, "audio_bits": self.audio_bits, "auto_reveal": self.auto_reveal, + "sequence_fps": self.sequence_fps, } @@ -112,6 +128,14 @@ def load_persisted_settings() -> Settings: auto_reveal = bool(data.get("auto_reveal", False)) + raw_fps = data.get("sequence_fps", 24.0) + try: + sequence_fps = float(raw_fps) + except (TypeError, ValueError): + sequence_fps = 24.0 + if not (0 < sequence_fps <= 240): + sequence_fps = 24.0 + return Settings( profile=profile, alpha=False, @@ -119,6 +143,7 @@ def load_persisted_settings() -> Settings: out_dir=out_dir, audio_bits=audio_bits, auto_reveal=auto_reveal, + sequence_fps=sequence_fps, ) @@ -140,6 +165,7 @@ class SettingsPane(Gtk.Box): self._encoder_kind = encoder_kind self._encoding_locked = False self._first_file_name: Optional[str] = None + self._first_file_sequence: Optional[SequenceSpec] = None self._profile_buttons: dict[str, Gtk.ToggleButton] = {} self._profile_rows: dict[str, Gtk.Widget] = {} self._profile_radios: dict[str, Gtk.Widget] = {} @@ -167,11 +193,20 @@ class SettingsPane(Gtk.Box): self._audio_bits_switch.set_sensitive(not encoding) if hasattr(self, "_auto_reveal_switch"): self._auto_reveal_switch.set_sensitive(not encoding) + if hasattr(self, "_seq_fps_spin"): + self._seq_fps_spin.set_sensitive(not encoding) + if hasattr(self, "_seq_fps_dropdown"): + self._seq_fps_dropdown.set_sensitive(not encoding) self._naming_dropdown.set_sensitive(not encoding) self._browse_btn.set_sensitive(not encoding) - def set_first_file_name(self, name: Optional[str]) -> None: + def set_first_file_name( + self, + name: Optional[str], + sequence: Optional[SequenceSpec] = None, + ) -> None: self._first_file_name = name + self._first_file_sequence = sequence self._update_cmd_preview() def refresh(self) -> None: @@ -196,6 +231,12 @@ class SettingsPane(Gtk.Box): if self._naming_handler_id: self._naming_dropdown.handler_unblock(self._naming_handler_id) self._folder_path.set_text(self._settings.out_dir) + if hasattr(self, "_seq_fps_spin"): + self._seq_fps_spin.handler_block(self._seq_fps_spin_handler) + try: + self._seq_fps_spin.set_value(self._settings.sequence_fps) + finally: + self._seq_fps_spin.handler_unblock(self._seq_fps_spin_handler) self._update_cmd_preview() # ---------- header ---------- @@ -237,6 +278,7 @@ class SettingsPane(Gtk.Box): body.append(self._build_profile_section()) body.append(self._build_alpha_section()) body.append(self._build_audio_bits_section()) + body.append(self._build_sequence_fps_section()) body.append(self._build_auto_reveal_section()) body.append(self._build_naming_section()) body.append(self._build_folder_section()) @@ -435,6 +477,87 @@ class SettingsPane(Gtk.Box): self.emit("settings-changed") return False + # ---------- sequence frame rate ---------- + + # Common cinema / broadcast / streaming frame rates. The spin button lets + # the user enter anything in (0, 240], but most users pick one of these. + _SEQ_FPS_PRESETS: tuple[float, ...] = ( + 23.976, 24.0, 25.0, 29.97, 30.0, 48.0, 50.0, 59.94, 60.0, + ) + + def _build_sequence_fps_section(self) -> Gtk.Widget: + section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + label = Gtk.Label(label="Sequence frame rate", xalign=0) + label.add_css_class("section-label") + section.append(label) + sub = Gtk.Label(xalign=0) + sub.add_css_class("section-sublabel") + sub.set_wrap(True) + sub.set_max_width_chars(50) + sub.set_label( + "Applied to every image-sequence job. Pick a preset or enter a " + "custom rate (1–240 fps)." + ) + section.append(sub) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + spin = Gtk.SpinButton.new_with_range(1.0, 240.0, 1.0) + spin.set_digits(3) + spin.set_climb_rate(1.0) + spin.set_value(self._settings.sequence_fps) + spin.set_hexpand(True) + self._seq_fps_spin = spin + self._seq_fps_spin_handler = spin.connect("value-changed", self._on_seq_fps_changed) + row.append(spin) + + labels = [_fmt_fps_preset(v) for v in self._SEQ_FPS_PRESETS] + model = Gtk.StringList.new(labels) + dropdown = Gtk.DropDown.new(model, None) + dropdown.add_css_class("nocoder-select") + # Default the dropdown to "no preset highlighted" by selecting the + # closest match if one exists, else leaving it at index 0. + try: + preset_idx = self._SEQ_FPS_PRESETS.index( + min(self._SEQ_FPS_PRESETS, key=lambda v: abs(v - self._settings.sequence_fps)) + ) + except ValueError: + preset_idx = 1 # 24.0 + dropdown.set_selected(preset_idx) + self._seq_fps_dropdown = dropdown + self._seq_fps_dropdown_handler = dropdown.connect( + "notify::selected", self._on_seq_fps_preset_changed, + ) + row.append(dropdown) + + section.append(row) + return section + + def _on_seq_fps_changed(self, spin: Gtk.SpinButton) -> None: + new = float(spin.get_value()) + if abs(new - self._settings.sequence_fps) < 1e-4: + return + self._settings.sequence_fps = new + self._update_cmd_preview() + self.emit("settings-changed") + + def _on_seq_fps_preset_changed(self, dropdown: Gtk.DropDown, _pspec) -> None: + idx = dropdown.get_selected() + if not (0 <= idx < len(self._SEQ_FPS_PRESETS)): + return + new = self._SEQ_FPS_PRESETS[idx] + if abs(new - self._settings.sequence_fps) < 1e-4: + return + self._settings.sequence_fps = new + # Sync the spin button without retriggering settings-changed twice. + self._seq_fps_spin.handler_block(self._seq_fps_spin_handler) + try: + self._seq_fps_spin.set_value(new) + finally: + self._seq_fps_spin.handler_unblock(self._seq_fps_spin_handler) + self._update_cmd_preview() + self.emit("settings-changed") + # ---------- auto-reveal toggle ---------- def _build_auto_reveal_section(self) -> Gtk.Widget: @@ -599,7 +722,30 @@ class SettingsPane(Gtk.Box): def _update_cmd_preview(self) -> None: if not hasattr(self, "_buffer"): return - if self._first_file_name: + if self._first_file_sequence is not None: + spec = self._first_file_sequence + # Reflect the spin button's current value in the preview, even if + # the FileEntry's spec was built with a different fps — the + # preview is forward-looking. + preview_spec = SequenceSpec( + dir=spec.dir, + prefix=spec.prefix, + ext=spec.ext, + padding=spec.padding, + start_frame=spec.start_frame, + frame_count=spec.frame_count, + expected_frames=spec.expected_frames, + fps=self._settings.sequence_fps, + ) + suffix = f"_prores_{self._settings.profile}" if self._settings.naming == "suffix" else "" + out_path = f"{self._settings.out_dir.rstrip('/')}/{preview_spec.stripped_stem}{suffix}.mov" + text = format_preview_command( + preview_spec.first_frame_path, out_path, + self._settings.profile, self._settings.alpha, + audio_bits=self._settings.audio_bits, + sequence=preview_spec, + ) + elif self._first_file_name: stem = Path(self._first_file_name).stem suffix = f"_prores_{self._settings.profile}" if self._settings.naming == "suffix" else "" out_path = f"{self._settings.out_dir.rstrip('/')}/{stem}{suffix}.mov" diff --git a/nocoder/window.py b/nocoder/window.py index 97d3203..159dc1c 100644 --- a/nocoder/window.py +++ b/nocoder/window.py @@ -33,11 +33,14 @@ from .data import ( ) from .encoder import ( EncodeJob, + SequenceSpec, detect_prores_encoder, plan_output_path, probe_metadata, + probe_sequence_metadata, run_encode, ) +from .sequence_scan import scan_folder, sum_frame_sizes from .footer import Footer from .queue_pane import FileEntry, QueuePane from .config import load_config, update_config @@ -92,6 +95,10 @@ class MainWindow(Adw.ApplicationWindow): self._queue = QueuePane() self._queue.connect("add-files-requested", lambda *_: self._open_files_dialog()) self._queue.connect("add-folder-requested", lambda *_: self._open_folder_dialog()) + self._queue.connect( + "add-sequence-folder-requested", + lambda *_: self._open_sequence_folder_dialog(), + ) self._queue.connect("clear-requested", lambda *_: self._clear_files()) self._queue.connect("files-dropped", self._on_files_dropped) self._queue.connect("selection-changed", self._on_selection_changed) @@ -251,8 +258,13 @@ class MainWindow(Adw.ApplicationWindow): if self._selected_id is not None: self._queue.set_selected(self._selected_id) self._queue.set_encoding(self._state == "encoding") - first_name = self._files[0].name if self._files else None - self._settings_pane.set_first_file_name(first_name) + first = self._files[0] if self._files else None + if first is not None: + self._settings_pane.set_first_file_name( + first.display_name, sequence=first.sequence, + ) + else: + self._settings_pane.set_first_file_name(None) self._settings_pane.set_encoding(self._state == "encoding") self._settings_pane.refresh() self._footer.update( @@ -367,6 +379,32 @@ class MainWindow(Adw.ApplicationWindow): paths.sort() self._add_paths(paths) + def _open_sequence_folder_dialog(self) -> None: + dialog = Gtk.FileDialog() + dialog.set_title("Choose image-sequence folder") + dialog.set_modal(True) + dialog.select_folder(self, None, self._on_sequence_folder_chosen) + + def _on_sequence_folder_chosen(self, dialog: Gtk.FileDialog, result) -> None: + try: + f = dialog.select_folder_finish(result) + except GLib.Error: + return + if f is None: + return + path = f.get_path() + if not path: + return + specs = scan_folder(path, self._settings.sequence_fps) + if not specs: + self._show_error( + f"No image sequences found in:\n{path}\n\n" + "Sequences are groups of ≥2 frames sharing a name prefix and " + "extension, ending in a digit run (e.g. shot_0001.png …)." + ) + return + self._add_sequences(specs) + def _open_out_dir_dialog(self) -> None: dialog = Gtk.FileDialog() dialog.set_title("Choose output folder") @@ -436,6 +474,73 @@ class MainWindow(Adw.ApplicationWindow): for entry in added: self._probe_async(entry, mbps) + def _add_sequences(self, specs: list[SequenceSpec]) -> None: + # Dedupe by (dir + pattern_basename) so re-adding the same folder + # doesn't queue the same sequence twice. Realpath dedupe is wrong + # here because two sequences in the same folder share the parent + # path — the pattern is what makes them distinct. + existing = { + (f.sequence.dir, f.sequence.pattern_basename) + for f in self._files + if f.sequence is not None + } + mbps = PROFILES_BY_ID[self._settings.profile].mbps + added: list[FileEntry] = [] + for spec in specs: + key = (spec.dir, spec.pattern_basename) + if key in existing: + continue + first = spec.first_frame_path + try: + size = sum_frame_sizes(spec) + except OSError: + size = 0 + entry = FileEntry(path=first, size=size, sequence=spec) + entry.est_out = 0.0 + self._files.append(entry) + existing.add(key) + added.append(entry) + if not added: + return + if self._state == "complete": + self._state = "ready" + self._reset_file_statuses() + self._refresh_all() + for entry in added: + self._probe_sequence_async(entry, mbps) + + def _probe_sequence_async(self, entry: FileEntry, mbps: int) -> None: + spec = entry.sequence + assert spec is not None + + def worker() -> None: + meta = probe_sequence_metadata(spec) + + def apply() -> bool: + entry.meta = meta + entry.est_out = estimate_output_bytes(meta.duration, mbps) + self._queue.update_file(entry) + self._footer.update( + state="ready" if self._state in ("empty", "ready") else self._state, + files=self._files, + profile_id=self._settings.profile, + overall=self._overall_progress(), + current_idx=self._current_idx, + ) + first = self._files[0] if self._files else None + if first is not None: + self._settings_pane.set_first_file_name( + first.display_name, sequence=first.sequence, + ) + else: + self._settings_pane.set_first_file_name(None) + return False + + GLib.idle_add(apply) + + t = threading.Thread(target=worker, daemon=True) + t.start() + def _probe_async(self, entry: FileEntry, mbps: int) -> None: def worker() -> None: meta = probe_metadata(entry.path) @@ -450,7 +555,13 @@ class MainWindow(Adw.ApplicationWindow): overall=self._overall_progress(), current_idx=self._current_idx, ) - self._settings_pane.set_first_file_name(self._files[0].name if self._files else None) + first = self._files[0] if self._files else None + if first is not None: + self._settings_pane.set_first_file_name( + first.display_name, sequence=first.sequence, + ) + else: + self._settings_pane.set_first_file_name(None) return False GLib.idle_add(apply) t = threading.Thread(target=worker, daemon=True) @@ -541,11 +652,15 @@ class MainWindow(Adw.ApplicationWindow): GLib.idle_add(self._finish_file, entry.id, False, "source file is missing") continue GLib.idle_add(self._set_current_encoding, idx, entry.id) + stem_override = ( + entry.sequence.stripped_stem if entry.sequence is not None else None + ) out_path = plan_output_path( entry.path, self._settings.out_dir, self._settings.naming, self._settings.profile, + stem_override=stem_override, ) done_event = threading.Event() result = {"ok": False, "err": None} @@ -569,6 +684,7 @@ class MainWindow(Adw.ApplicationWindow): on_done=on_done, on_speed=on_speed, audio_stream_indexes=list(entry.meta.audio_stream_indexes), + sequence=entry.sequence, cancel_event=cancel, ) # Track the active job so _on_close_request can cancel the live