Skip to content

Commit 8eaac75

Browse files
committed
fix: preview panel reactivity
1 parent 5805ab0 commit 8eaac75

17 files changed

Lines changed: 1041 additions & 395 deletions

src/clipper/gui/app.py

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ def __init__(self) -> None:
100100
'image_bytes': None,
101101
'mime': 'image/jpeg',
102102
}
103+
# Preview subprocess management (hard cancel support)
104+
self._preview_proc_lock = threading.Lock()
105+
self._active_preview_proc = None
106+
# Idempotent preview in-progress & result cache for color-graded outputs
107+
# key: (video_path|timestamp_ms|scale|filter or 'base') -> {'status': 'success', 'image_bytes': b'..', 'mime': 'image/jpeg'}
108+
self._preview_result_cache = {}
109+
self._preview_inflight = {}
103110

104111
# ---------------------
105112
# Preview cache helpers
@@ -1060,21 +1067,24 @@ def create_temp_markup_file(self, markup_data: Dict[str, Any]) -> Dict[str, Any]
10601067

10611068
def generate_frame_preview(self, video_path: str, timestamp: float,
10621069
color_grading: Optional[str] = None,
1063-
resolution_scale: float = 1.0) -> Dict[str, Any]:
1070+
resolution_scale: float = 1.0,
1071+
request_id: Optional[str] = None) -> Dict[str, Any]:
10641072
"""Generate a frame preview with optional color grading filters.
10651073
10661074
Args:
10671075
video_path: Path to the video file or a direct HTTP(S) URL
10681076
timestamp: Time in seconds to extract frame from
10691077
color_grading: Optional FFmpeg color grading filter string
10701078
resolution_scale: Scale factor for output resolution (0.1, 0.25, 0.5, 1.0)
1079+
request_id: Deprecated (ignored).
10711080
10721081
Returns:
10731082
Dict with status, message, and base64_image for success
10741083
"""
10751084
try:
10761085
import base64
10771086

1087+
# request_id kept for backward compatibility; no longer used for control flow
10781088
self.logger.info(f"Generating frame preview for {video_path} at {timestamp}s")
10791089

10801090
# Validate inputs: support local files and direct HTTP(S) URLs
@@ -1109,6 +1119,56 @@ def generate_frame_preview(self, video_path: str, timestamp: float,
11091119
'message': 'Invalid color grading filter string',
11101120
}
11111121

1122+
# Compose a normalized key (milliseconds precision)
1123+
key_base = f"{video_path}|{self._norm_ts(timestamp)}|{resolution_scale}"
1124+
filter_key = color_grading or 'base'
1125+
full_key = f"{key_base}|{filter_key}"
1126+
1127+
# Fast path: return completed cached result (color graded or base)
1128+
with self._preview_proc_lock:
1129+
cached = self._preview_result_cache.get(full_key)
1130+
if cached and cached.get('status') == 'success':
1131+
image_bytes_cached = cached.get('image_bytes')
1132+
if isinstance(image_bytes_cached, (bytes, bytearray)):
1133+
import base64
1134+
return {
1135+
'status': 'success',
1136+
'message': 'Frame preview (cached)',
1137+
'base64_image': base64.b64encode(image_bytes_cached).decode('utf-8'),
1138+
'mime_type': cached.get('mime', 'image/jpeg'),
1139+
'timestamp': timestamp,
1140+
'resolution_scale': resolution_scale,
1141+
'cached': True,
1142+
}
1143+
1144+
# If an identical request is already in-flight, wait for it instead of spawning another
1145+
wait_event: Optional[threading.Event] = None
1146+
if full_key not in self._preview_result_cache:
1147+
with self._preview_proc_lock:
1148+
if full_key in self._preview_inflight:
1149+
wait_event = threading.Event()
1150+
self._preview_inflight[full_key].append(wait_event)
1151+
else:
1152+
# Mark in-flight with list of waiters
1153+
self._preview_inflight[full_key] = []
1154+
if wait_event:
1155+
# Block until original finishes or timeout
1156+
wait_event.wait(timeout=30)
1157+
with self._preview_proc_lock:
1158+
cached = self._preview_result_cache.get(full_key)
1159+
if cached and cached.get('status') == 'success':
1160+
import base64
1161+
return {
1162+
'status': 'success',
1163+
'message': 'Frame preview (deduped)',
1164+
'base64_image': base64.b64encode(cached['image_bytes']).decode('utf-8'), # type: ignore[index]
1165+
'mime_type': cached.get('mime', 'image/jpeg'),
1166+
'timestamp': timestamp,
1167+
'resolution_scale': resolution_scale,
1168+
'deduped': True,
1169+
}
1170+
# Fall through to recompute (original may have failed)
1171+
11121172
# Helper: ensure cached naked frame (scaled/padded, no color filters)
11131173
def ensure_cached_frame() -> Optional[bytes]:
11141174
cache_key_path = str(video_path)
@@ -1136,15 +1196,34 @@ def ensure_cached_frame() -> Optional[bytes]:
11361196
base_cmd.append('pipe:1')
11371197

