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