@@ -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