11381198
self.logger.debug(f"FFmpeg (cache base) command: {' '.join(base_cmd)}")
1139-
result = subprocess.run(base_cmd, capture_output=True, timeout=30, check=False)
1140-
if result.returncode != 0 or not result.stdout:
1199+
# Hard-cancel previous preview extraction if still running
1200+
with self._preview_proc_lock:
1201+
if self._active_preview_proc and self._active_preview_proc.poll() is None:
1202+
self.logger.debug("Terminating previous preview ffmpeg process")
1203+
with contextlib.suppress(Exception):
1204+
self._active_preview_proc.terminate()
1205+
try:
1206+
self._active_preview_proc.wait(timeout=0.5)
1207+
except Exception:
1208+
with contextlib.suppress(Exception):
1209+
self._active_preview_proc.kill()
1210+
proc = subprocess.Popen(base_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1211+
self._active_preview_proc = proc
1212+
1213+
stdout, stderr = proc.communicate(timeout=30)
1214+
if proc.returncode != 0 or not stdout:
11411215
self.logger.error("Failed to generate base frame for cache")
1142-
if result.stderr:
1143-
self.logger.error(result.stderr.decode(errors='ignore'))
1216+
if stderr:
1217+
self.logger.error(stderr.decode(errors='ignore'))
11441218
return None
11451219

1146-
self._store_cached_frame(cache_key_path, normalized_ts, resolution_scale, result.stdout)
1147-
return result.stdout
1220+
# Store cache only if this request is still current (avoid race overwriting newer)
1221+
with self._preview_proc_lock:
1222+
# Always store; latest call already killed any older proc
1223+
self._store_cached_frame(cache_key_path, normalized_ts, resolution_scale, stdout)
1224+
if self._active_preview_proc is proc:
1225+
self._active_preview_proc = None
1226+
return stdout
11481227

11491228
# If no color grading (or preview disabled on frontend), just return cached naked frame
11501229
if not color_grading:
@@ -1155,15 +1234,25 @@ def ensure_cached_frame() -> Optional[bytes]:
11551234
'message': 'Failed to generate base frame for preview',
11561235
}
11571236
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
1158-
self.logger.info(f"Frame preview generated from cache (base64, {len(image_base64)} chars)")
1159-
return {
1237+
result_obj = {
11601238
'status': 'success',
11611239
'message': 'Frame preview generated',
11621240
'base64_image': image_base64,
11631241
'mime_type': 'image/jpeg',
11641242
'timestamp': timestamp,
11651243
'resolution_scale': resolution_scale,
11661244
}
1245+
with self._preview_proc_lock:
1246+
self._preview_result_cache[full_key] = {
1247+
'status': 'success',
1248+
'image_bytes': image_bytes,
1249+
'mime': 'image/jpeg',
1250+
}
1251+
# Release waiters
1252+
waiters = self._preview_inflight.pop(full_key, [])
1253+
for ev in waiters:
1254+
ev.set()
1255+
return result_obj
11671256

11681257
# With color grading: reuse cached base frame and apply filters only
11691258
base_frame = ensure_cached_frame()
@@ -1189,26 +1278,43 @@ def ensure_cached_frame() -> Optional[bytes]:
11891278
]
11901279

