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
17 changes: 16 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from screen_recorder.shared import (
APP_BUILD,
SingleInstanceGuard,
get_source_snapshot_root,
resolve_ffmpeg_path,
resolve_ffprobe_path,
)
Expand Down Expand Up @@ -57,7 +58,21 @@ def report(message, *, error=False):
)
return 23

report(f"PACKAGING_SMOKE_OK build={APP_BUILD}")
source_root = get_source_snapshot_root()
try:
embedded_sources = list(Path(source_root).rglob("*.py"))
except Exception:
embedded_sources = []
if not embedded_sources:
report(
f"PACKAGING_SMOKE_FAIL: diagnostic source snapshot is empty: {source_root}",
error=True,
)
return 24

report(
f"PACKAGING_SMOKE_OK build={APP_BUILD} embedded_sources={len(embedded_sources)}"
)
return 0


Expand Down
17 changes: 16 additions & 1 deletion screen_recorder/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def __init__(self, root):
})
self.ffmpeg_path = resolve_ffmpeg_path()
self._encoder_support_cache = {}
self._encoder_option_support_cache = {}
self._filter_support_cache = {}
self._input_format_support_cache = {}
self._ddagrab_warm_done = False
Expand Down Expand Up @@ -330,7 +331,21 @@ def __init__(self, root):
# плавающая панель специально видна на экране и попадает в итоговое видео.
self.annotation_toolbar_clean_frame = None

self.output_folder = tk.StringVar(value=self.settings.get("output_folder", os.getcwd()))
saved_output_folder = self.settings.get("output_folder", default_recording_output_folder())
try:
saved_output_text = str(saved_output_folder or "").strip()
if (
is_probably_temporary_path(saved_output_text)
and (
"scoped_dir" in saved_output_text.lower()
or Path(saved_output_text).resolve() == APP_DIR.resolve()
or Path(saved_output_text).resolve() == Path.cwd().resolve()
)
):
saved_output_folder = default_recording_output_folder()
except Exception:
pass
self.output_folder = tk.StringVar(value=saved_output_folder)
self.format_var = tk.StringVar(value=self.settings.get("format", "mkv"))
self.fps_var = tk.StringVar(value=self.settings.get("fps", "60"))
self.auto_adjust_fps_var = tk.BooleanVar(value=bool(self.settings.get("auto_adjust_fps", False)))
Expand Down
19 changes: 12 additions & 7 deletions screen_recorder/mixins/capture_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,11 +311,14 @@ def append_encoder_options(self, cmd, fps_int, video_bitrate, bufsize, use_nvenc

if use_nvenc:
# Для захвата экрана важнее равномерно и быстро принимать кадры, чем
# держать большую очередь lookahead. Нулевой lookahead и отключённый
# temporal AQ уменьшают задержку/нагрузку GPU; битрейт уже имеет запас.
# держать большую очередь lookahead. Нулевой lookahead уменьшает
# задержку. AQ-параметры добавляем только если конкретная сборка
# FFmpeg реально показывает их в ffmpeg -h encoder=..., чтобы смена
# FFmpeg не ломала старт записи из-за необязательной AVOption.
encoder_name = "hevc_nvenc" if use_hevc else "h264_nvenc"
cmd += [
"-c:v",
"hevc_nvenc" if use_hevc else "h264_nvenc",
encoder_name,
"-preset",
"fast",
"-rc",
Expand All @@ -328,10 +331,12 @@ def append_encoder_options(self, cmd, fps_int, video_bitrate, bufsize, use_nvenc
bufsize2,
"-rc-lookahead",
"0",
"-spatial-aq",
"1",
"-temporal-aq",
"0",
]
if self.ffmpeg_supports_encoder_option(encoder_name, "spatial-aq"):
cmd += ["-spatial-aq", "1"]
if self.ffmpeg_supports_encoder_option(encoder_name, "temporal-aq"):
cmd += ["-temporal-aq", "0"]
cmd += [
"-profile:v",
"main" if use_hevc else "high",
"-bf",
Expand Down
45 changes: 45 additions & 0 deletions screen_recorder/mixins/ffmpeg_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,51 @@ def ffmpeg_supports_encoder(self, encoder_name):
self._encoder_support_cache[key] = ok
return ok

def ffmpeg_supports_encoder_option(self, encoder_name, option_name):
"""Проверяет конкретную CLI-опцию энкодера через ffmpeg -h encoder=NAME.

NVENC может присутствовать в -encoders, но конкретная сборка FFmpeg
способна отличаться набором/именами AVOptions. Не блокируем GUI: фоновый
preflight заполняет кэш, а при неизвестном результате необязательная
опция просто не добавляется в команду записи.
"""
encoder_key = str(encoder_name or "").strip().lower()
option_key = str(option_name or "").strip().lower().lstrip("-")
cache = getattr(self, "_encoder_option_support_cache", None)
if not isinstance(cache, dict):
cache = {}
self._encoder_option_support_cache = cache
key = (encoder_key, option_key)
if key in cache:
return bool(cache[key])
if not encoder_key or not option_key:
cache[key] = False
return False
if self.is_gui_thread():
try:
if self._preflight_thread is None or not self._preflight_thread.is_alive():
self._preflight_thread = threading.Thread(target=self.preflight_worker, daemon=True)
self._preflight_thread.start()
except Exception:
pass
return False
try:
result = self.run_managed_process(
[self.ffmpeg_path, "-hide_banner", "-h", f"encoder={encoder_key}"],
capture_output=True,
text=True,
encoding="utf-8",
errors="ignore",
timeout=8,
creationflags=self.creation_flags(),
)
help_text = ((result.stdout or "") + "\n" + (result.stderr or "")).lower()
ok = bool(re.search(r"(?m)^\s*-" + re.escape(option_key) + r"(?=\s|$)", help_text))
except Exception:
ok = False
cache[key] = ok
return ok

def ffmpeg_supports_filter(self, filter_name):
"""Проверяем фильтр FFmpeg. Для плавного захвата нужен ddagrab."""
key = str(filter_name).lower()
Expand Down
9 changes: 9 additions & 0 deletions screen_recorder/mixins/instant_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ def preflight_worker(self):
return
try:
self.ffmpeg_supports_encoder("h264_nvenc")
self.ffmpeg_supports_encoder("hevc_nvenc")
for encoder_name in ("h264_nvenc", "hevc_nvenc"):
if self._encoder_support_cache.get(encoder_name):
self.ffmpeg_supports_encoder_option(encoder_name, "spatial-aq")
self.ffmpeg_supports_encoder_option(encoder_name, "temporal-aq")
except Exception:
pass
ddagrab_ok = False
Expand All @@ -220,6 +225,10 @@ def preflight_worker(self):
self.diagnostic_log("preflight_worker_finish", {
"ffmpeg_ok": self._ffmpeg_ok_cache,
"encoder_support_cache": self._encoder_support_cache,
"encoder_option_support_cache": {
f"{encoder}:{option}": supported
for (encoder, option), supported in self._encoder_option_support_cache.items()
},
"filter_support_cache": self._filter_support_cache,
"input_format_support_cache": self._input_format_support_cache,
})
Expand Down
32 changes: 32 additions & 0 deletions screen_recorder/mixins/processes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@ class ProcessMixin:
"acquirenextframe failed: 887a0026",
"dxgi_error_access_lost",
)
# Сообщения инициализации Desktop Duplication из FFmpeg ddagrab.
# Только эти ошибки оправдывают переключение на gdigrab. Синтаксис CLI,
# неизвестные encoder options и NVENC failures не должны маскироваться GDI.
DDAGRAB_STARTUP_FALLBACK_MARKERS = (
"failed querying idxgidevice",
"failed getting parent idxgiadapter",
"failed to enumerate dxgi output",
"failed getting output description",
"failed querying idxgioutput1",
"too many open duplication sessions",
"selected output not supported",
"invalid output duplication argument",
"desktop duplication access denied",
"failed duplicating output",
"only 8 bit output supported with legacy api",
"createvertexshader failed",
"createinputlayout failed",
"createpixelshader failed",
"createbuffer const buffer failed",
"createsamplerstate failed",
"createblendstate failed",
)

@staticmethod
def classify_recording_video_progress_health(
Expand Down Expand Up @@ -53,6 +75,12 @@ def classify_ffmpeg_capture_stderr(cls, text, capture_backend):
return "dxgi_access_lost"
return None

@classmethod
def should_fallback_ddagrab_startup(cls, text):
"""True только для ошибок инициализации Desktop Duplication."""
normalized = str(text or "").lower()
return any(marker in normalized for marker in cls.DDAGRAB_STARTUP_FALLBACK_MARKERS)

@staticmethod
def classify_input_desktop_name(name, open_succeeded=True):
if not open_succeeded:
Expand Down Expand Up @@ -207,6 +235,10 @@ def worker():
stderr_log.write(raw_bytes)

combined = scan_tail + text
# Нужен для точной классификации мгновенного startup
# failure: fallback захвата не должен скрывать ошибки
# FFmpeg CLI/NVENC.
self.current_ffmpeg_stderr_tail = combined[-4000:]
classification = self.classify_ffmpeg_capture_stderr(
combined,
capture_backend,
Expand Down
68 changes: 61 additions & 7 deletions screen_recorder/mixins/recording_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,25 @@ def start_recording(self):
except Exception as exc:
self.stop_cursor_highlight_overlay()
self.log_exception("start_recording", exc)
self.recording_failure_reason = str(exc)
try:
self.write_ai_smoothness_report(
outcome="recording_start_failed",
error_text=str(exc),
)
except Exception as report_exc:
self.log_exception("start_recording.write_ai_smoothness_report", report_exc)
try:
self.write_ai_problem_summary(
outcome="Ошибка запуска записи",
error_text=str(exc),
)
except Exception as summary_exc:
self.log_exception("start_recording.write_ai_problem_summary", summary_exc)
try:
self.close_recording_problem_log_session(reason="start_failed")
except Exception:
pass
try:
self.stop_recording_performance_sampler()
except Exception:
Expand Down Expand Up @@ -368,10 +387,15 @@ def start_new_segment(self):
try:
self.launch_checked_ffmpeg_segment(segment_path, fallback_backend)
except Exception as fb_exc:
# Если ddagrab был выбран из кэша, но всё равно упал,
# последняя безопасная попытка — gdigrab. Без повторных
# ffmpeg -filters проверок в GUI-потоке.
if fallback_backend != "gdigrab":
# Переходим на GDI только если stderr доказывает сбой
# именно Desktop Duplication. Ошибки CLI/NVENC должны
# остаться исходной ошибкой, а не повторяться во второй
# почти такой же FFmpeg-команде.
stderr_tail = getattr(self, "current_ffmpeg_stderr_tail", "")
if (
fallback_backend == "ddagrab"
and self.should_fallback_ddagrab_startup(stderr_tail)
):
self.log_exception("fallback_capture_backend", fb_exc)
try:
if segment_path.exists():
Expand All @@ -390,8 +414,15 @@ def start_new_segment(self):
try:
self.launch_checked_ffmpeg_segment(segment_path, capture_backend)
except RuntimeError as exc:
if capture_backend == "ddagrab":
self.log_handle.write(f"\n--- FALLBACK ---\nddagrab failed at start: {exc}\nTrying gdigrab...\n")
stderr_tail = getattr(self, "current_ffmpeg_stderr_tail", "")
if (
capture_backend == "ddagrab"
and self.should_fallback_ddagrab_startup(stderr_tail)
):
self.log_handle.write(
f"\n--- FALLBACK ---\nddagrab initialization failed: {exc}\n"
"Trying gdigrab because stderr identifies a Desktop Duplication failure.\n"
)
self.log_handle.flush()
try:
if segment_path.exists():
Expand All @@ -400,6 +431,15 @@ def start_new_segment(self):
pass
self.launch_checked_ffmpeg_segment(segment_path, "gdigrab")
else:
self.diagnostic_log(
"ffmpeg_start_failure_no_capture_fallback",
{
"capture_backend": capture_backend,
"error": str(exc),
"stderr_tail": str(stderr_tail)[-2000:],
},
level="ERROR",
)
raise
except Exception:
if python_loopback_started:
Expand Down Expand Up @@ -467,6 +507,7 @@ def launch_checked_ffmpeg_segment(self, segment_path, capture_backend):
self.current_segment_last_video_frame_advance_perf = None
self.current_segment_last_video_frame_out_time_seconds = None
self.current_segment_video_stall_detected = False
self.current_ffmpeg_stderr_tail = ""
self.start_ffmpeg_stderr_reader(
process,
log_path=self.get_current_recording_log_path(),
Expand Down Expand Up @@ -502,7 +543,20 @@ def launch_checked_ffmpeg_segment(self, segment_path, capture_backend):
pass
self.wait_for_ffmpeg_stderr_reader(process, timeout=1.0)
self.unregister_child_process(process)
raise RuntimeError(f"FFmpeg сразу завершился с кодом {code}. Лог: {self.current_log_path}")
stderr_excerpt = str(getattr(self, "current_ffmpeg_stderr_tail", "") or "").strip()
self.diagnostic_log(
"ffmpeg_recording_segment_start_failed",
{
"capture_backend": capture_backend,
"returncode": code,
"stderr_tail": stderr_excerpt[-2000:],
},
level="ERROR",
)
message = f"FFmpeg сразу завершился с кодом {code}. Лог: {self.current_log_path}"
if stderr_excerpt:
message += "\nFFmpeg stderr:\n" + stderr_excerpt[-1500:]
raise RuntimeError(message)

self.current_segment_engine = "ffmpeg"
with self.process_lock:
Expand Down
Loading