Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,20 @@ def restart_application() -> bool:
return False

try:
# start_new_session détache le nouveau process du tty/groupe parent :
# évite que sa fermeture entraîne le process courant ou l'inverse.
popen_kwargs: dict[str, object] = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
if sys.platform != "win32":
popen_kwargs["start_new_session"] = True
if getattr(sys, "frozen", False):
subprocess.Popen([sys.executable])
subprocess.Popen([sys.executable], **popen_kwargs)
else:
launcher_path = Path(__file__).parent.parent / "launcher.py"
subprocess.Popen([sys.executable, str(launcher_path)])
subprocess.Popen([sys.executable, str(launcher_path)], **popen_kwargs)
return True
except Exception:
return False
Expand Down
15 changes: 11 additions & 4 deletions core/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,11 @@ def _task() -> None:
signals.cancelled.emit()
except Exception as exc:
signals.failed.emit(str(exc), exc)
finally:
executor.shutdown(wait=False)

executor.submit(_task)
# shutdown(wait=False) ferme l'executor au retour du _task ; le thread
# est libéré proprement sans bloquer l'appelant.
executor.shutdown(wait=False)
return signals

# ------------------------------------------------------------------
Expand Down Expand Up @@ -324,8 +325,8 @@ def _parallel() -> None:

# Cas liste vide : rien à exécuter, succès immédiat
if not tasks:
signals.finished.emit("0 tâche(s) terminée(s) en 0.0s")
executor.shutdown(wait=False)
signals.finished.emit("0 tâche(s) terminée(s) en 0.0s")
return

future_to_label: dict[Future[str], str] = {}
Expand Down Expand Up @@ -356,7 +357,8 @@ def _parallel() -> None:
except Exception as exc:
errors.append(f"[{lbl}] {exc}")

executor.shutdown(wait=False)
# Tous les futurs sont consommés : libère les threads workers.
executor.shutdown(wait=True)
elapsed = time.monotonic() - t_start