11911280
self.logger.debug(f"FFmpeg (apply filters) command: {' '.join(filter_cmd)}")
1192-
result = subprocess.run(filter_cmd, input=base_frame, capture_output=True, timeout=30, check=False)
1193-
if result.returncode != 0 or not result.stdout:
1281+
# Apply filters (pure CPU, fast) - run as subprocess (no caching needed)
1282+
result2 = subprocess.run(filter_cmd, input=base_frame, capture_output=True, timeout=30, check=False)
1283+
if result2.returncode != 0 or not result2.stdout:
11941284
self.logger.error("FFmpeg filter application failed")
1195-
if result.stderr:
1196-
self.logger.error(result.stderr.decode(errors='ignore'))
1197-
return {
1285+
if result2.stderr:
1286+
self.logger.error(result2.stderr.decode(errors='ignore'))
1287+
err_obj = {
11981288
'status': 'error',
11991289
'message': 'Failed to apply color grading to cached frame',
12001290
}
1201-
1202-
image_base64 = base64.b64encode(result.stdout).decode('utf-8')
1291+
with self._preview_proc_lock:
1292+
# Release waiters with failure
1293+
waiters = self._preview_inflight.pop(full_key, [])
1294+
for ev in waiters:
1295+
ev.set()
1296+
return err_obj
1297+
1298+
image_base64 = base64.b64encode(result2.stdout).decode('utf-8')
12031299
self.logger.info(f"Frame preview generated successfully (base64, {len(image_base64)} chars)")
1204-
return {
1300+
success_obj = {
12051301
'status': 'success',
12061302
'message': 'Frame preview generated',
12071303
'base64_image': image_base64,
12081304
'mime_type': 'image/jpeg',
12091305
'timestamp': timestamp,
12101306
'resolution_scale': resolution_scale,
12111307
}
1308+
with self._preview_proc_lock:
1309+
self._preview_result_cache[full_key] = {
1310+
'status': 'success',
1311+
'image_bytes': result2.stdout,
1312+
'mime': 'image/jpeg',
1313+
}
1314+
waiters = self._preview_inflight.pop(full_key, [])
1315+
for ev in waiters:
1316+
ev.set()
1317+
return success_obj
12121318

12131319
except subprocess.TimeoutExpired:
12141320
return {
@@ -1222,6 +1328,28 @@ def ensure_cached_frame() -> Optional[bytes]:
12221328
'message': f'Failed to generate frame preview: {e!s}',
12231329
}
12241330

1331+
def cancel_frame_preview(self, request_id: Optional[str] = None) -> Dict[str, Any]:
1332+
"""Cancel the active frame preview ffmpeg process.
1333+
1334+
request_id parameter is deprecated and ignored.
1335+
"""
1336+
with self._preview_proc_lock:
1337+
proc = self._active_preview_proc
1338+
if not proc or proc.poll() is not None:
1339+
return {'status': 'success', 'message': 'No active preview process'}
1340+
try:
1341+
self.logger.debug('Canceling active preview process')
1342+
proc.terminate()
1343+
try:
1344+
proc.wait(timeout=0.5)
1345+
except Exception:
1346+
with contextlib.suppress(Exception):
1347+
proc.kill()
1348+
self._active_preview_proc = None
1349+
return {'status': 'success', 'message': 'Preview process canceled'}
1350+
except Exception as e:
1351+
return {'status': 'error', 'message': f'Failed to cancel preview: {e!s}'}
1352+
12251353
def get_direct_video_url(self, page_url: str) -> Dict[str, Any]:
12261354
"""Resolve a direct media URL using yt-dlp based on GUI settings.
12271355

0 commit comments

Comments
 (0)