diff --git a/src/clipper/gui/app.py b/src/clipper/gui/app.py
index 5ca7bc2..5720f9e 100644
--- a/src/clipper/gui/app.py
+++ b/src/clipper/gui/app.py
@@ -10,6 +10,7 @@
import threading
import time
import uuid
+from collections import OrderedDict
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -100,6 +101,14 @@ def __init__(self) -> None:
'image_bytes': None,
'mime': 'image/jpeg',
}
+ # Preview subprocess management (hard cancel support)
+ self._preview_proc_lock = threading.Lock()
+ self._active_preview_proc = None
+ # Idempotent preview in-progress & result cache (LRU bounded)
+ # key: (video_path|timestamp_ms|scale|filter or 'base') -> {'status': 'success', 'image_bytes': b'..', 'mime': 'image/jpeg'}
+ self._preview_result_cache = OrderedDict() # type: ignore[var-annotated]
+ self._preview_result_cache_max = 64 # Reasonable default; holds recent previews
+ self._preview_inflight = {}
# ---------------------
# Preview cache helpers
@@ -135,6 +144,171 @@ def _clear_cached_frame(self) -> None:
'image_bytes': None,
})
+ # --------- New helper methods to simplify generate_frame_preview (reduce branching) ---------
+ def _validate_preview_inputs(self, video_path: str, timestamp: float, resolution_scale: float,
+ color_grading: Optional[str]) -> Optional[Dict[str, Any]]:
+ """Validate basic preview inputs. Return error dict if invalid, else None."""
+ is_http = str(video_path).startswith(('http://', 'https://'))
+ if not is_http:
+ video_file = Path(video_path)
+ if not video_file.exists():
+ return {'status': 'error', 'message': f'Video file not found: {video_path}'}
+ if timestamp < 0:
+ return {'status': 'error', 'message': 'Timestamp must be non-negative'}
+ if resolution_scale not in [0.1, 0.25, 0.5, 1.0]:
+ return {'status': 'error', 'message': 'Resolution scale must be 0.1, 0.25, 0.5, or 1.0'}
+ if color_grading:
+ from clipper.ffmpeg_filter import _validate_color_grading_filter
+ if not _validate_color_grading_filter(color_grading):
+ return {'status': 'error', 'message': 'Invalid color grading filter string'}
+ return None
+
+ def _get_cached_preview(self, full_key: str, timestamp: float, resolution_scale: float) -> Optional[Dict[str, Any]]:
+ """Return cached preview result (already base64 encoded) if available."""
+ with self._preview_proc_lock:
+ cached = self._preview_result_cache.get(full_key)
+ if cached and cached.get('status') == 'success':
+ image_bytes_cached = cached.get('image_bytes')
+ if isinstance(image_bytes_cached, (bytes, bytearray)):
+ # Move to MRU position
+ self._preview_result_cache.move_to_end(full_key, last=True)
+ import base64
+ return {
+ 'status': 'success',
+ 'message': 'Frame preview (cached)',
+ 'base64_image': base64.b64encode(image_bytes_cached).decode('utf-8'),
+ 'mime_type': cached.get('mime', 'image/jpeg'),
+ 'timestamp': timestamp,
+ 'resolution_scale': resolution_scale,
+ 'cached': True,
+ }
+ return None
+
+ def _register_inflight_or_wait(self, full_key: str) -> Optional[threading.Event]:
+ """Register request as in-flight or attach as waiter. Returns wait_event if should wait."""
+ wait_event: Optional[threading.Event] = None
+ if full_key not in self._preview_result_cache:
+ with self._preview_proc_lock:
+ if full_key in self._preview_inflight:
+ wait_event = threading.Event()
+ self._preview_inflight[full_key].append(wait_event)
+ else:
+ self._preview_inflight[full_key] = [] # This caller becomes producer
+ return wait_event
+
+ def _wait_for_inflight(self, full_key: str, wait_event: threading.Event, timestamp: float,
+ resolution_scale: float) -> Optional[Dict[str, Any]]:
+ """Wait for existing in-flight result and return standardized response if success."""
+ wait_event.wait(timeout=30)
+ with self._preview_proc_lock:
+ cached = self._preview_result_cache.get(full_key)
+ if cached and cached.get('status') == 'success':
+ with self._preview_proc_lock:
+ self._preview_result_cache.move_to_end(full_key, last=True)
+ import base64
+ return {
+ 'status': 'success',
+ 'message': 'Frame preview (deduped)',
+ 'base64_image': base64.b64encode(cached['image_bytes']).decode('utf-8'), # type: ignore[index]
+ 'mime_type': cached.get('mime', 'image/jpeg'),
+ 'timestamp': timestamp,
+ 'resolution_scale': resolution_scale,
+ 'deduped': True,
+ }
+ return None
+
+ def _ensure_base_frame(self, video_path: str, normalized_ts: float, resolution_scale: float) -> Optional[bytes]:
+ """Ensure a cached base frame exists (no color grading). Return bytes or None."""
+ cache_key_path = str(video_path)
+ if self._cache_matches(cache_key_path, normalized_ts, resolution_scale):
+ self.logger.debug("Using cached base frame for preview")
+ return self._preview_frame_cache.get('image_bytes')
+
+ base_cmd = [
+ 'ffmpeg', '-ss', str(normalized_ts), '-i', str(video_path),
+ '-vframes', '1', '-f', 'image2pipe', '-vcodec', 'mjpeg', '-pix_fmt', 'yuvj420p', '-q:v', '2',
+ ]
+ vf_parts = []
+ if resolution_scale != 1.0:
+ vf_parts.append(f'scale=iw*{resolution_scale}:ih*{resolution_scale}:force_original_aspect_ratio=decrease')
+ vf_parts.append('pad=ceil(iw/2)*2:ceil(ih/2)*2:(ow-iw)/2:(oh-ih)/2:color=black')
+ if vf_parts:
+ base_cmd.extend(['-vf', ','.join(vf_parts)])
+ base_cmd.append('pipe:1')
+
+ self.logger.debug(f"FFmpeg (cache base) command: {' '.join(base_cmd)}")
+ with self._preview_proc_lock:
+ if self._active_preview_proc and self._active_preview_proc.poll() is None:
+ self.logger.debug("Terminating previous preview ffmpeg process")
+ with contextlib.suppress(Exception):
+ self._active_preview_proc.terminate()
+ try:
+ self._active_preview_proc.wait(timeout=0.5)
+ except Exception:
+ with contextlib.suppress(Exception):
+ self._active_preview_proc.kill()
+ proc = subprocess.Popen(base_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ self._active_preview_proc = proc
+
+ try:
+ stdout, stderr = proc.communicate(timeout=30)
+ except Exception:
+ with contextlib.suppress(Exception):
+ proc.kill()
+ return None
+ if proc.returncode != 0 or not stdout:
+ self.logger.error("Failed to generate base frame for cache")
+ if stderr:
+ self.logger.error(stderr.decode(errors='ignore'))
+ return None
+ with self._preview_proc_lock:
+ self._store_cached_frame(cache_key_path, normalized_ts, resolution_scale, stdout)
+ if self._active_preview_proc is proc:
+ self._active_preview_proc = None
+ return stdout
+
+ def _apply_color_grading_filters(self, base_frame: bytes, color_grading: str) -> Optional[bytes]:
+ """Apply ffmpeg color grading filters to a base JPEG frame."""
+ filter_cmd = [
+ 'ffmpeg', '-f', 'image2pipe', '-vcodec', 'mjpeg', '-i', 'pipe:0',
+ '-vframes', '1', '-f', 'image2pipe', '-vcodec', 'mjpeg', '-pix_fmt', 'yuvj420p', '-q:v', '2',
+ '-vf', color_grading, 'pipe:1',
+ ]
+ self.logger.debug(f"FFmpeg (apply filters) command: {' '.join(filter_cmd)}")
+ result2 = subprocess.run(filter_cmd, input=base_frame, capture_output=True, timeout=30, check=False)
+ if result2.returncode != 0 or not result2.stdout:
+ self.logger.error("FFmpeg filter application failed")
+ if result2.stderr:
+ self.logger.error(result2.stderr.decode(errors='ignore'))
+ return None
+ return result2.stdout
+
+ # Finalization helpers to reduce branching in main preview method
+ def _finalize_preview_success(self, full_key: str, image_bytes: bytes, timestamp: float, resolution_scale: float) -> Dict[str, Any]:
+ import base64
+ image_base64 = base64.b64encode(image_bytes).decode('utf-8')
+ success_obj = {
+ 'status': 'success', 'message': 'Frame preview generated', 'base64_image': image_base64,
+ 'mime_type': 'image/jpeg', 'timestamp': timestamp, 'resolution_scale': resolution_scale,
+ }
+ with self._preview_proc_lock:
+ self._preview_result_cache[full_key] = {'status': 'success', 'image_bytes': image_bytes, 'mime': 'image/jpeg'}
+ self._preview_result_cache.move_to_end(full_key, last=True)
+ # Evict LRU entries beyond max size
+ while len(self._preview_result_cache) > self._preview_result_cache_max:
+ self._preview_result_cache.popitem(last=False)
+ waiters = self._preview_inflight.pop(full_key, [])
+ for ev in waiters:
+ ev.set()
+ return success_obj
+
+ def _finalize_preview_failure(self, full_key: str, message: str) -> Dict[str, Any]:
+ with self._preview_proc_lock:
+ waiters = self._preview_inflight.pop(full_key, [])
+ for ev in waiters:
+ ev.set()
+ return {'status': 'error', 'message': message}
+
def _update_cache_manager_settings(self) -> None:
"""Update cache manager with current settings."""
try:
@@ -1059,8 +1233,9 @@ def create_temp_markup_file(self, markup_data: Dict[str, Any]) -> Dict[str, Any]
}
def generate_frame_preview(self, video_path: str, timestamp: float,
- color_grading: Optional[str] = None,
- resolution_scale: float = 1.0) -> Dict[str, Any]:
+ color_grading: Optional[str] = None,
+ resolution_scale: float = 1.0,
+ request_id: Optional[str] = None) -> Dict[str, Any]:
"""Generate a frame preview with optional color grading filters.
Args:
@@ -1068,159 +1243,75 @@ def generate_frame_preview(self, video_path: str, timestamp: float,
timestamp: Time in seconds to extract frame from
color_grading: Optional FFmpeg color grading filter string
resolution_scale: Scale factor for output resolution (0.1, 0.25, 0.5, 1.0)
+ request_id: Deprecated (ignored).
Returns:
Dict with status, message, and base64_image for success
"""
try:
- import base64
-
self.logger.info(f"Generating frame preview for {video_path} at {timestamp}s")
- # Validate inputs: support local files and direct HTTP(S) URLs
- is_http = str(video_path).startswith(('http://', 'https://'))
- video_file = Path(video_path) if not is_http else None
- if not is_http and (not video_file or not video_file.exists()):
- return {
- 'status': 'error',
- 'message': f'Video file not found: {video_path}',
- }
-
- if timestamp < 0:
- return {
- 'status': 'error',
- 'message': 'Timestamp must be non-negative',
- }
-
- if resolution_scale not in [0.1, 0.25, 0.5, 1.0]:
- return {
- 'status': 'error',
- 'message': 'Resolution scale must be 0.1, 0.25, 0.5, or 1.0',
- }
+ # Basic validation
+ err = self._validate_preview_inputs(video_path, timestamp, resolution_scale, color_grading)
+ if err:
+ return err
normalized_ts = self._norm_ts(timestamp)
+ key_base = f"{video_path}|{normalized_ts}|{resolution_scale}"
+ full_key = f"{key_base}|{color_grading or 'base'}"
- # Validate color grading string early (if provided)
- if color_grading:
- from clipper.ffmpeg_filter import _validate_color_grading_filter
- if not _validate_color_grading_filter(color_grading):
- return {
- 'status': 'error',
- 'message': 'Invalid color grading filter string',
- }
+ # Fast cached lookup
+ cached = self._get_cached_preview(full_key, timestamp, resolution_scale)
+ if cached:
+ return cached
- # Helper: ensure cached naked frame (scaled/padded, no color filters)
- def ensure_cached_frame() -> Optional[bytes]:
- cache_key_path = str(video_path)
- if self._cache_matches(cache_key_path, normalized_ts, resolution_scale):
- self.logger.debug("Using cached base frame for preview")
- return self._preview_frame_cache.get('image_bytes')
-
- # Build command to extract a single JPEG frame with scale/pad only
- base_cmd = [
- 'ffmpeg',
- '-ss', str(normalized_ts),
- '-i', str(video_path),
- '-vframes', '1',
- '-f', 'image2pipe',
- '-vcodec', 'mjpeg',
- '-pix_fmt', 'yuvj420p',
- '-q:v', '2',
- ]
- vf_parts = []
- if resolution_scale != 1.0:
- vf_parts.append(f'scale=iw*{resolution_scale}:ih*{resolution_scale}:force_original_aspect_ratio=decrease')
- vf_parts.append('pad=ceil(iw/2)*2:ceil(ih/2)*2:(ow-iw)/2:(oh-ih)/2:color=black')
- if vf_parts:
- base_cmd.extend(['-vf', ','.join(vf_parts)])
- base_cmd.append('pipe:1')
-
- self.logger.debug(f"FFmpeg (cache base) command: {' '.join(base_cmd)}")
- result = subprocess.run(base_cmd, capture_output=True, timeout=30, check=False)
- if result.returncode != 0 or not result.stdout:
- self.logger.error("Failed to generate base frame for cache")
- if result.stderr:
- self.logger.error(result.stderr.decode(errors='ignore'))
- return None
-
- self._store_cached_frame(cache_key_path, normalized_ts, resolution_scale, result.stdout)
- return result.stdout
-
- # If no color grading (or preview disabled on frontend), just return cached naked frame
- if not color_grading:
- image_bytes = ensure_cached_frame()
- if not image_bytes:
- return {
- 'status': 'error',
- 'message': 'Failed to generate base frame for preview',
- }
- image_base64 = base64.b64encode(image_bytes).decode('utf-8')
- self.logger.info(f"Frame preview generated from cache (base64, {len(image_base64)} chars)")
- return {
- 'status': 'success',
- 'message': 'Frame preview generated',
- 'base64_image': image_base64,
- 'mime_type': 'image/jpeg',
- 'timestamp': timestamp,
- 'resolution_scale': resolution_scale,
- }
+ # Dedup logic (may return or proceed)
+ wait_event = self._register_inflight_or_wait(full_key)
+ if wait_event and (deduped := self._wait_for_inflight(full_key, wait_event, timestamp, resolution_scale)):
+ return deduped
- # With color grading: reuse cached base frame and apply filters only
- base_frame = ensure_cached_frame()
+ # Obtain base frame (cached or freshly extracted)
+ base_frame = self._ensure_base_frame(video_path, normalized_ts, resolution_scale)
if not base_frame:
- return {
- 'status': 'error',
- 'message': 'Failed to generate base frame for preview with filters',
- }
+ return self._finalize_preview_failure(full_key, 'Failed to generate base frame for preview')
- # Build ffmpeg to read JPEG from stdin, apply color filters, and output JPEG
- filter_cmd = [
- 'ffmpeg',
- '-f', 'image2pipe',
- '-vcodec', 'mjpeg',
- '-i', 'pipe:0',
- '-vframes', '1',
- '-f', 'image2pipe',
- '-vcodec', 'mjpeg',
- '-pix_fmt', 'yuvj420p',
- '-q:v', '2',
- '-vf', color_grading,
- 'pipe:1',
- ]
-
- self.logger.debug(f"FFmpeg (apply filters) command: {' '.join(filter_cmd)}")
- result = subprocess.run(filter_cmd, input=base_frame, capture_output=True, timeout=30, check=False)
- if result.returncode != 0 or not result.stdout:
- self.logger.error("FFmpeg filter application failed")
- if result.stderr:
- self.logger.error(result.stderr.decode(errors='ignore'))
- return {
- 'status': 'error',
- 'message': 'Failed to apply color grading to cached frame',
- }
+ # If no color grading requested
+ if not color_grading:
+ return self._finalize_preview_success(full_key, base_frame, timestamp, resolution_scale)
- image_base64 = base64.b64encode(result.stdout).decode('utf-8')
- self.logger.info(f"Frame preview generated successfully (base64, {len(image_base64)} chars)")
- return {
- 'status': 'success',
- 'message': 'Frame preview generated',
- 'base64_image': image_base64,
- 'mime_type': 'image/jpeg',
- 'timestamp': timestamp,
- 'resolution_scale': resolution_scale,
- }
+ # Apply color grading filters to cached base frame
+ graded_bytes = self._apply_color_grading_filters(base_frame, color_grading)
+ if not graded_bytes:
+ return self._finalize_preview_failure(full_key, 'Failed to apply color grading to cached frame')
+ return self._finalize_preview_success(full_key, graded_bytes, timestamp, resolution_scale)
except subprocess.TimeoutExpired:
- return {
- 'status': 'error',
- 'message': 'Frame extraction timed out',
- }
+ return {'status': 'error', 'message': 'Frame extraction timed out'}
except Exception as e:
self.logger.error(f"Error generating frame preview: {e}", exc_info=True)
- return {
- 'status': 'error',
- 'message': f'Failed to generate frame preview: {e!s}',
- }
+ return {'status': 'error', 'message': f'Failed to generate frame preview: {e!s}'}
+
+ def cancel_frame_preview(self, request_id: Optional[str] = None) -> Dict[str, Any]:
+ """Cancel the active frame preview ffmpeg process.
+
+ request_id parameter is deprecated and ignored.
+ """
+ with self._preview_proc_lock:
+ proc = self._active_preview_proc
+ if not proc or proc.poll() is not None:
+ return {'status': 'success', 'message': 'No active preview process'}
+ try:
+ self.logger.debug('Canceling active preview process')
+ proc.terminate()
+ try:
+ proc.wait(timeout=0.5)
+ except Exception:
+ with contextlib.suppress(Exception):
+ proc.kill()
+ self._active_preview_proc = None
+ return {'status': 'success', 'message': 'Preview process canceled'}
+ except Exception as e:
+ return {'status': 'error', 'message': f'Failed to cancel preview: {e!s}'}
def get_direct_video_url(self, page_url: str) -> Dict[str, Any]:
"""Resolve a direct media URL using yt-dlp based on GUI settings.
diff --git a/src/gui-frontend/src/components/ColorControls.vue b/src/gui-frontend/src/components/ColorControls.vue
index bca5baa..b052f7d 100644
--- a/src/gui-frontend/src/components/ColorControls.vue
+++ b/src/gui-frontend/src/components/ColorControls.vue
@@ -26,7 +26,10 @@
-
+
@@ -42,17 +45,21 @@
-
+
Copy Filter
-
+
Paste Filter
+
+
+ Copy String
+
-
+
Copy to All Clips
@@ -70,98 +77,152 @@
diff --git a/src/gui-frontend/src/components/DebugState.vue b/src/gui-frontend/src/components/DebugState.vue
new file mode 100644
index 0000000..4a8d668
--- /dev/null
+++ b/src/gui-frontend/src/components/DebugState.vue
@@ -0,0 +1,38 @@
+
+
+
+ Debug State
+ {{ stateDump }}
+
+
+
+
+
+
+
diff --git a/src/gui-frontend/src/components/LiftGammaGainWheels.vue b/src/gui-frontend/src/components/LiftGammaGainWheels.vue
index 8aaf691..288e346 100644
--- a/src/gui-frontend/src/components/LiftGammaGainWheels.vue
+++ b/src/gui-frontend/src/components/LiftGammaGainWheels.vue
@@ -89,54 +89,92 @@
diff --git a/src/gui-frontend/src/components/common/ColorWheel.vue b/src/gui-frontend/src/components/common/ColorWheel.vue
index dc8864e..83075b9 100644
--- a/src/gui-frontend/src/components/common/ColorWheel.vue
+++ b/src/gui-frontend/src/components/common/ColorWheel.vue
@@ -29,6 +29,7 @@ const emit = defineEmits<{
const canvasRef = ref(null)
const dragging = ref(false)
+const lastHex = ref(props.modelValue)
const center = computed(() => ({ x: props.size / 2, y: props.size / 2 }))
const radius = computed(() => props.size / 2)
@@ -145,7 +146,7 @@ function setFromPoint(px: number, py: number) {
const hex = rgbToHex(r, g, b)
currentPoint.value = { x: cx + Math.cos(angle) * clampedDist, y: cy + Math.sin(angle) * clampedDist }
emit('update:modelValue', hex)
- emit('change', hex)
+ lastHex.value = hex
emit('vector-change', { hex, sat })
}
@@ -171,6 +172,8 @@ function onPointerUp(e?: PointerEvent) {
if (el && 'releasePointerCapture' in el && e && typeof e.pointerId === 'number') {
try { (el as HTMLElement).releasePointerCapture(e.pointerId) } catch {}
}
+ // Commit event on release
+ emit('change', lastHex.value)
}
function updateThumbFromColor() {
@@ -195,6 +198,7 @@ watch(() => props.size, () => {
watch(() => props.modelValue, () => {
updateThumbFromColor()
+ lastHex.value = props.modelValue
})
// markerSat is used only for appearance; it should not affect thumb position
diff --git a/src/gui-frontend/src/composables/useColorGrading.ts b/src/gui-frontend/src/composables/useColorGrading.ts
index 887b567..883db7d 100644
--- a/src/gui-frontend/src/composables/useColorGrading.ts
+++ b/src/gui-frontend/src/composables/useColorGrading.ts
@@ -1,9 +1,12 @@
-import { ref, computed } from 'vue'
+import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useVideoOperations } from './useVideoOperations'
import { useMarkupOperations } from './useMarkupOperations'
+import type { ColorGradingState } from '@/types/colorGrading'
import type { ClipInfo } from '@/types/api'
+import { buildFilterFromState } from '@/utils/colorFilterBuild'
import { UI_MESSAGES } from '@/constants'
+import { useClipperStore } from '@/stores/counter'
/**
* Composable for color grading functionality including active clip management
@@ -13,8 +16,12 @@ export function useColorGrading(
videoOps?: ReturnType,
markupOps?: ReturnType
) {
- // State
- const activeColorGradingClip = ref(null)
+ const clipperStore = useClipperStore()
+ // Must expose the numeric index, not the ref object itself, or downstream typeof checks fail
+ const activeColorGradingClip = computed({
+ get: () => clipperStore.activeColorGradingClip,
+ set: (v: number | null) => clipperStore.setActiveColorGradingClip(v)
+ })
// Use passed composables or create new instances (for backward compatibility)
const { videoDuration, findFirstValidClip } = videoOps || useVideoOperations()
@@ -40,7 +47,7 @@ export function useColorGrading(
return
}
- activeColorGradingClip.value = clipIndex
+ activeColorGradingClip.value = clipIndex
if (clipIndex !== null) {
ElMessage.info(UI_MESSAGES.CLIP_SWITCHED(clipIndex))
@@ -61,8 +68,9 @@ export function useColorGrading(
/**
* Handle color grading changes for the active clip
*/
- function handleColorGradingChanged(clipNumber: number, filter: string) {
- const success = updateClipColorGrading(clipNumber, filter)
+ function handleColorGradingChanged(clipNumber: number, filter: string, state?: ColorGradingState) {
+ const effectiveFilter = (!filter || !filter.length) && state ? buildFilterFromState(state) : filter
+ const success = updateClipColorGrading(clipNumber, effectiveFilter, state)
if (!success) {
ElMessage.error('Failed to apply color grading changes')
}
@@ -73,20 +81,20 @@ export function useColorGrading(
*/
function initializeActiveClip(clips: ClipInfo[]) {
if (clips.length === 0) {
- activeColorGradingClip.value = null
+ activeColorGradingClip.value = null
return
}
// Set the first valid clip as active for color grading
const firstValidIndex = findFirstValidClip(clips, videoDuration.value)
- activeColorGradingClip.value = firstValidIndex
+ activeColorGradingClip.value = firstValidIndex
}
/**
* Reset color grading state
*/
function resetColorGradingState() {
- activeColorGradingClip.value = null
+ activeColorGradingClip.value = null
}
/**
@@ -128,13 +136,13 @@ export function useColorGrading(
/**
* Apply color grading filter to all clips
*/
- function handleCopyToAllClips(filter: string): boolean {
+ function handleCopyToAllClips(state: ColorGradingState): boolean {
if (isMockMarkup.value) {
ElMessage.warning('Mock markup only has one clip')
return false
}
-
- return applyColorGradingToAllClips(filter)
+ const filter = buildFilterFromState(state)
+ return applyColorGradingToAllClips(filter, state)
}
/**
* Move to previous clip for color grading
diff --git a/src/gui-frontend/src/composables/useFramePreview.ts b/src/gui-frontend/src/composables/useFramePreview.ts
new file mode 100644
index 0000000..4faf859
--- /dev/null
+++ b/src/gui-frontend/src/composables/useFramePreview.ts
@@ -0,0 +1,207 @@
+import { ref, computed, watch } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useClipperStore } from '@/stores/counter'
+import { debounce } from '@/utils/debounce'
+
+export interface UseFramePreviewOptions {
+ filter?: string | (() => string | null | undefined) | null
+ previewEnabled?: boolean | (() => boolean | undefined)
+ markupVideoUrl?: string | (() => string | null | undefined) | null
+ resolution?: number
+}
+
+function resolveMaybeFn(v: T | (() => T)): T {
+ return typeof v === 'function' ? (v as () => T)() : v
+}
+
+export function useFramePreview(opts: UseFramePreviewOptions = {}) {
+ const store = useClipperStore()
+ const { activeSelectedClip, selectedFiles, videoDuration: storeVideoDuration } = storeToRefs(store)
+
+ // Base reactive state
+ const previewResolution = ref(opts.resolution ?? 0.5)
+ const previewTimestamp = ref(0)
+ const effectiveTimestamp = ref(0)
+ const isGeneratingPreview = ref(false)
+ const previewImageUrl = ref('')
+ const previewError = ref('')
+ const remoteResolveError = ref('')
+ const refreshNonce = ref(0)
+
+ // Authoritative clip + video path + duration
+ const clip = computed(() => activeSelectedClip.value)
+ const localVideoPath = computed(() => selectedFiles.value.video || null)
+ const duration = computed(() => storeVideoDuration.value || null)
+
+ // Remote URL handling (explicit enable + resolution)
+ const remotePreviewEnabled = ref(false)
+ const remoteResolvedVideoUrl = ref(null)
+ const isResolvingRemote = ref(false)
+ const markupUrl = computed(() => {
+ const raw = opts.markupVideoUrl
+ if (!raw) return null
+ return resolveMaybeFn(raw) || null
+ })
+ function enableRemotePreview() { if (markupUrl.value) remotePreviewEnabled.value = true }
+ async function resolveRemoteVideoUrl(): Promise {
+ remoteResolveError.value = ''
+ if (!markupUrl.value) { remoteResolveError.value = 'No markup URL available'; return false }
+ if (!remotePreviewEnabled.value) enableRemotePreview()
+ if (remoteResolvedVideoUrl.value) return true
+ try {
+ isResolvingRemote.value = true
+ const res = await window.pywebview?.api?.get_direct_video_url?.(markupUrl.value)
+ if (!res || res.status !== 'success' || !res.url) { remoteResolveError.value = res?.message || 'Failed to resolve direct URL'; return false }
+ if (!/^https?:\/\//i.test(res.url)) { remoteResolveError.value = 'Resolved URL not http(s)'; return false }
+ remoteResolvedVideoUrl.value = res.url
+ refreshNonce.value++
+ return true
+ } catch (e) {
+ remoteResolveError.value = `Resolve error: ${String(e)}`
+ return false
+ } finally {
+ isResolvingRemote.value = false
+ }
+ }
+
+ const effectiveVideoPath = computed(() => {
+ if (localVideoPath.value) return localVideoPath.value
+ if (remotePreviewEnabled.value && remoteResolvedVideoUrl.value) return remoteResolvedVideoUrl.value
+ return null
+ })
+
+ // Preview enabled indicates whether to apply filter; when disabled we still generate base frame (original video)
+ const enabled = computed(() => {
+ if (opts.previewEnabled == null) return true
+ return !!resolveMaybeFn(opts.previewEnabled as boolean | (() => boolean | undefined))
+ })
+ const filter = computed(() => {
+ if (!opts.filter) return null
+ const raw = resolveMaybeFn(opts.filter as string | (() => string | null | undefined) | null)
+ return enabled.value ? raw : null
+ })
+
+ const hasTimeline = computed(() => !!clip.value || (duration.value != null && duration.value > 0))
+ const timestampRange = computed(() => {
+ if (clip.value) {
+ const c = clip.value
+ const vd = duration.value
+ if (vd && vd > 0) {
+ const start = Math.min(c.start, Math.max(0, vd - 0.1))
+ const end = Math.min(c.end, vd)
+ return { min: Math.max(0, start), max: end, start, end }
+ }
+ return { min: c.start, max: c.end, start: c.start, end: c.end }
+ }
+ const vd = duration.value
+ if (vd && vd > 0) return { min: 0, max: vd, start: 0, end: vd }
+ return { min: 0, max: 0, start: 0, end: 0 }
+ })
+
+ // Clamp timestamp when clip or duration changes
+ watch([clip, duration], () => {
+ const r = timestampRange.value
+ if (r.max <= r.min) { previewTimestamp.value = 0; return }
+ if (previewTimestamp.value < r.min || previewTimestamp.value > r.max) {
+ previewTimestamp.value = r.start + (r.end - r.start) / 2
+ }
+ }, { immediate: true })
+
+ // Debounce slider changes
+ const commitEffectiveTs = debounce(() => { effectiveTimestamp.value = Number(previewTimestamp.value.toFixed(3)) }, 200)
+ watch(() => previewTimestamp.value, () => { if (hasTimeline.value) commitEffectiveTs() })
+
+ // Reset both timestamps to clip midpoint on clip or duration change
+ watch([clip, duration], () => {
+ const r = timestampRange.value
+ if (r.max <= r.min) { previewTimestamp.value = 0; effectiveTimestamp.value = 0; previewImageUrl.value=''; return }
+ const mid = r.start + (r.end - r.start) / 2
+ previewTimestamp.value = mid
+ effectiveTimestamp.value = mid
+ refreshNonce.value++
+ }, { immediate: true })
+
+ // Parameter change retrigger
+ watch([effectiveVideoPath, filter, previewResolution], () => {
+ effectiveTimestamp.value = Number(previewTimestamp.value.toFixed(3))
+ refreshNonce.value++
+ })
+
+ // Generation key (debug / dedupe marker)
+ const generationKey = computed(() => {
+ const vs = effectiveVideoPath.value || ''
+ const c = clip.value
+ const clipPart = c ? `${c.number}:${c.start}:${c.end}` : 'global'
+ const ts = (effectiveTimestamp.value || previewTimestamp.value || 0).toFixed(3)
+ const filt = filter.value || ''
+ const res = previewResolution.value
+ const en = enabled.value ? '1' : '0'
+ return `${en}|${vs}|${clipPart}|${ts}|${filt}|${res}|n${refreshNonce.value}`
+ })
+
+ // Preview generation
+ watch(generationKey, async (k) => {
+ const vs = effectiveVideoPath.value
+ if (!vs) { previewImageUrl.value=''; previewError.value=''; return }
+ const rawTs = effectiveTimestamp.value || previewTimestamp.value || 0
+ const ts = Number(rawTs.toFixed(3))
+ isGeneratingPreview.value = true
+ previewError.value = ''
+ try {
+ const res = await window.pywebview?.api?.generate_frame_preview?.(vs, ts, filter.value || undefined, previewResolution.value, undefined)
+ if (generationKey.value !== k) return // stale
+ if (!res || res.status !== 'success' || !res.base64_image) {
+ previewError.value = res?.message || 'Failed to generate preview'
+ previewImageUrl.value = ''
+ return
+ }
+ previewImageUrl.value = `data:${res.mime_type || 'image/jpeg'};base64,${res.base64_image}`
+ } catch (e) {
+ previewError.value = `Preview error: ${String(e)}`
+ previewImageUrl.value = ''
+ } finally {
+ if (generationKey.value === k) isGeneratingPreview.value = false
+ }
+ }, { immediate: true })
+
+ function handleTimelineInput(v: number | number[]) {
+ const n = Array.isArray(v) ? (v[0] ?? 0) : v
+ previewTimestamp.value = n
+ }
+ function refresh(forceMidpoint = false) {
+ if (forceMidpoint) {
+ const r = timestampRange.value
+ if (r.max > r.min) {
+ const mid = r.start + (r.end - r.start) / 2
+ previewTimestamp.value = mid
+ effectiveTimestamp.value = mid
+ refreshNonce.value++
+ return
+ }
+ }
+ effectiveTimestamp.value = Number(previewTimestamp.value.toFixed(3))
+ refreshNonce.value++
+ }
+
+ return {
+ // State
+ previewResolution,
+ previewTimestamp,
+ isGeneratingPreview,
+ previewImageUrl,
+ previewError,
+ hasSomeTimeline: hasTimeline,
+ timestampRange,
+ // Controls
+ handleTimelineInput,
+ refresh,
+ enableRemotePreview,
+ resolveRemoteVideoUrl,
+ remotePreviewEnabled,
+ isResolvingRemote,
+ remoteResolveError,
+ remoteResolvedVideoUrl,
+ generationKey, // optional debug
+ }
+}
+
diff --git a/src/gui-frontend/src/composables/useMarkupOperations.ts b/src/gui-frontend/src/composables/useMarkupOperations.ts
index 4c1e4c0..90b3ba8 100644
--- a/src/gui-frontend/src/composables/useMarkupOperations.ts
+++ b/src/gui-frontend/src/composables/useMarkupOperations.ts
@@ -1,31 +1,42 @@
-import { ref, readonly, computed } from 'vue'
+import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useClipperStore } from '@/stores/counter'
import type { ClipInfo } from '@/types/api'
import type { MarkupData } from '@/utils/markup'
import { SUPPORTED_MARKUP_EXTENSIONS, MOCK_MARKUP_DEFAULTS } from '@/constants'
+import type { ColorGradingState } from '@/types/colorGrading'
+
+function cloneState(obj: T): T {
+ if (obj == null) return obj
+ try {
+ if (typeof structuredClone === 'function') {
+ return structuredClone(obj as unknown as T)
+ }
+ } catch { /* ignore */ }
+ return JSON.parse(JSON.stringify(obj)) as T
+}
/**
* Composable for markup-related operations including parsing, clip management,
* and markup data manipulation
*/
export function useMarkupOperations() {
- // State
- const parsedClips = ref([])
- const selectedClips = ref([])
- const parsedMarkupData = ref(null)
-
- // Store
+ // Central store (single source of truth)
const clipperStore = useClipperStore()
+ const parsedClips = computed(() => clipperStore.parsedClips)
+ const selectedClips = computed({
+ get: () => clipperStore.selectedClips,
+ set: (v: number[]) => clipperStore.setSelectedClips(v)
+ })
+ const parsedMarkupData = computed(() => clipperStore.parsedMarkupData)
// Computed properties
const isMockMarkup = computed(() => {
// Mock markup is identified by having exactly one clip that starts at 0
// and the markup data having the generic platform structure
- if (parsedClips.value.length !== 1) return false
-
- const clip = parsedClips.value[0]
- const markupData = parsedMarkupData.value
+ if (parsedClips.value.length !== 1) return false
+ const clip = parsedClips.value[0]
+ const markupData = parsedMarkupData.value as MarkupData | null
return (
clip.start === 0 &&
@@ -40,14 +51,11 @@ export function useMarkupOperations() {
*/
async function parseMarkupFile(filePath: string): Promise {
try {
- const result = await clipperStore.parseMarkupFile(filePath)
+ const result = await clipperStore.parseMarkupFile(filePath)
if (result.status === 'success' && result.clips) {
- parsedClips.value = result.clips
- selectedClips.value = Array.from({ length: result.clips.length }, (_, i) => i)
-
- // Load the complete markup structure to enable color grading modifications
- await loadFullMarkupData(filePath)
+ clipperStore.setParsedClips(result.clips)
+ await loadFullMarkupData(filePath) // loads markup data
ElMessage.success(`Loaded ${result.clips.length} clips from markup`)
return true
@@ -59,7 +67,7 @@ export function useMarkupOperations() {
ElMessage.error(`Failed to parse markup file: ${error}`)
// Reset state on failure
- resetMarkupState()
+ resetMarkupState()
return false
}
}
@@ -69,18 +77,18 @@ export function useMarkupOperations() {
*/
async function loadFullMarkupData(filePath: string): Promise {
try {
- const fullMarkupData = await window.pywebview.api.load_markup_file_data(filePath)
+ const fullMarkupData = await window.pywebview.api.load_markup_file_data(filePath)
if (fullMarkupData.status === 'success' && fullMarkupData.data) {
- parsedMarkupData.value = fullMarkupData.data
+ clipperStore.setParsedMarkupData(fullMarkupData.data as MarkupData)
} else {
- parsedMarkupData.value = null
+ clipperStore.setParsedMarkupData(null)
if (fullMarkupData.message) {
ElMessage.warning(`Color grading may not work: ${fullMarkupData.message}`)
}
}
- } catch {
- parsedMarkupData.value = null
+ } catch {
+ clipperStore.setParsedMarkupData(null)
ElMessage.warning('Color grading changes may not persist due to file loading error')
}
}
@@ -89,15 +97,14 @@ export function useMarkupOperations() {
* Set parsed markup data and clips from external source (e.g., mock markup)
*/
function setMarkupData(markupData: MarkupData, clips: ClipInfo[]) {
- parsedMarkupData.value = markupData
- parsedClips.value = clips
- selectedClips.value = Array.from({ length: clips.length }, (_, i) => i)
+ clipperStore.setParsedMarkupData(markupData)
+ clipperStore.setParsedClips(clips)
}
/**
* Apply color grading filter to all clips
*/
- function applyColorGradingToAllClips(filter: string): boolean {
+ function applyColorGradingToAllClips(filter: string, state?: ColorGradingState): boolean {
if (isMockMarkup.value) {
ElMessage.warning('Cannot apply to all clips in mock markup')
return false
@@ -105,19 +112,22 @@ export function useMarkupOperations() {
let appliedCount = 0
- parsedClips.value.forEach(clip => {
+ parsedClips.value.forEach(clip => {
clip.overrides = {
...clip.overrides,
- colorGrading: filter || undefined
+ colorGrading: filter || undefined,
+ colorGradingState: state ? cloneState(state) : (clip.overrides as { colorGradingState?: ColorGradingState } | undefined)?.colorGradingState
}
// Also update the markup data structure
- if (parsedMarkupData.value?.markerPairs && Array.isArray(parsedMarkupData.value.markerPairs)) {
- const markerPair = parsedMarkupData.value.markerPairs.find((mp: { number?: unknown }) => mp.number === clip.number)
+ const pm = parsedMarkupData.value as unknown as { markerPairs?: Array<{ number: number; overrides?: { colorGrading?: string; colorGradingState?: ColorGradingState } }> } | null
+ if (pm?.markerPairs && Array.isArray(pm.markerPairs)) {
+ const markerPair = pm.markerPairs.find((mp: { number?: unknown }) => mp.number === clip.number)
if (markerPair) {
markerPair.overrides = {
...markerPair.overrides,
- colorGrading: filter || undefined
+ colorGrading: filter || undefined,
+ colorGradingState: state ? cloneState(state) : markerPair.overrides?.colorGradingState
}
appliedCount++
}
@@ -132,23 +142,26 @@ export function useMarkupOperations() {
return false
}
}
- function updateClipColorGrading(clipNumber: number, filter: string): boolean {
+ function updateClipColorGrading(clipNumber: number, filter: string, state?: ColorGradingState): boolean {
// Find the clip and update its color grading
- const clip = parsedClips.value.find(c => c.number === clipNumber)
+ const clip = parsedClips.value.find(c => c.number === clipNumber)
if (clip) {
// Update the clip's color grading in the overrides
clip.overrides = {
...clip.overrides,
- colorGrading: filter || undefined
+ colorGrading: filter || undefined,
+ colorGradingState: state ? cloneState(state) : (clip.overrides as { colorGradingState?: ColorGradingState } | undefined)?.colorGradingState
}
// Also update the markup data structure to ensure backend receives changes
- if (parsedMarkupData.value?.markerPairs && Array.isArray(parsedMarkupData.value.markerPairs)) {
- const markerPair = parsedMarkupData.value.markerPairs.find((mp: { number?: unknown }) => mp.number === clipNumber)
+ const pm = parsedMarkupData.value as unknown as { markerPairs?: Array<{ number: number; overrides?: { colorGrading?: string; colorGradingState?: ColorGradingState } }> } | null
+ if (pm?.markerPairs && Array.isArray(pm.markerPairs)) {
+ const markerPair = pm.markerPairs.find((mp: { number?: unknown }) => mp.number === clipNumber)
if (markerPair) {
markerPair.overrides = {
...markerPair.overrides,
- colorGrading: filter || undefined
+ colorGrading: filter || undefined,
+ colorGradingState: state ? cloneState(state) : markerPair.overrides?.colorGradingState
}
}
}
@@ -166,14 +179,11 @@ export function useMarkupOperations() {
*/
function getActiveClip(activeIndex: number | null): ClipInfo | null {
if (!parsedClips.value.length) return null
-
- // Use the active color grading clip if set, otherwise use the first selected clip
let targetIndex = activeIndex
if (targetIndex === null || targetIndex === undefined) {
if (!selectedClips.value.length) return null
targetIndex = selectedClips.value[0]
}
-
return parsedClips.value[targetIndex] || null
}
@@ -190,49 +200,42 @@ export function useMarkupOperations() {
* Reset all markup-related state
*/
function resetMarkupState() {
- parsedClips.value = []
- selectedClips.value = []
- parsedMarkupData.value = null
+ clipperStore.resetMarkupState()
}
/**
* Toggle clip selection for processing
*/
function toggleClipSelection(clipIndex: number) {
- const currentIndex = selectedClips.value.indexOf(clipIndex)
- if (currentIndex > -1) {
- selectedClips.value.splice(currentIndex, 1)
- } else {
- selectedClips.value.push(clipIndex)
- }
+ clipperStore.toggleClipSelection(clipIndex)
}
/**
* Select all clips for processing
*/
function selectAllClips() {
- selectedClips.value = Array.from({ length: parsedClips.value.length }, (_, i) => i)
+ clipperStore.selectAllClips()
}
/**
* Deselect all clips
*/
function deselectAllClips() {
- selectedClips.value = []
+ clipperStore.deselectAllClips()
}
/**
* Check if we have valid markup data for processing
*/
function hasValidMarkup(): boolean {
- return parsedClips.value.length > 0 || parsedMarkupData.value !== null
+ return clipperStore.hasValidMarkup()
}
return {
// State
- parsedClips,
- selectedClips,
- parsedMarkupData: readonly(parsedMarkupData),
+ parsedClips,
+ selectedClips,
+ parsedMarkupData,
// Computed
isMockMarkup,
diff --git a/src/gui-frontend/src/stores/counter.ts b/src/gui-frontend/src/stores/counter.ts
index e481da6..9aa21d2 100644
--- a/src/gui-frontend/src/stores/counter.ts
+++ b/src/gui-frontend/src/stores/counter.ts
@@ -1,9 +1,11 @@
-import { ref, computed } from 'vue'
+import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
-import type { SelectedFiles, ProcessingResult, EngineStatus, ParseMarkupResult, JobStatus } from '@/types/api'
+import type { SelectedFiles, ProcessingResult, EngineStatus, ParseMarkupResult, JobStatus, ClipInfo } from '@/types/api'
+import type { ColorGradingState } from '@/types/colorGrading'
import { waitForPywebview } from '@/utils/api'
import { useSettingsStore } from './settings'
import { ElMessage } from 'element-plus'
+import type { MarkupData } from '@/utils/markup'
export const useClipperStore = defineStore('clipper', () => {
// State
@@ -19,10 +21,46 @@ export const useClipperStore = defineStore('clipper', () => {
const processingResult = ref(null)
const engineStatus = ref(null)
+ // New: video info (single source of truth for loaded video metadata)
+ const videoInfo = ref(null)
+
+ // Markup / clips state (single source of truth for UI)
+ const parsedClips = ref([])
+ const selectedClips = ref([])
+ const parsedMarkupData = ref(null)
+ const activeColorGradingClip = ref(null)
+ // Key to force remount of preview / grading panel when selected files change in any way
+ const previewMountKey = ref(0)
+
+ // Derived clip state
+ const hasClips = computed(() => parsedClips.value.length > 0)
+ const activeSelectedClip = computed(() => {
+ if (!parsedClips.value.length) return null
+ if (activeColorGradingClip.value !== null && activeColorGradingClip.value >= 0 && activeColorGradingClip.value < parsedClips.value.length) {
+ return parsedClips.value[activeColorGradingClip.value]
+ }
+ if (selectedClips.value.length) {
+ const idx = selectedClips.value[0]
+ return parsedClips.value[idx] || null
+ }
+ return parsedClips.value[0]
+ })
+ // Alias for preview usage (semantic clarity)
+ const currentPreviewClip = activeSelectedClip
+
// Getters
const hasMarkupFile = computed(() => !!selectedFiles.value.markup)
const hasVideoFile = computed(() => !!selectedFiles.value.video)
const canProcess = computed(() => hasMarkupFile.value && !isProcessing.value && !isCanceling.value)
+ const videoDuration = computed(() => videoInfo.value?.duration ?? null)
// Actions
function setSelectedFiles(files: SelectedFiles) {
@@ -37,11 +75,16 @@ export const useClipperStore = defineStore('clipper', () => {
selectedFiles.value.video = path
}
+ function setVideoInfo(info: typeof videoInfo.value) {
+ videoInfo.value = info
+ }
+
+ function clearVideoInfo() {
+ videoInfo.value = null
+ }
+
function clearSelectedFiles() {
- selectedFiles.value = {
- markup: null,
- video: null
- }
+ selectedFiles.value = { markup: null, video: null }
}
async function startProcessing(selectedClips?: number[], markupData?: Record): Promise {
@@ -198,6 +241,113 @@ export const useClipperStore = defineStore('clipper', () => {
}
}
+ // ----- Clip / Markup Actions -----
+ function setParsedClips(clips: ClipInfo[]) {
+ parsedClips.value = [...clips]
+ selectedClips.value = clips.map((_, i) => i)
+ activeColorGradingClip.value = clips.length ? 0 : null
+ }
+
+ function setSelectedClips(indices: number[]) {
+ selectedClips.value = [...indices]
+ }
+
+ function toggleClipSelection(index: number) {
+ const pos = selectedClips.value.indexOf(index)
+ if (pos > -1) selectedClips.value.splice(pos, 1)
+ else selectedClips.value.push(index)
+ }
+
+ function selectAllClips() {
+ selectedClips.value = Array.from({ length: parsedClips.value.length }, (_, i) => i)
+ }
+
+ function deselectAllClips() {
+ selectedClips.value = []
+ }
+
+ function setParsedMarkupData(data: MarkupData | null) {
+ parsedMarkupData.value = data
+ }
+
+ function resetMarkupState() {
+ parsedClips.value = []
+ selectedClips.value = []
+ parsedMarkupData.value = null
+ activeColorGradingClip.value = null
+ }
+
+ // ---------- Color Grading Override Utilities ----------
+ interface GradingOverrides { colorGrading?: string; colorGradingState?: ColorGradingState }
+ function cloneColorGradingState(state: ColorGradingState): ColorGradingState {
+ try {
+ if (typeof structuredClone === 'function') return structuredClone(state)
+ } catch { /* ignore */ }
+ // Fallback
+ return JSON.parse(JSON.stringify(state)) as ColorGradingState
+ }
+ function stripGrading(o: Record | undefined): void {
+ if (!o) return
+ delete (o as GradingOverrides).colorGrading
+ delete (o as GradingOverrides).colorGradingState
+ }
+ function clearAllGrading(): void {
+ for (const clip of parsedClips.value) stripGrading(clip.overrides as Record)
+ const pm = parsedMarkupData.value as { markerPairs?: Array<{ overrides?: Record }> } | null
+ if (pm?.markerPairs && Array.isArray(pm.markerPairs)) {
+ for (const mp of pm.markerPairs) stripGrading(mp.overrides as Record | undefined)
+ }
+ }
+
+ watch(selectedFiles, (newVal) => {
+ if (!newVal.markup && !newVal.video) {
+ clearVideoInfo()
+ resetMarkupState()
+ previewMountKey.value++
+ return
+ }
+ if (!newVal.video) clearVideoInfo()
+ clearAllGrading()
+ activeColorGradingClip.value = parsedClips.value.length ? 0 : null
+ previewMountKey.value++
+ }, { deep: true })
+
+ function setActiveColorGradingClip(index: number | null) {
+ if (index === null) { activeColorGradingClip.value = null; return }
+ if (index < 0 || index >= parsedClips.value.length) return
+ activeColorGradingClip.value = index
+ }
+
+ function updateClipColorGrading(clipNumber: number, filterString: string, newState?: ColorGradingState): boolean {
+ const targetClip = parsedClips.value.find(c => c.number === clipNumber)
+ if (!targetClip) return false
+ const resolvedState: ColorGradingState | undefined = newState ? cloneColorGradingState(newState) : (targetClip.overrides as GradingOverrides)?.colorGradingState
+ targetClip.overrides = { ...targetClip.overrides, colorGrading: filterString || undefined, colorGradingState: resolvedState }
+
+ const markup = parsedMarkupData.value as { markerPairs?: Array<{ number: number; overrides?: GradingOverrides }> } | null
+ if (markup?.markerPairs) {
+ const markerPair = markup.markerPairs.find(m => m.number === clipNumber)
+ if (markerPair) {
+ const mirroredState: ColorGradingState | undefined = newState ? cloneColorGradingState(newState) : markerPair.overrides?.colorGradingState
+ markerPair.overrides = { ...markerPair.overrides, colorGrading: filterString || undefined, colorGradingState: mirroredState }
+ }
+ }
+ return true
+ }
+
+ function applyColorGradingToAllClips(filter: string, state?: ColorGradingState): number {
+ let count = 0
+ parsedClips.value.forEach(c => {
+ const changed = updateClipColorGrading(c.number, filter, state)
+ if (changed) count++
+ })
+ return count
+ }
+
+ function hasValidMarkup(): boolean {
+ return parsedClips.value.length > 0 || parsedMarkupData.value !== null
+ }
+
return {
// State
selectedFiles,
@@ -207,22 +357,46 @@ export const useClipperStore = defineStore('clipper', () => {
processingStatus,
processingResult,
engineStatus,
+ parsedClips,
+ selectedClips,
+ parsedMarkupData,
+ activeColorGradingClip,
+ videoInfo,
+ previewMountKey,
// Getters
hasMarkupFile,
hasVideoFile,
canProcess,
+ hasClips,
+ activeSelectedClip,
+ currentPreviewClip,
+ videoDuration,
// Actions
setSelectedFiles,
setMarkupFile,
setVideoFile,
clearSelectedFiles,
+ setVideoInfo,
+ clearVideoInfo,
startProcessing,
getEngineStatus,
selectFiles,
parseMarkupFile,
onProcessingEvent,
- cancelCurrentJob
+ cancelCurrentJob,
+ // Clip / markup actions
+ setParsedClips,
+ setSelectedClips,
+ toggleClipSelection,
+ selectAllClips,
+ deselectAllClips,
+ setParsedMarkupData,
+ resetMarkupState,
+ setActiveColorGradingClip,
+ updateClipColorGrading,
+ applyColorGradingToAllClips,
+ hasValidMarkup
}
})
diff --git a/src/gui-frontend/src/types/api.ts b/src/gui-frontend/src/types/api.ts
index 3d900b9..5329d8f 100644
--- a/src/gui-frontend/src/types/api.ts
+++ b/src/gui-frontend/src/types/api.ts
@@ -165,7 +165,8 @@ declare global {
cleanup_old_jobs: () => Promise<{ cleaned: number }>
// Frame preview
- generate_frame_preview: (videoPath: string, timestamp: number, colorGrading?: string, resolutionScale?: number) => Promise
+ generate_frame_preview: (videoPath: string, timestamp: number, colorGrading?: string, resolutionScale?: number, requestId?: string) => Promise
+ cancel_frame_preview: (requestId?: string) => Promise<{ status: string; message?: string }>
get_direct_video_url: (pageUrl: string) => Promise<{ status: 'success' | 'error'; url?: string; message?: string }>
// Video info
diff --git a/src/gui-frontend/src/types/colorGrading.ts b/src/gui-frontend/src/types/colorGrading.ts
new file mode 100644
index 0000000..ca64268
--- /dev/null
+++ b/src/gui-frontend/src/types/colorGrading.ts
@@ -0,0 +1,41 @@
+export interface BasicColorState {
+ brightness: number
+ contrast: number
+ saturation: number
+ hue: number
+ gamma: number
+}
+
+export interface LggWheelBandState {
+ hex: string
+ sat: number // radial saturation/strength 0..1
+ amount: number // neutral-centered slider 0..1 (0.5 = neutral)
+}
+
+export interface LggWheelState {
+ lift: LggWheelBandState
+ mid: LggWheelBandState
+ gain: LggWheelBandState
+ globalGamma: number
+}
+
+export interface ColorGradingState {
+ basic: BasicColorState
+ lgg: LggWheelState
+}
+
+export function createDefaultColorGradingState(): ColorGradingState {
+ return {
+ basic: { brightness: 0, contrast: 1, saturation: 1, hue: 0, gamma: 1 },
+ lgg: {
+ lift: { hex: '#ffffff', sat: 0, amount: 0.5 },
+ mid: { hex: '#ffffff', sat: 0, amount: 0.5 },
+ gain: { hex: '#ffffff', sat: 0, amount: 0.5 },
+ globalGamma: 1,
+ },
+ }
+}
+
+export function cloneColorGradingState(src: ColorGradingState): ColorGradingState {
+ return JSON.parse(JSON.stringify(src))
+}
diff --git a/src/gui-frontend/src/utils/colorFilterBuild.ts b/src/gui-frontend/src/utils/colorFilterBuild.ts
new file mode 100644
index 0000000..2e02ed4
--- /dev/null
+++ b/src/gui-frontend/src/utils/colorFilterBuild.ts
@@ -0,0 +1,22 @@
+import type { ColorGradingState } from '@/types/colorGrading'
+import { buildBasicFilter } from '@/utils/colorBasics'
+import { buildLggFilterFromWheels } from '@/utils/lgg'
+import { joinFilters } from '@/utils/filterString'
+
+export function buildFilterFromState(state: ColorGradingState): string {
+ const b = state.basic
+ const basic = buildBasicFilter({
+ brightness: b.brightness,
+ contrast: b.contrast,
+ saturation: b.saturation,
+ hue: b.hue,
+ gamma: b.gamma,
+ })
+ const w = state.lgg
+ const adv = buildLggFilterFromWheels({
+ lift: { hex: w.lift.hex, strength: w.lift.sat, neutral: w.lift.amount },
+ mid: { hex: w.mid.hex, strength: w.mid.sat, neutral: w.mid.amount },
+ gain: { hex: w.gain.hex, strength: w.gain.sat, neutral: w.gain.amount },
+ }, w.globalGamma)
+ return joinFilters(basic, adv)
+}
\ No newline at end of file
diff --git a/src/gui-frontend/src/utils/colorGradingBuffer.ts b/src/gui-frontend/src/utils/colorGradingBuffer.ts
new file mode 100644
index 0000000..eab66f2
--- /dev/null
+++ b/src/gui-frontend/src/utils/colorGradingBuffer.ts
@@ -0,0 +1,25 @@
+import type { ColorGradingState } from '@/types/colorGrading'
+import { ref } from 'vue'
+
+function cloneState(obj: T): T {
+ if (obj == null) return obj
+ try {
+ if (typeof structuredClone === 'function') return structuredClone(obj)
+ } catch { /* ignore */ }
+ return JSON.parse(JSON.stringify(obj))
+}
+
+// Reactive buffer so components can update enabled state (e.g., Paste button) across clip switches / remounts.
+const bufferRef = ref(null)
+
+export function setColorGradingBuffer(state: ColorGradingState) { bufferRef.value = cloneState(state) }
+
+export function getColorGradingBuffer(): ColorGradingState | null { return bufferRef.value ? cloneState(bufferRef.value) : null }
+
+export function hasColorGradingBuffer(): boolean {
+ return bufferRef.value !== null
+}
+
+export function clearColorGradingBuffer() { bufferRef.value = null }
+
+export function useColorGradingBufferReactive() { return bufferRef }
diff --git a/src/gui-frontend/src/views/ClipperView.vue b/src/gui-frontend/src/views/ClipperView.vue
index 007046e..35fa729 100644
--- a/src/gui-frontend/src/views/ClipperView.vue
+++ b/src/gui-frontend/src/views/ClipperView.vue
@@ -47,7 +47,6 @@
:clip-count="markupOps.parsedClips.value.length"
:selected-clips="markupOps.selectedClips.value"
:parsed-clips="markupOps.parsedClips.value"
- :active-color-grading-clip="colorGrading.activeColorGradingClip.value"
:video-duration="videoOps.videoDuration.value"
:video-info="videoOps.videoInfo.value"
:is-processing="fileHandler.isProcessing.value"
@@ -76,16 +75,18 @@
@import-settings="dialogManager.handleImportSettings"
@clear-settings-error="settingsStore.clearError"
/>
+