if cancelled:
Expand Down Expand Up @@ -452,6 +454,11 @@ def _run_cmd(
if signals is not None and signals._cancel_event.is_set():
raise TaskCancelledError()

# Borne la sortie retournée : ffmpeg peut produire >100K lignes
# de progress sur un encodage long. On garde les dernières.
_MAX_OUTPUT_LINES = 10000
if len(lines) > _MAX_OUTPUT_LINES:
lines = lines[-_MAX_OUTPUT_LINES:]
output = "\n".join(lines)

if proc.returncode != 0:
Expand Down
17 changes: 10 additions & 7 deletions core/subprocess_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,18 @@ def subprocess_windows_no_window_kwargs() -> dict[str, Any]:
"""
Return subprocess kwargs that prevent a console window from flashing on Windows.

Inclut aussi stdin=DEVNULL pour éviter WinError 50 quand l'application
tourne sous MSIX / en mode GUI sans console : Python tenterait sinon de
dupliquer le handle stdin du parent (virtualisé, non supporté).
Inclut systématiquement stdin=DEVNULL : sous Linux, ffmpeg/dovi_tool/etc.
héritent sinon du tty parent et peuvent altérer ses flags termios (echo
désactivé, mode raw) — ce qui casse le terminal après fermeture de l'app.
Sous Windows, évite aussi WinError 50 en mode GUI sans console.

Safe to pass to both subprocess.run() and subprocess.Popen().
"""
if sys.platform != "win32":
return {}

kwargs: dict[str, Any] = {"stdin": subprocess.DEVNULL}

if sys.platform != "win32":
return kwargs

create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if create_no_window:
kwargs["creationflags"] = create_no_window
Expand All @@ -55,11 +56,13 @@ def subprocess_text_kwargs() -> dict[str, Any]:
Sous Windows, on force l'UTF-8 ; ailleurs, on conserve le comportement
standard de Python pour limiter le périmètre du changement.
"""
# stdin=DEVNULL est inclus systématiquement (cf. subprocess_windows_no_window_kwargs)
# pour empêcher les outils externes d'altérer le tty parent.
kwargs: dict[str, Any] = {"text": True}
kwargs.update(subprocess_windows_no_window_kwargs())
if sys.platform == "win32":
kwargs["encoding"] = _TOOL_TEXT_ENCODING
kwargs["errors"] = _TOOL_TEXT_ERRORS
kwargs.update(subprocess_windows_no_window_kwargs())
return kwargs


Expand Down
2 changes: 1 addition & 1 deletion core/workflows/encode/runtime/direct_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ def _task() -> None:
live_sync_session.close()
if is_two_pass:
cb.cleanup_two_pass_logs(cwd)
executor.shutdown(wait=False)

executor.submit(_task)
executor.shutdown(wait=False)
return signals
3 changes: 1 addition & 2 deletions core/workflows/encode/runtime/metadata_inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,6 @@ def _free(path: Path) -> None:
except Exception as exc:
signals.failed.emit(str(exc), exc)
finally:
if executor is not None:
executor.shutdown(wait=False)
if live_sync_session is not None:
for proc in live_sync_session.processes:
signals._unregister_proc(proc)
Expand All @@ -328,6 +326,7 @@ def _free(path: Path) -> None:
cb.bind_nfo_write(signals, config.output)
assert executor is not None
executor.submit(_task)
executor.shutdown(wait=False)
else:
_task()
return signals
3 changes: 1 addition & 2 deletions core/workflows/encode/runtime/multi_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,13 +498,12 @@ def _on_ram_wait(order: int, required: int, available: int) -> None:
remove_path(path)
except OSError:
pass
if executor is not None:
executor.shutdown(wait=False)

if prep_signals is not None:
_run_pipeline()
return signals

assert executor is not None
executor.submit(_run_pipeline)
executor.shutdown(wait=False)
return signals
27 changes: 21 additions & 6 deletions core/workflows/encode/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import subprocess
import sys
import tempfile
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import replace
from pathlib import Path
Expand Down Expand Up @@ -170,6 +171,21 @@
_FALLBACK_HEVC_FRAME_RATE = "24000/1001"


class _LRUCache(OrderedDict):
"""Dict borné FIFO : évict le plus ancien quand maxsize est atteint."""

def __init__(self, maxsize: int = 256) -> None:
super().__init__()
self._maxsize = maxsize

def __setitem__(self, key, value) -> None: # type: ignore[override]
if key in self:
self.move_to_end(key)
super().__setitem__(key, value)
while len(self) > self._maxsize:
self.popitem(last=False)


class EncodeWorkflow(QObject):
"""
Construit et exécute un encodage ffmpeg.
Expand Down Expand Up @@ -234,9 +250,9 @@ def __init__(
# de fois pour le même fichier lors de changements UI).
# Clé : (abs_path, mtime_ns, size). Invalide automatiquement si le
# fichier a été modifié.
self._ffprobe_payload_cache: dict[tuple[str, int, int], dict[str, object] | None] = {}
self._ffprobe_frame_hdr_cache: dict[tuple[str, int, int], tuple[bool, bool] | None] = {}
self._mediainfo_hdr_cache: dict[tuple[str, int, int], tuple[bool, bool] | None] = {}
self._ffprobe_payload_cache: _LRUCache = _LRUCache(maxsize=256)
self._ffprobe_frame_hdr_cache: _LRUCache = _LRUCache(maxsize=256)
self._mediainfo_hdr_cache: _LRUCache = _LRUCache(maxsize=256)
self._generate_nfo = generate_nfo
self._runner = ToolRunner(max_workers=1, parent=self)
self._ram_buffer_enabled = ram_buffer_enabled
Expand Down Expand Up @@ -1829,10 +1845,9 @@ def _task() -> None:
signals.cancelled.emit()
except Exception as exc:
signals.failed.emit(str(exc), exc)
finally:
executor.shutdown(wait=False)

executor.submit(_task)
executor.shutdown(wait=False)
return signals

@staticmethod
Expand Down Expand Up @@ -2321,9 +2336,9 @@ def _task() -> None:
signals.failed.emit(str(exc), exc)
finally:
self._cleanup_two_pass_logs(cwd)
executor.shutdown(wait=False)

executor.submit(_task)
executor.shutdown(wait=False)
return signals

@staticmethod
Expand Down
2 changes: 1 addition & 1 deletion core/workflows/remux.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,9 +500,9 @@ def _task() -> None:
remove_path(path)
except OSError:
pass
executor.shutdown(wait=False)

executor.submit(_task)
executor.shutdown(wait=False)
return signals

def _write_nfo(self, output_path: Path) -> None:
Expand Down
63 changes: 52 additions & 11 deletions core/workflows/remux_timeline_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@
from core.workflows.remux_models import RemuxError, SourceInput


def _make_safe_close_fd(fd: int) -> Callable[[], None]:
"""Crée une callback fermant un FD POSIX, sans capturer 'self'."""
def _close() -> None:
try:
os.close(fd)
except (OSError, ValueError):
pass
return _close




class _TrackLike(Protocol):
@property
def track_type(self) -> str:
Expand Down Expand Up @@ -530,6 +542,7 @@ def _start_posix_fifo_session(
self._log("$ " + " ".join(str(c) for c in cmd))
proc = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
Expand All @@ -545,10 +558,7 @@ def _start_posix_fifo_session(
inputs=inputs,
processes=processes,
fifo_paths=fifo_paths,
_cleanup_callbacks=[
lambda fd=fd: os.close(fd)
for fd in keepalive_fds
],
_cleanup_callbacks=[_make_safe_close_fd(fd) for fd in keepalive_fds],
).close()
raise

Expand All @@ -560,10 +570,7 @@ def _start_posix_fifo_session(
inputs=inputs,
processes=processes,
fifo_paths=fifo_paths,
_cleanup_callbacks=[
lambda fd=fd: os.close(fd)
for fd in keepalive_fds
],
_cleanup_callbacks=[_make_safe_close_fd(fd) for fd in keepalive_fds],
)

def _start_windows_named_pipe_session(
Expand Down Expand Up @@ -624,6 +631,12 @@ def _close_handle(handle) -> None:
except Exception:
pass

def _make_safe_close_handle(handle):
"""Crée une callback fermant un handle, sans capturer 'self'."""
def _close() -> None:
_close_handle(handle)
return _close

inputs: list[SyncPreparedInput] = []
processes: list[subprocess.Popen] = []
threads: list[threading.Thread] = []
Expand Down Expand Up @@ -664,6 +677,7 @@ def _close_handle(handle) -> None:
self._log("$ " + " ".join(str(c) for c in cmd))
proc = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0,
Expand Down Expand Up @@ -736,7 +750,7 @@ def _pump_stdout_to_named_pipe(
processes=processes,
named_pipe_paths=pipe_paths,
_threads=threads,
_cleanup_callbacks=[lambda h=h: _close_handle(h) for h in handles],
_cleanup_callbacks=[_make_safe_close_handle(h) for h in handles],
)
session.close()
raise
Expand Down Expand Up @@ -865,11 +879,28 @@ def _extract_stream_via_mmap(self, *, source: Path, stream_index: int, destinati

proc = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)

# Drain stderr en parallèle : sinon ffmpeg bloque dès que le tampon
# OS de stderr (~64KB) est plein quand stdout est lourdement consommé.
stderr_chunks: list[bytes] = []

def _drain_stderr() -> None:
if proc.stderr is None:
return
try:
for chunk in iter(lambda: proc.stderr.read(8192), b""):
stderr_chunks.append(chunk)
except Exception:
pass

stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
stderr_thread.start()

initial_size = 8 * 1024 * 1024
max_chunk = 64 * 1024
written = 0
Expand Down Expand Up @@ -905,10 +936,20 @@ def _extract_stream_via_mmap(self, *, source: Path, stream_index: int, destinati
mm.close()
fh.truncate(written)
finally:
if proc.poll() is None:
try:
proc.wait(timeout=1200)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
stderr_thread.join(timeout=5)
for stream in (proc.stdout, proc.stderr):
if stream is not None:
try:
stream.close()
except Exception:
pass

stderr = (proc.stderr.read() if proc.stderr else b"").decode("utf-8", errors="replace").strip()
stderr = b"".join(stderr_chunks).decode("utf-8", errors="replace").strip()
if proc.returncode != 0 or written == 0 or not destination.exists():
raise RemuxError(
"Normalisation timeline mmap échouée "
Expand Down
17 changes: 16 additions & 1 deletion launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from __future__ import annotations

import atexit
import ctypes
import os
import subprocess
Expand All @@ -24,14 +25,28 @@
APP_VERSION = "0.0.0"


_DEVNULL_STREAMS: list = []


def _ensure_text_stream(name: str, mode: str) -> None:
"""Provide stdout/stderr when frozen without a console window."""
if getattr(sys, name, None) is None:
setattr(sys, name, open(os.devnull, mode, encoding="utf-8"))
fh = open(os.devnull, mode, encoding="utf-8")
_DEVNULL_STREAMS.append(fh)
setattr(sys, name, fh)


def _close_devnull_streams() -> None:
for fh in _DEVNULL_STREAMS:
try:
fh.close()
except Exception:
pass


_ensure_text_stream("stdout", "w")
_ensure_text_stream("stderr", "w")
atexit.register(_close_devnull_streams)


def _ensure_ssl_ca_bundle() -> None:
Expand Down
Loading
Loading