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",
|
"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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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,6 +347,21 @@ def build_command(
|
||||||
FFMPEG, "-hide_banner", "-loglevel", "error", "-y",
|
FFMPEG, "-hide_banner", "-loglevel", "error", "-y",
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
]
|
]
|
||||||
|
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()
|
hw = get_hwaccel()
|
||||||
if hw:
|
if hw:
|
||||||
# ffmpeg silently falls back to CPU decode for codecs the GPU can't
|
# ffmpeg silently falls back to CPU decode for codecs the GPU can't
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
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 .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 (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 ----------
|
# ---------- 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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue