diff --git a/README.md b/README.md index 6cd4275b..99816bd7 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ Run `lsusb` to find your USB ID (`xxxx:xxxx` after `ID`), then match it below. **Bulk USB devices** — raw USB protocol: | USB ID | Devices | |--------|---------| -| `87AD:70DB` | GrandVision 360 AIO, Mjolnir Vision 360, Wonder Vision Pro 360, Frozen Warframe Pro | +| `87AD:70DB` | GrandVision 360 AIO, Mjolnir Vision 360, Wonder Vision Pro 360, Frozen Warframe Pro, Phantom Spirit 120 Vision EVO | **LY USB devices** — chunked bulk protocol: | USB ID | Devices | diff --git a/doc/REFERENCE_DEVICES.md b/doc/REFERENCE_DEVICES.md index 278ee49e..d40af0b1 100644 --- a/doc/REFERENCE_DEVICES.md +++ b/doc/REFERENCE_DEVICES.md @@ -66,7 +66,8 @@ These have a full pixel LCD (240x240 to 1920x462) for custom themes, images, vid | Peerless Assassin 120 Vision | — | | Royal Lord 120 Vision | — | | Royal Knight 130 Vision | — | -| Phantom Spirit 120 Vision | — | +| Phantom Spirit 120 Vision / EVO | 幻灵 120 Vision | +| Phantom Spirit 120 Vision EVO | Bulk (87AD:70DB, PM 4 SUB 4 A1LF10V) | [alan7383](https://github.com/alan7383) | ### LED + Segment Display Products (Digital Series) diff --git a/doc/TESTERS_WANTED.md b/doc/TESTERS_WANTED.md index 62cc12f5..b0e9f02a 100644 --- a/doc/TESTERS_WANTED.md +++ b/doc/TESTERS_WANTED.md @@ -31,6 +31,7 @@ These devices have been tested on real Linux hardware by contributors: | GrandVision 360 AIO | `87AD:70DB` | Bulk | [bipobuilt](https://github.com/bipobuilt) | | Mjolnir Vision 360 | `87AD:70DB` | Bulk | [Pikarz](https://github.com/Pikarz) | | Trofeo Vision LCD | `0416:5302` | HID Type 2 | [PantherX12max](https://github.com/PantherX12max), [N8ghtz](https://github.com/N8ghtz) | +| Phantom Spirit 120 Vision EVO | `87AD:70DB` | Bulk | [alan7383](https://github.com/alan7383) | | Assassin Spirit 120 Vision ARGB | `0416:5302` | HID Type 2 | [michael-spinelli](https://github.com/michael-spinelli) | | Frozen Warframe SE | `0402:3922` | SCSI | [apj202-ops](https://github.com/apj202-ops) | | FROZEN WARFRAME (SCSI) | `0402:3922` | SCSI | [gizbo](https://github.com/gizbo) | @@ -138,7 +139,6 @@ The following models are listed on [Thermalright's official download page](https | MC-3 DIGITAL | HID LCD or LED | Need tester | | Peerless Assassin 140 Digital | HID LCD or LED | Need tester | | Peerless Vision | HID LCD or LED | Need tester | -| Phantom Spirit 120 Digital/Vision | HID LCD or LED | Need tester | | Rainbow Vision | HID LCD or LED | Need tester | | Royal Knight 130 Digital/Vision | HID LCD or LED | Need tester | | Royal Lord 120 Vision | HID LCD or LED | Need tester | diff --git a/src/trcc/adapters/diagnostics/health.py b/src/trcc/adapters/diagnostics/health.py index 7753336b..55cc66e6 100644 --- a/src/trcc/adapters/diagnostics/health.py +++ b/src/trcc/adapters/diagnostics/health.py @@ -309,9 +309,13 @@ def check_udev_rules_linux() -> HealthCheckResult: message="Not applicable on this OS", ) candidate_paths = [ + Path("/etc/udev/rules.d/99-trcc-lcd.rules"), Path("/etc/udev/rules.d/99-trcc.rules"), Path("/etc/udev/rules.d/90-trcc.rules"), + Path("/lib/udev/rules.d/99-trcc-lcd.rules"), Path("/lib/udev/rules.d/99-trcc.rules"), + Path("/usr/lib/udev/rules.d/99-trcc-lcd.rules"), + Path("/usr/lib/udev/rules.d/99-trcc.rules"), ] found = [p for p in candidate_paths if p.is_file()] if not found: @@ -319,7 +323,7 @@ def check_udev_rules_linux() -> HealthCheckResult: name="udev-rules", severity="WARN", message="No TRCC udev rules found under /etc/udev/rules.d/", fix_hint="Run `trcc system setup` (or install via the distro " - "package) to lay down /etc/udev/rules.d/99-trcc.rules", + "package) to lay down /etc/udev/rules.d/99-trcc-lcd.rules", ) return HealthCheckResult( name="udev-rules", severity="OK", diff --git a/src/trcc/adapters/render/qt.py b/src/trcc/adapters/render/qt.py index 18cf2910..6f1fd525 100644 --- a/src/trcc/adapters/render/qt.py +++ b/src/trcc/adapters/render/qt.py @@ -371,7 +371,9 @@ def get_pixels_rgb( # ── Legacy boundary (raw RGB24 video frame → QImage) ────────────── - def from_raw_rgb24(self, frame: RawFrame) -> Any: + def from_raw_rgb24(self, frame: Any) -> Any: + if isinstance(frame, QImage): + return frame.convertToFormat(QImage.Format.Format_ARGB32) log.debug("from_raw_rgb24: %dx%d", frame.width, frame.height) qimg = QImage( frame.data, frame.width, frame.height, diff --git a/src/trcc/adapters/system/linux.py b/src/trcc/adapters/system/linux.py index e0906b0a..156adb13 100644 --- a/src/trcc/adapters/system/linux.py +++ b/src/trcc/adapters/system/linux.py @@ -561,7 +561,7 @@ def no_devices_hint(self) -> str: log.debug("LinuxPlatform.no_devices_hint: called") return ( "Run `trcc system setup` to install the udev rules " - "(/etc/udev/rules.d/99-trcc.rules), then replug the device." + "(/etc/udev/rules.d/99-trcc-lcd.rules), then replug the device." ) # ── Hardware probes (LED memory + disk widgets) ─────────────────── diff --git a/src/trcc/app.py b/src/trcc/app.py index 8b9800d6..4978f09e 100644 --- a/src/trcc/app.py +++ b/src/trcc/app.py @@ -891,6 +891,13 @@ def _on_visual_change(self, event: Any) -> None: type(event).__name__, key, ) continue + ds = self._app.settings.for_device(key) + if ds.screencast_region is not None: + log.debug( + "DeviceRenderObserver: skip %s for %s (screencast active)", + type(event).__name__, key, + ) + continue log.debug( "DeviceRenderObserver: %s for %s → RenderAndSend", type(event).__name__, key, diff --git a/src/trcc/core/commands/_helpers.py b/src/trcc/core/commands/_helpers.py index 41af5e79..f547f57b 100644 --- a/src/trcc/core/commands/_helpers.py +++ b/src/trcc/core/commands/_helpers.py @@ -70,10 +70,10 @@ def oriented_theme_path( return cand if cand.exists() else stored -_VIDEO_EXTS_FOR_SAVE = frozenset({".mp4", ".mov", ".webm", ".zt", ".mkv", ".avi"}) +_VIDEO_EXTS_FOR_SAVE = frozenset({".mp4", ".mov", ".webm", ".zt", ".mkv", ".avi", ".gif"}) -_VIDEO_EXTS_OK = frozenset({".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt"}) +_VIDEO_EXTS_OK = frozenset({".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt", ".gif"}) _BG_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".bmp", ".webp"}) @@ -92,7 +92,7 @@ def oriented_theme_path( _VIDEO_EXTS_FOR_LOAD = frozenset({ - ".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt", + ".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt", ".gif", }) diff --git a/src/trcc/core/commands/device.py b/src/trcc/core/commands/device.py index f1b68f94..efa4e145 100644 --- a/src/trcc/core/commands/device.py +++ b/src/trcc/core/commands/device.py @@ -572,6 +572,13 @@ def execute(self, app: App) -> RenderResult: except DeviceNotFoundError as e: return RenderResult(ok=False, key=self.key, message=str(e)) + ds_dev = app.settings.for_device(self.key) + if ds_dev.screencast_region is not None: + return RenderResult( + ok=True, key=self.key, + message="screencast active — theme render skipped", + ) + theme = app.active_themes.get(self.key) if theme is None: return RenderResult( @@ -1049,20 +1056,16 @@ def execute(self, app: App) -> VideoResult: @dataclass(frozen=True, slots=True) class StopVideo(Command[VideoResult]): - """Clear the device's playback override AND the persisted bg override. + """Clear the device's playback override AND optionally the persisted bg override. Idempotent — calling on a device with no playback is a no-op + ok=True so scripts can use it as a defensive cleanup. - Clears ``DeviceSettings.background_path`` so the next render falls - back to the active theme's bundled background. Without this, the - next ``RenderAndSend`` (triggered by ``VideoStopped`` via - ``DeviceRenderObserver``) would find ``background_path`` still set, - take the override branch in ``DisplayService._resolve_background``, - and silently re-decode the same video via ``MediaService.load_video`` - — turning "stop" into "rewind to frame 0". + Clears ``DeviceSettings.background_path`` (unless ``keep_override=True``) + so the next render falls back to the active theme's bundled background. """ key: str + keep_override: bool = False def execute(self, app: App) -> VideoResult: had_playback = app.media.playback(self.key) is not None @@ -1070,7 +1073,7 @@ def execute(self, app: App) -> VideoResult: app.settings.for_device(self.key).background_path is not None ) app.media.unload(self.key) - if had_override: + if had_override and not self.keep_override: log.info( "StopVideo: clearing background_path override for %s", self.key, diff --git a/src/trcc/core/commands/theme.py b/src/trcc/core/commands/theme.py index f2804adf..aa5487a3 100644 --- a/src/trcc/core/commands/theme.py +++ b/src/trcc/core/commands/theme.py @@ -1556,21 +1556,33 @@ def execute(self, app: App) -> ThemeResult: # the same name, so restore must NOT re-resolve a shipped pointer to # the user one — it loads whatever the user last selected. candidate = oriented_theme_path(app, self.key, candidate) - return LoadTheme( + result = LoadTheme( key=self.key, path=candidate, reset_overrides=False, ).execute(app) + else: + # Legacy bare-name value — search the known theme roots. + resolved = _search_theme_by_name(app, self.key, stored) + if resolved is None: + return ThemeResult( + ok=False, key=self.key, theme_name=stored, + message=(f"Persisted theme {stored!r} not found in any " + "known theme root for this device"), + ) + result = LoadTheme( + key=self.key, path=resolved, reset_overrides=False, + ).execute(app) - # Legacy bare-name value — search the known theme roots. - resolved = _search_theme_by_name(app, self.key, stored) - if resolved is None: - return ThemeResult( - ok=False, key=self.key, theme_name=stored, - message=(f"Persisted theme {stored!r} not found in any " - "known theme root for this device"), - ) - return LoadTheme( - key=self.key, path=resolved, reset_overrides=False, - ).execute(app) + if result.ok: + bg = app.settings.for_device(self.key).background_path + if bg and Path(bg).exists(): + ext = Path(bg).suffix.lower() + if ext in _VIDEO_EXTS_FOR_LOAD: + log.info( + "RestoreLastTheme: %s replaying persisted background video %s", + self.key, bg, + ) + PlayVideo(key=self.key, path=Path(bg)).execute(app) + return result @dataclass(frozen=True, slots=True) class RestoreDeviceState(Command[ThemeResult]): diff --git a/src/trcc/services/display.py b/src/trcc/services/display.py index e161fee7..318c13bf 100644 --- a/src/trcc/services/display.py +++ b/src/trcc/services/display.py @@ -50,7 +50,7 @@ log = logging.getLogger(__name__) -_VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt"} +_VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".zt", ".gif"} _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"} diff --git a/src/trcc/services/theme.py b/src/trcc/services/theme.py index 5fa70e40..2dbf397d 100644 --- a/src/trcc/services/theme.py +++ b/src/trcc/services/theme.py @@ -78,7 +78,7 @@ class DiscoveredMask: # JPEG-sequence archive UCVideoCut writes). No ``background.*`` — # that name never existed in legacy or Windows TRCC. _VIDEO_CANDIDATES = ( - "Theme.mp4", "Theme.mov", "Theme.webm", "Theme.zt", + "Theme.mp4", "Theme.mov", "Theme.webm", "Theme.zt", "Theme.gif", ) # Video container extensions we ship (derived from _VIDEO_CANDIDATES so the # two never drift); the background allowlist is those plus the static PNG. diff --git a/src/trcc/ui/gui/display_mode_panels.py b/src/trcc/ui/gui/display_mode_panels.py index 1a18782f..6bdfa5e5 100644 --- a/src/trcc/ui/gui/display_mode_panels.py +++ b/src/trcc/ui/gui/display_mode_panels.py @@ -291,6 +291,10 @@ def set_enabled(self, enabled): self.toggle_btn.blockSignals(False) self._set_actions_enabled(enabled) + @property + def is_enabled(self) -> bool: + return self.toggle_btn.isChecked() + def set_title(self, text: str) -> None: """Update the title label text.""" self._title_lbl.setText(text) @@ -681,6 +685,10 @@ def set_values(self, x=None, y=None, w=None, h=None): def set_resolution(self, width, height): """Set LCD resolution for aspect ratio calculations.""" self._resolution = (width, height) + if not self.entry_w.text() or self.entry_w.text() == "0": + self.entry_w.setText(str(width)) + if not self.entry_h.text() or self.entry_h.text() == "0": + self.entry_h.setText(str(height)) def set_aspect_lock(self, enabled): self._aspect_lock = enabled diff --git a/src/trcc/ui/gui/lcd_handler.py b/src/trcc/ui/gui/lcd_handler.py index 7550422e..632406fc 100644 --- a/src/trcc/ui/gui/lcd_handler.py +++ b/src/trcc/ui/gui/lcd_handler.py @@ -42,7 +42,6 @@ SetOverlayConfig, SetSplitMode, StopVideo, - UploadCustomMask, VideoStatus, ) from ..presentation.lcd_presentation_model import LcdPresentationModel @@ -563,17 +562,13 @@ def apply_mask(self, mask_info: Any) -> None: self._w['preview'].set_status(f"Mask: {mask_info.name}") return mask_dir = Path(mask_info.path) - # DC first — sets overlay resolution + element positions for this mask - self._load_theme_overlay_config(mask_dir, persist=False) - is_custom = getattr(mask_info, 'is_custom', False) - if is_custom: - r = self._app.dispatch(UploadCustomMask( - key=self._device_key, source=mask_dir, - )) - else: - r = self._app.dispatch(ApplyMask( - key=self._device_key, path=mask_dir, - )) + # DC first — sets overlay resolution + element positions for this mask (if present) + overlay_config = dc_as_legacy_overlay_config(mask_dir) + if overlay_config: + self._load_theme_overlay_config(mask_dir, persist=False) + r = self._app.dispatch(ApplyMask( + key=self._device_key, path=mask_dir, + )) if r.ok: self._w['preview'].set_status(r.message) else: @@ -983,6 +978,9 @@ def rebuild_preview(self) -> None: self._device_key, ) return + theme_setting = self._w.get('theme_setting') + if theme_setting and getattr(theme_setting, 'screencast_panel', None) and theme_setting.screencast_panel.is_enabled: + return image = self._app.display.rendered_surface(self._device_key) if image is None: # No frame rendered yet (pre-load) — build a one-off surface. @@ -1179,7 +1177,7 @@ def on_screencast_frame(self, image: Any) -> None: return try: data = self._app.display.build_screencast_frame( - info=device.info, frame=image, + info=device.info, frame=image, profile=device.profile, ) except Exception as e: self.log.warning( @@ -1307,6 +1305,12 @@ def _render_and_send(self) -> None: "_render_and_send: skipped — animation timer owns the wire", ) return + theme_setting = self._w.get('theme_setting') + if theme_setting and getattr(theme_setting, 'screencast_panel', None) and theme_setting.screencast_panel.is_enabled: + self.log.debug( + "_render_and_send: skipped — screencast owns the wire", + ) + return device = self._app.devices.get(self._device_key) if device is None or not device.is_connected: self.log.debug( @@ -1481,7 +1485,7 @@ def set_inactive(self) -> None: def _cleanup_device(self) -> None: """Release LCD resources via Commands.""" from ...core.commands import SendColor - self._app.dispatch(StopVideo(key=self._device_key)) + self._app.dispatch(StopVideo(key=self._device_key, keep_override=True)) try: # Best-effort black-frame so the screen visibly goes blank. self._app.dispatch(SendColor( diff --git a/src/trcc/ui/gui/trcc_app.py b/src/trcc/ui/gui/trcc_app.py index 950e9d1e..2273c016 100644 --- a/src/trcc/ui/gui/trcc_app.py +++ b/src/trcc/ui/gui/trcc_app.py @@ -564,7 +564,7 @@ def _on_bus_frame_sent(self, event: Any) -> None: handler.handle_frame(surface) elif colors: handler.handle_frame({"display_colors": list(colors)}) - else: + elif not self._screencast.active: handler.rebuild_preview() def _on_bus_video_started(self, event: Any) -> None: @@ -1823,6 +1823,11 @@ def _on_screencast_toggle(self, enabled: bool) -> None: # render hint, not a session-lifecycle fact. self._screencast.set_lcd_size(w, hw) x, y, sw, sh = self._screencast.params + if sw <= 0 or sh <= 0: + sw, sh = w, hw + self._screencast.set_params(x, y, sw, sh) + if hasattr(self, 'uc_theme_setting'): + self.uc_theme_setting.screencast_panel.set_coords(x=x, y=y, w=sw, h=sh) result = self._app.dispatch(StartScreencast( key=h.device_key, x=x, y=y, w=sw, h=sh, audio=self._screencast.audio_enabled, @@ -2153,15 +2158,26 @@ def _on_video_cut_done(self, zt_path: Any) -> None: self._hide_cutters() h = self._active_lcd() if zt_path and h: + import shutil + target_dir = ( + self._app.platform.paths().user_content_dir() / "backgrounds" + ) + target_dir.mkdir(parents=True, exist_ok=True) + safe_key = h.device_key.replace(":", "_") or "default" + target = target_dir / f"{safe_key}.zt" + try: + shutil.copy2(zt_path, target) + final_path = target + except Exception as e: + log.warning("_on_video_cut_done: failed to copy %s to %s: %s", zt_path, target, e) + final_path = Path(zt_path) + # ``SetBackground`` persists the .zt as the device's # background override (``DeviceSettings.background_path``) # THEN delegates to ``PlayVideo`` for the decode/animate # pipeline — same as the image cutter's ``_on_image_cut_done``. - # Dispatching ``PlayVideo`` directly here (the old code) left - # no override for ``SaveTheme`` to bake in, so a saved theme - # lost the video and reloaded with a black background. result = self._app.dispatch(SetBackground( - key=h.device_key, path=Path(zt_path), + key=h.device_key, path=final_path, )) if result.ok: self.uc_preview.set_playing(True) diff --git a/src/trcc/ui/gui/uc_video_cut.py b/src/trcc/ui/gui/uc_video_cut.py index f20cc990..14ee21c7 100644 --- a/src/trcc/ui/gui/uc_video_cut.py +++ b/src/trcc/ui/gui/uc_video_cut.py @@ -92,7 +92,7 @@ class ExportWorker(QThread): error = Signal(str) def __init__(self, video_path, start_ms, end_ms, target_w, target_h, - rotation, width_fit): + rotation, width_fit, zoom=1.0, pan_x=0.5, pan_y=0.5): super().__init__() self.video_path = str(video_path) self.start_ms = start_ms @@ -101,6 +101,9 @@ def __init__(self, video_path, start_ms, end_ms, target_w, target_h, self.target_h = target_h self.rotation = rotation self.width_fit = width_fit + self.zoom = zoom + self.pan_x = pan_x + self.pan_y = pan_y def run(self): try: @@ -126,11 +129,20 @@ def _do_export(self): elif self.rotation == 270: vf_filters.append('transpose=2') + if not self.width_fit or self.zoom > 1.0: + tw = max(self.target_w, int(self.target_w * self.zoom)) + th = max(self.target_h, int(self.target_h * self.zoom)) + vf_filters.append( + f'scale=w={tw}:h={th}:force_original_aspect_ratio=increase,' + f'crop={self.target_w}:{self.target_h}:(iw-{self.target_w})*{self.pan_x}:(ih-{self.target_h})*{self.pan_y}' + ) + else: + vf_filters.append(f'scale=w={self.target_w}:h={self.target_h}:force_original_aspect_ratio=decrease,pad={self.target_w}:{self.target_h}:(ow-iw)/2:(oh-ih)/2') + cmd = [ 'ffmpeg', '-ss', str(start_s), '-t', str(duration_s), '-i', self.video_path, '-y', '-r', str(EXPORT_FPS), - '-s', f'{self.target_w}x{self.target_h}', ] if vf_filters: cmd.extend(['-vf', ','.join(vf_filters)]) @@ -219,13 +231,18 @@ def __init__(self, parent=None): self._target_h = 0 self._rotation = 0 self._width_fit = True + self._zoom = 1.0 + self._pan_x = 0.5 + self._pan_y = 0.5 + self._drag_last_x = 0 + self._drag_last_y = 0 # Timeline handles (pixel x positions) self._start_x = TIMELINE_X self._end_x = TIMELINE_X + TIMELINE_W self._start_ms = 0 self._end_ms = 0 - self._dragging = None # 'start' or 'end' + self._dragging = None # 'start', 'end', or 'preview' # Preview state self._preview_pixmap = None @@ -350,14 +367,34 @@ def paintEvent(self, event): p.end() # ========================================================================= - # Mouse interaction (timeline handles) + # Mouse interaction (timeline handles, preview zoom & pan) # ========================================================================= + def wheelEvent(self, event): + x, y = event.position().x(), event.position().y() + if PREVIEW_X <= x <= PREVIEW_X + PREVIEW_W and PREVIEW_Y <= y <= PREVIEW_Y + PREVIEW_H: + delta = event.angleDelta().y() + if delta > 0: + self._zoom = min(5.0, round(self._zoom + 0.15, 2)) + elif delta < 0: + self._zoom = max(1.0, round(self._zoom - 0.15, 2)) + self._seek_and_show(self._preview_pos_ms if self._previewing else self._start_ms) + event.accept() + else: + super().wheelEvent(event) + def mousePressEvent(self, event): if event.button() != Qt.MouseButton.LeftButton: return x, y = event.position().x(), event.position().y() + # Check if click is on preview area (pan / drag to position crop) + if PREVIEW_X <= x <= PREVIEW_X + PREVIEW_W and PREVIEW_Y <= y <= PREVIEW_Y + PREVIEW_H: + self._dragging = 'preview' + self._drag_last_x = x + self._drag_last_y = y + return + # Check if click is on timeline area if not (TIMELINE_Y <= y <= TIMELINE_Y + TIMELINE_H): return @@ -376,7 +413,19 @@ def mousePressEvent(self, event): def mouseMoveEvent(self, event): if not self._dragging or self._duration_ms <= 0: return - x = event.position().x() + x, y = event.position().x(), event.position().y() + + if self._dragging == 'preview': + dx = x - self._drag_last_x + dy = y - self._drag_last_y + self._drag_last_x = x + self._drag_last_y = y + sens = max(20.0, (self._zoom - 0.9) * 200.0) + self._pan_x = max(0.0, min(1.0, self._pan_x - dx / sens)) + self._pan_y = max(0.0, min(1.0, self._pan_y - dy / sens)) + self._seek_and_show(self._preview_pos_ms if self._previewing else self._start_ms) + return + x = max(TIMELINE_X, min(TIMELINE_X + TIMELINE_W, x)) if self._dragging == 'start': @@ -492,6 +541,11 @@ def load_video(self, path): self._lbl_start.setText(_format_time(self._start_ms)) self._lbl_end.setText(_format_time(self._end_ms)) + # Reset zoom and pan + self._zoom = 1.0 + self._pan_x = 0.5 + self._pan_y = 0.5 + # Show first frame self._seek_and_show(0) self.update() @@ -537,12 +591,24 @@ def _seek_and_show(self, ms): if self._rotation: img = img.transformed(QTransform().rotate(self._rotation)) w, h = img.width(), img.height() - scale = min(PREVIEW_W / w, PREVIEW_H / h) - new_w, new_h = int(w * scale), int(h * scale) - if new_w > 0 and new_h > 0: + if self._width_fit and self._zoom <= 1.0: + scale = min(PREVIEW_W / w, PREVIEW_H / h) + new_w, new_h = max(1, int(w * scale)), max(1, int(h * scale)) + img = img.scaled(new_w, new_h, + Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation) + else: + base_scale = max(PREVIEW_W / w, PREVIEW_H / h) + scale = base_scale * self._zoom + new_w, new_h = max(1, int(w * scale)), max(1, int(h * scale)) img = img.scaled(new_w, new_h, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) + max_cx = max(0, new_w - PREVIEW_W) + max_cy = max(0, new_h - PREVIEW_H) + cx = int(max_cx * self._pan_x) + cy = int(max_cy * self._pan_y) + img = img.copy(cx, cy, min(new_w, PREVIEW_W), min(new_h, PREVIEW_H)) self._preview_pixmap = QPixmap.fromImage(img) self._lbl_current.setText(_format_time(ms)) @@ -555,11 +621,17 @@ def _seek_and_show(self, ms): def _on_width_fit(self): log.debug("_on_width_fit: width_fit=True") self._width_fit = True + self._zoom = 1.0 + self._pan_x = 0.5 + self._pan_y = 0.5 self._seek_and_show(self._start_ms) def _on_height_fit(self): log.debug("_on_height_fit: width_fit=False") self._width_fit = False + self._zoom = 1.0 + self._pan_x = 0.5 + self._pan_y = 0.5 self._seek_and_show(self._start_ms) def _on_rotate(self): @@ -598,7 +670,7 @@ def _preview_tick(self): # ========================================================================= def _on_export(self): - log.debug("_on_export: video_path=%s start=%s end=%s", self._video_path, self._start_ms, self._end_ms) + log.debug("_on_export: video_path=%s start=%s end=%s zoom=%s", self._video_path, self._start_ms, self._end_ms, self._zoom) if self._is_processing or not self._video_path: return @@ -613,7 +685,8 @@ def _on_export(self): self._export_worker = ExportWorker( self._video_path, self._start_ms, self._end_ms, self._target_w, self._target_h, - self._rotation, self._width_fit + self._rotation, self._width_fit, + self._zoom, self._pan_x, self._pan_y ) self._export_worker.progress.connect(self._on_export_progress) self._export_worker.finished.connect(self._on_export_finished) diff --git a/src/trcc/ui/screen_overlay.py b/src/trcc/ui/screen_overlay.py index d70a7ac9..45324002 100644 --- a/src/trcc/ui/screen_overlay.py +++ b/src/trcc/ui/screen_overlay.py @@ -47,7 +47,7 @@ def is_wayland() -> bool: ) -_FALLBACK_TOOLS: tuple[str, ...] = ("grim", "gnome-screenshot", "scrot") +_FALLBACK_TOOLS: tuple[str, ...] = ("grim", "spectacle", "gnome-screenshot", "scrot") def _has_tool(name: str) -> bool: @@ -58,6 +58,7 @@ def _try_external_capture(tmp_path: str) -> QPixmap: """Run a fallback screenshot tool, return what it wrote (or null).""" cmds = { "grim": ["grim", tmp_path], + "spectacle": ["spectacle", "-b", "-n", "-o", tmp_path], "gnome-screenshot": ["gnome-screenshot", "-f", tmp_path], "scrot": ["scrot", tmp_path], } @@ -72,8 +73,13 @@ def _try_external_capture(tmp_path: str) -> QPixmap: except subprocess.TimeoutExpired: log.warning("screen capture: %s timed out", tool) continue - if result.returncode != 0 or not Path(tmp_path).exists(): - log.debug("screen capture: %s exited %d", tool, result.returncode) + import time + for _ in range(20): + if Path(tmp_path).exists() and Path(tmp_path).stat().st_size > 0: + break + time.sleep(0.025) + if not Path(tmp_path).exists() or Path(tmp_path).stat().st_size == 0: + log.debug("screen capture: %s output file missing or empty", tool) continue pix = QPixmap(tmp_path) if not pix.isNull(): @@ -99,10 +105,11 @@ def grab_full_screen() -> QPixmap: fd, tmp_path = tempfile.mkstemp(suffix=".png") os.close(fd) try: + Path(tmp_path).unlink(missing_ok=True) return _try_external_capture(tmp_path) finally: try: - Path(tmp_path).unlink() + Path(tmp_path).unlink(missing_ok=True) except OSError: pass