Add image-sequence to ProRes encoding

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
<pattern>) 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).
This commit is contained in:
28allday 2026-05-19 08:23:13 +01:00
parent e72a46cce5
commit 52fcb3a1fd
7 changed files with 634 additions and 32 deletions

View file

@ -13,7 +13,8 @@ Schema (all keys optional):
"naming": "suffix" | "keep", "naming": "suffix" | "keep",
"audio_bits": 16 | 24, "audio_bits": 16 | 24,
"auto_reveal": false, "auto_reveal": false,
"cpu_pane_expanded": true "cpu_pane_expanded": true,
"sequence_fps": 24.0
} }
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -55,6 +55,24 @@ def is_video_path(path: str) -> bool:
return any(lower.endswith(ext) for ext in VIDEO_EXTENSIONS) 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 # Subdirectory names to SKIP when recursively walking a dropped folder or
# camera card. The names match case-insensitively. # camera card. The names match case-insensitively.
# #

View file

@ -116,6 +116,46 @@ class Metadata:
return "" 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 <dir>/<prefix>%0Pd<ext>`.
"""
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: def probe_metadata(path: str) -> Metadata:
"""Run ffprobe synchronously. Callers should invoke from a worker thread. """Run ffprobe synchronously. Callers should invoke from a worker thread.
@ -178,6 +218,55 @@ def probe_metadata(path: str) -> Metadata:
return meta 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 # Concrete pixel-format tokens that carry an alpha channel. The earlier
# heuristic (`"a" in pix_fmt.split("p", 1)[0]`) misfired on grayscale formats # heuristic (`"a" in pix_fmt.split("p", 1)[0]`) misfired on grayscale formats
# because "gray" contains the letter 'a'. # 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 "") 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( def build_command(
src: str, src: str,
out: str, out: str,
@ -226,6 +322,7 @@ def build_command(
encoder: str, encoder: str,
audio_indexes: Optional[list[int]] = None, audio_indexes: Optional[list[int]] = None,
audio_bits: int = 16, audio_bits: int = 16,
sequence: Optional[SequenceSpec] = None,
) -> list[str]: ) -> list[str]:
"""Assemble the ffmpeg command list for a single encode. """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, audio_indexes is None fallback `-map 0:a:0?` (first audio,
optional) for ad-hoc callers who optional) for ad-hoc callers who
haven't probed yet haven't probed yet
`sequence`, when set, switches the input to the image2 demuxer
(`-framerate FPS -start_number N -i <pattern>`) 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] profile = PROFILES_BY_ID[profile_id]
pix_fmt = pick_pixel_format(profile_id, alpha) pix_fmt = pick_pixel_format(profile_id, alpha)
@ -246,23 +347,38 @@ def build_command(
FFMPEG, "-hide_banner", "-loglevel", "error", "-y", FFMPEG, "-hide_banner", "-loglevel", "error", "-y",
"-nostdin", "-nostdin",
] ]
hw = get_hwaccel() if sequence is not None:
if hw: # image2 demuxer: deterministic frame ordering via %0Nd, no hwaccel
# ffmpeg silently falls back to CPU decode for codecs the GPU can't # (it's decoder-side, irrelevant for still-image input), no audio.
# handle (MJPEG, ProRes input, etc.), so unconditional -hwaccel is safe. # -vsync passthrough on EXR/DPX keeps ffmpeg from dropping/duplicating
cmd += ["-hwaccel", hw] # frames when the file mtimes look unusual.
cmd += ["-i", src, "-map", "0:v:0"] if sequence.ext in (".exr", ".dpx"):
cmd += ["-vsync", "passthrough"]
if audio_indexes is None: cmd += [
# No probe info → safe fallback (first known audio, optional). "-framerate", _format_fps(sequence.fps),
cmd += ["-map", "0:a:0?"] "-start_number", str(sequence.start_frame),
has_audio = True "-i", sequence.pattern_path,
elif audio_indexes: "-map", "0:v:0",
for idx in audio_indexes: ]
cmd += ["-map", f"0:{idx}"]
has_audio = True
else:
has_audio = False 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": if encoder == "ks":
cmd += ["-c:v", "prores_ks", "-profile:v", profile.id, "-pix_fmt", pix_fmt] cmd += ["-c:v", "prores_ks", "-profile:v", profile.id, "-pix_fmt", pix_fmt]
@ -285,16 +401,40 @@ def build_command(
return cmd 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. """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 The real command maps each known audio stream by absolute index; the
preview shows `0:a?` (glob-all) for brevity the runtime behaviour is preview shows `0:a?` (glob-all) for brevity the runtime behaviour is
equivalent when every audio stream is known-codec. 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] profile = PROFILES_BY_ID[profile_id]
pix_fmt = pick_pixel_format(profile_id, alpha) pix_fmt = pick_pixel_format(profile_id, alpha)
alpha_flag = " -alpha_bits 16" if (alpha and profile.pid >= 4) else "" 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 = get_hwaccel()
hw_line = f" -hwaccel {hw} \\\n" if hw else "" hw_line = f" -hwaccel {hw} \\\n" if hw else ""
audio_codec = "pcm_s24le" if audio_bits == 24 else "pcm_s16le" 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: def plan_output_path(
"""Output path rules from prowrap-yad.sh: keep vs suffix, with ' (N)' disambiguation.""" src: str,
stem = Path(src).stem 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": if naming == "suffix":
base = f"{stem}_prores_{profile_id}" base = f"{stem}_prores_{profile_id}"
else: else:
@ -344,6 +494,12 @@ class EncodeJob:
# build_command can map each known audio stream by absolute index. # build_command can map each known audio stream by absolute index.
# Empty list = silent video; None = no probe info (safe fallback applies). # Empty list = silent video; None = no probe info (safe fallback applies).
audio_stream_indexes: Optional[list[int]] = None 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) cancel_event: threading.Event = field(default_factory=threading.Event)
_proc: Optional[subprocess.Popen] = None _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") job.on_done(False, "cancelled")
return 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: try:
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,

View file

@ -30,7 +30,7 @@ gi.require_version("GObject", "2.0")
from gi.repository import GdkPixbuf, GLib, GObject, Gdk, Gio, Gtk from gi.repository import GdkPixbuf, GLib, GObject, Gdk, Gio, Gtk
from .data import VIDEO_EXTENSIONS, format_bytes, format_duration from .data import VIDEO_EXTENSIONS, format_bytes, format_duration
from .encoder import Metadata from .encoder import Metadata, SequenceSpec
@dataclass @dataclass
@ -43,11 +43,21 @@ class FileEntry:
status: str = "queued" # queued | encoding | done | failed status: str = "queued" # queued | encoding | done | failed
progress: float = 0.0 # 0..1 progress: float = 0.0 # 0..1
error: Optional[str] = None 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 @property
def name(self) -> str: def name(self) -> str:
return os.path.basename(self.path) 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): class QueuePane(Gtk.Box):
__gtype_name__ = "NoCoderQueuePane" __gtype_name__ = "NoCoderQueuePane"
@ -55,6 +65,7 @@ class QueuePane(Gtk.Box):
__gsignals__ = { __gsignals__ = {
"add-files-requested": (GObject.SignalFlags.RUN_LAST, None, ()), "add-files-requested": (GObject.SignalFlags.RUN_LAST, None, ()),
"add-folder-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, ()), "clear-requested": (GObject.SignalFlags.RUN_LAST, None, ()),
"files-dropped": (GObject.SignalFlags.RUN_LAST, None, (object,)), "files-dropped": (GObject.SignalFlags.RUN_LAST, None, (object,)),
"selection-changed": (GObject.SignalFlags.RUN_LAST, None, (str,)), "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")) add_folder.connect("clicked", lambda _b: self.emit("add-folder-requested"))
self._action_bar.append(add_folder) 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 = Gtk.Box()
spacer.set_hexpand(True) spacer.set_hexpand(True)
self._action_bar.append(spacer) self._action_bar.append(spacer)
@ -196,6 +219,17 @@ class QueuePane(Gtk.Box):
secondary.set_child(_icon_label("folder-symbolic", "Add folder")) secondary.set_child(_icon_label("folder-symbolic", "Add folder"))
secondary.connect("clicked", lambda _b: self.emit("add-folder-requested")) secondary.connect("clicked", lambda _b: self.emit("add-folder-requested"))
buttons.append(secondary) 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) drop.append(buttons)
hint = Gtk.Label() 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: 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
meta_box: Gtk.Box = widgets["meta_box"] meta_box: Gtk.Box = widgets["meta_box"]
_clear_children(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)) parts.append((format_duration(entry.meta.duration), None))
if entry.meta.alpha: if entry.meta.alpha:
parts.append(("α", "alpha-mark")) 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: if not parts:
lbl = Gtk.Label(label="probing…", xalign=0) lbl = Gtk.Label(label="probing…", xalign=0)
meta_box.append(lbl) meta_box.append(lbl)

116
nocoder/sequence_scan.py Normal file
View file

@ -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

View file

@ -17,7 +17,14 @@ from gi.repository import GObject, Gtk, Pango
from .config import load_config from .config import load_config
from .data import PROFILES, PROFILES_BY_ID 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: 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: 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__( def __init__(
self, self,
@ -51,6 +61,7 @@ class Settings:
out_dir: str = "", out_dir: str = "",
audio_bits: int = 16, audio_bits: int = 16,
auto_reveal: bool = False, auto_reveal: bool = False,
sequence_fps: float = 24.0,
) -> None: ) -> None:
self.profile = profile self.profile = profile
self.alpha = alpha self.alpha = alpha
@ -63,11 +74,15 @@ class Settings:
# batch completes. Convenient for one-shot transcodes; off by default # batch completes. Convenient for one-shot transcodes; off by default
# so the app doesn't surprise users mid-workflow. # so the app doesn't surprise users mid-workflow.
self.auto_reveal = auto_reveal 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": def snapshot(self) -> "Settings":
return Settings( return Settings(
self.profile, self.alpha, self.naming, self.out_dir, 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: def to_persistable(self) -> dict:
@ -83,6 +98,7 @@ class Settings:
"out_dir": self.out_dir, "out_dir": self.out_dir,
"audio_bits": self.audio_bits, "audio_bits": self.audio_bits,
"auto_reveal": self.auto_reveal, "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)) 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( return Settings(
profile=profile, profile=profile,
alpha=False, alpha=False,
@ -119,6 +143,7 @@ def load_persisted_settings() -> Settings:
out_dir=out_dir, out_dir=out_dir,
audio_bits=audio_bits, audio_bits=audio_bits,
auto_reveal=auto_reveal, auto_reveal=auto_reveal,
sequence_fps=sequence_fps,
) )
@ -140,6 +165,7 @@ class SettingsPane(Gtk.Box):
self._encoder_kind = encoder_kind self._encoder_kind = encoder_kind
self._encoding_locked = False self._encoding_locked = False
self._first_file_name: Optional[str] = None self._first_file_name: Optional[str] = None
self._first_file_sequence: Optional[SequenceSpec] = None
self._profile_buttons: dict[str, Gtk.ToggleButton] = {} self._profile_buttons: dict[str, Gtk.ToggleButton] = {}
self._profile_rows: dict[str, Gtk.Widget] = {} self._profile_rows: dict[str, Gtk.Widget] = {}
self._profile_radios: 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) self._audio_bits_switch.set_sensitive(not encoding)
if hasattr(self, "_auto_reveal_switch"): if hasattr(self, "_auto_reveal_switch"):
self._auto_reveal_switch.set_sensitive(not encoding) 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._naming_dropdown.set_sensitive(not encoding)
self._browse_btn.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_name = name
self._first_file_sequence = sequence
self._update_cmd_preview() self._update_cmd_preview()
def refresh(self) -> None: def refresh(self) -> None:
@ -196,6 +231,12 @@ class SettingsPane(Gtk.Box):
if self._naming_handler_id: if self._naming_handler_id:
self._naming_dropdown.handler_unblock(self._naming_handler_id) self._naming_dropdown.handler_unblock(self._naming_handler_id)
self._folder_path.set_text(self._settings.out_dir) 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() self._update_cmd_preview()
# ---------- header ---------- # ---------- header ----------
@ -237,6 +278,7 @@ class SettingsPane(Gtk.Box):
body.append(self._build_profile_section()) body.append(self._build_profile_section())
body.append(self._build_alpha_section()) body.append(self._build_alpha_section())
body.append(self._build_audio_bits_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_auto_reveal_section())
body.append(self._build_naming_section()) body.append(self._build_naming_section())
body.append(self._build_folder_section()) body.append(self._build_folder_section())
@ -435,6 +477,87 @@ class SettingsPane(Gtk.Box):
self.emit("settings-changed") self.emit("settings-changed")
return False 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 (1240 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 ---------- # ---------- auto-reveal toggle ----------
def _build_auto_reveal_section(self) -> Gtk.Widget: def _build_auto_reveal_section(self) -> Gtk.Widget:
@ -599,7 +722,30 @@ class SettingsPane(Gtk.Box):
def _update_cmd_preview(self) -> None: def _update_cmd_preview(self) -> None:
if not hasattr(self, "_buffer"): if not hasattr(self, "_buffer"):
return 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 stem = Path(self._first_file_name).stem
suffix = f"_prores_{self._settings.profile}" if self._settings.naming == "suffix" else "" suffix = f"_prores_{self._settings.profile}" if self._settings.naming == "suffix" else ""
out_path = f"{self._settings.out_dir.rstrip('/')}/{stem}{suffix}.mov" out_path = f"{self._settings.out_dir.rstrip('/')}/{stem}{suffix}.mov"

View file

@ -33,11 +33,14 @@ from .data import (
) )
from .encoder import ( from .encoder import (
EncodeJob, EncodeJob,
SequenceSpec,
detect_prores_encoder, detect_prores_encoder,
plan_output_path, plan_output_path,
probe_metadata, probe_metadata,
probe_sequence_metadata,
run_encode, run_encode,
) )
from .sequence_scan import scan_folder, sum_frame_sizes
from .footer import Footer from .footer import Footer
from .queue_pane import FileEntry, QueuePane from .queue_pane import FileEntry, QueuePane
from .config import load_config, update_config from .config import load_config, update_config
@ -92,6 +95,10 @@ class MainWindow(Adw.ApplicationWindow):
self._queue = QueuePane() self._queue = QueuePane()
self._queue.connect("add-files-requested", lambda *_: self._open_files_dialog()) 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-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("clear-requested", lambda *_: self._clear_files())
self._queue.connect("files-dropped", self._on_files_dropped) self._queue.connect("files-dropped", self._on_files_dropped)
self._queue.connect("selection-changed", self._on_selection_changed) self._queue.connect("selection-changed", self._on_selection_changed)
@ -251,8 +258,13 @@ class MainWindow(Adw.ApplicationWindow):
if self._selected_id is not None: if self._selected_id is not None:
self._queue.set_selected(self._selected_id) self._queue.set_selected(self._selected_id)
self._queue.set_encoding(self._state == "encoding") self._queue.set_encoding(self._state == "encoding")
first_name = self._files[0].name if self._files else None first = self._files[0] if self._files else None
self._settings_pane.set_first_file_name(first_name) 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.set_encoding(self._state == "encoding")
self._settings_pane.refresh() self._settings_pane.refresh()
self._footer.update( self._footer.update(
@ -367,6 +379,32 @@ class MainWindow(Adw.ApplicationWindow):
paths.sort() paths.sort()
self._add_paths(paths) 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: def _open_out_dir_dialog(self) -> None:
dialog = Gtk.FileDialog() dialog = Gtk.FileDialog()
dialog.set_title("Choose output folder") dialog.set_title("Choose output folder")
@ -436,6 +474,73 @@ class MainWindow(Adw.ApplicationWindow):
for entry in added: for entry in added:
self._probe_async(entry, mbps) 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 _probe_async(self, entry: FileEntry, mbps: int) -> None:
def worker() -> None: def worker() -> None:
meta = probe_metadata(entry.path) meta = probe_metadata(entry.path)
@ -450,7 +555,13 @@ class MainWindow(Adw.ApplicationWindow):
overall=self._overall_progress(), overall=self._overall_progress(),
current_idx=self._current_idx, 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 return False
GLib.idle_add(apply) GLib.idle_add(apply)
t = threading.Thread(target=worker, daemon=True) 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") GLib.idle_add(self._finish_file, entry.id, False, "source file is missing")
continue continue
GLib.idle_add(self._set_current_encoding, idx, entry.id) 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( out_path = plan_output_path(
entry.path, entry.path,
self._settings.out_dir, self._settings.out_dir,
self._settings.naming, self._settings.naming,
self._settings.profile, self._settings.profile,
stem_override=stem_override,
) )
done_event = threading.Event() done_event = threading.Event()
result = {"ok": False, "err": None} result = {"ok": False, "err": None}
@ -569,6 +684,7 @@ class MainWindow(Adw.ApplicationWindow):
on_done=on_done, on_done=on_done,
on_speed=on_speed, on_speed=on_speed,
audio_stream_indexes=list(entry.meta.audio_stream_indexes), audio_stream_indexes=list(entry.meta.audio_stream_indexes),
sequence=entry.sequence,
cancel_event=cancel, cancel_event=cancel,
) )
# Track the active job so _on_close_request can cancel the live # Track the active job so _on_close_request can cancel the live