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:
parent
e72a46cce5
commit
52fcb3a1fd
7 changed files with 634 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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 <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:
|
||||
"""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 <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]
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
116
nocoder/sequence_scan.py
Normal file
116
nocoder/sequence_scan.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue