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 @@ + + + + + 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" /> +