diff --git a/frontend/index.html b/frontend/index.html index 1f0d858..572b310 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0fffdb9..b6380fb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { useEditorStore } from './store/editorStore'; import VideoPlayer from './components/VideoPlayer'; import TranscriptEditor from './components/TranscriptEditor'; @@ -18,6 +19,7 @@ import { } from 'lucide-react'; const IS_ELECTRON = !!window.electronAPI; +const IS_TAURI = !IS_ELECTRON && '__TAURI_INTERNALS__' in window; type Panel = 'ai' | 'settings' | 'export' | null; @@ -45,6 +47,8 @@ export default function App() { useEffect(() => { if (IS_ELECTRON) { window.electronAPI!.getBackendUrl().then(setBackendUrl); + } else if (IS_TAURI) { + invoke('get_backend_url').then(setBackendUrl).catch(console.error); } }, [setBackendUrl]); diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx index 6572302..44a7d5f 100644 --- a/frontend/src/components/WaveformTimeline.tsx +++ b/frontend/src/components/WaveformTimeline.tsx @@ -1,6 +1,28 @@ import { useRef, useEffect, useCallback, useState } from 'react'; import { useEditorStore } from '../store/editorStore'; -import { ZoomIn, ZoomOut, AlertTriangle } from 'lucide-react'; +import { AlertTriangle } from 'lucide-react'; + +const RULER_H = 20; // px reserved at top of canvas for the time ruler + +function formatTime(secs: number): string { + const m = Math.floor(secs / 60); + const s = secs % 60; + if (m > 0) return `${m}:${String(Math.floor(s)).padStart(2, '0')}.${Math.floor((s % 1) * 10)}`; + return `${s.toFixed(1)}s`; +} + +function pickInterval(pxPerSec: number): { major: number; minor: number } { + const NICE = [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; + let major = NICE[NICE.length - 1]; + for (const n of NICE) { + if (n * pxPerSec >= 70) { major = n; break; } + } + let minor = major; + for (const n of NICE) { + if (n * pxPerSec >= 6 && n < major) { minor = n; } + } + return { major, minor }; +} export default function WaveformTimeline() { const waveCanvasRef = useRef(null); @@ -17,7 +39,8 @@ export default function WaveformTimeline() { const audioContextRef = useRef(null); const audioBufferRef = useRef(null); - const zoomRef = useRef(1); + const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in + const scrollSecsRef = useRef(0); // seconds scrolled from left const rafRef = useRef(0); useEffect(() => { @@ -27,7 +50,8 @@ export default function WaveformTimeline() { const loadAudio = async () => { try { const waveformUrl = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath!)}`; - console.log('[WaveformTimeline] Loading audio from waveform endpoint:', waveformUrl); + console.log('[WaveformTimeline] backendUrl:', backendUrl, '| videoPath:', videoPath); + console.log('[WaveformTimeline] Fetching:', waveformUrl); const ctx = new AudioContext(); audioContextRef.current = ctx; @@ -81,7 +105,8 @@ export default function WaveformTimeline() { drawStaticWaveform(); } catch (err) { console.error('[WaveformTimeline] Waveform load failed:', err); - setAudioError(`Waveform unavailable — ${err instanceof Error ? err.message : 'audio could not be decoded'}`); + const waveformUrl2 = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath ?? '')}`; + setAudioError(`Waveform unavailable — ${err instanceof Error ? err.message : 'audio could not be decoded'} [URL: ${waveformUrl2}]`); } }; @@ -108,38 +133,95 @@ export default function WaveformTimeline() { const width = rect.width; const height = rect.height; + const dur = buffer.duration; + const zoom = zoomRef.current; + const scroll = scrollSecsRef.current; + const pxPerSec = (width * zoom) / dur; + const sampleRate = buffer.sampleRate; const channelData = buffer.getChannelData(0); - const samplesPerPixel = Math.floor(channelData.length / width); ctx.clearRect(0, 0, width, height); - for (const range of deletedRanges) { - const x1 = (range.start / buffer.duration) * width; - const x2 = (range.end / buffer.duration) * width; - ctx.fillStyle = 'rgba(239, 68, 68, 0.15)'; - ctx.fillRect(x1, 0, x2 - x1, height); + // --- Ruler background --- + ctx.fillStyle = '#13141f'; + ctx.fillRect(0, 0, width, RULER_H); + + // Separator line + ctx.strokeStyle = '#2a2d3e'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, RULER_H); + ctx.lineTo(width, RULER_H); + ctx.stroke(); + + // --- Ruler ticks & labels --- + const { major, minor } = pickInterval(pxPerSec); + const visibleDur = width / pxPerSec; + + // Minor ticks + const minorStart = Math.floor(scroll / minor) * minor; + ctx.strokeStyle = '#3a3d52'; + ctx.lineWidth = 1; + for (let t = minorStart; t <= scroll + visibleDur + minor; t = Math.round((t + minor) * 1e6) / 1e6) { + const x = (t - scroll) * pxPerSec; + if (x < 0 || x > width) continue; + ctx.beginPath(); + ctx.moveTo(x, RULER_H); + ctx.lineTo(x, RULER_H * 0.45); + ctx.stroke(); } - const mid = height / 2; + // Major ticks + labels + const majorStart = Math.floor(scroll / major) * major; + ctx.lineWidth = 1; + ctx.font = `9px "JetBrains Mono", "Courier New", monospace`; + ctx.textBaseline = 'top'; + for (let t = majorStart; t <= scroll + visibleDur + major; t = Math.round((t + major) * 1e6) / 1e6) { + const x = (t - scroll) * pxPerSec; + if (x < -50 || x > width + 50) continue; + ctx.strokeStyle = '#4a4f6a'; + ctx.beginPath(); + ctx.moveTo(x, RULER_H); + ctx.lineTo(x, 0); + ctx.stroke(); + if (x >= 2 && x < width - 2) { + ctx.fillStyle = '#6b7280'; + ctx.fillText(formatTime(t), x + 3, 2); + } + } + + // --- Waveform --- + const waveTop = RULER_H + 1; + const waveH = height - waveTop; + + for (const range of deletedRanges) { + const x1 = (range.start - scroll) * pxPerSec; + const x2 = (range.end - scroll) * pxPerSec; + ctx.fillStyle = 'rgba(239, 68, 68, 0.15)'; + ctx.fillRect(x1, waveTop, x2 - x1, waveH); + } + + const mid = waveTop + waveH / 2; ctx.beginPath(); ctx.strokeStyle = '#4a4d5e'; ctx.lineWidth = 1; for (let x = 0; x < width; x++) { - const start = x * samplesPerPixel; - const end = Math.min(start + samplesPerPixel, channelData.length); + const tStart = scroll + x / pxPerSec; + const tEnd = scroll + (x + 1) / pxPerSec; + const sStart = Math.floor(tStart * sampleRate); + const sEnd = Math.min(Math.ceil(tEnd * sampleRate), channelData.length); + if (sStart >= channelData.length) break; - let min = 0; - let max = 0; - for (let i = start; i < end; i++) { + let min = 0, max = 0; + for (let i = sStart; i < sEnd; i++) { if (channelData[i] < min) min = channelData[i]; if (channelData[i] > max) max = channelData[i]; } - const yMin = mid + min * mid * 0.9; - const yMax = mid + max * mid * 0.9; - ctx.moveTo(x, yMin); - ctx.lineTo(x, yMax); + const amp = (waveH / 2) * 0.9; + ctx.moveTo(x, mid + min * amp); + ctx.lineTo(x, mid + max * amp); } ctx.stroke(); }, [deletedRanges]); @@ -177,13 +259,16 @@ export default function WaveformTimeline() { ctx.clearRect(0, 0, width, height); if (dur > 0 && video) { - const px = (video.currentTime / dur) * width; - ctx.beginPath(); - ctx.strokeStyle = '#6366f1'; - ctx.lineWidth = 2; - ctx.moveTo(px, 0); - ctx.lineTo(px, height); - ctx.stroke(); + const pxPerSec = (width * zoomRef.current) / dur; + const px = (video.currentTime - scrollSecsRef.current) * pxPerSec; + if (px >= 0 && px <= width) { + ctx.beginPath(); + ctx.strokeStyle = '#6366f1'; + ctx.lineWidth = 2; + ctx.moveTo(px, 0); + ctx.lineTo(px, height); + ctx.stroke(); + } } rafRef.current = requestAnimationFrame(tick); @@ -201,17 +286,50 @@ export default function WaveformTimeline() { return () => observer.disconnect(); }, [drawStaticWaveform]); + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const buffer = audioBufferRef.current; + const canvas = waveCanvasRef.current; + if (!buffer || !canvas) return; + const dur = buffer.duration; + const width = canvas.getBoundingClientRect().width; + + if (e.ctrlKey || e.metaKey) { + // Zoom around the cursor position + const mouseX = e.clientX - canvas.getBoundingClientRect().left; + const pxPerSecBefore = (width * zoomRef.current) / dur; + const timeCursor = scrollSecsRef.current + mouseX / pxPerSecBefore; + const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25; + zoomRef.current = Math.max(1, Math.min(100, zoomRef.current * factor)); + const pxPerSecAfter = (width * zoomRef.current) / dur; + scrollSecsRef.current = timeCursor - mouseX / pxPerSecAfter; + } else { + // Scroll horizontally + const pxPerSec = (width * zoomRef.current) / dur; + scrollSecsRef.current += (e.deltaY || e.deltaX) / pxPerSec * 1.5; + } + + // Clamp scroll + const pxPerSec = (width * zoomRef.current) / dur; + const maxScroll = Math.max(0, dur - width / pxPerSec); + scrollSecsRef.current = Math.max(0, Math.min(scrollSecsRef.current, maxScroll)); + drawStaticWaveform(); + }, [drawStaticWaveform]); + const handleClick = useCallback( (e: React.MouseEvent) => { - if (!headCanvasRef.current || duration === 0) return; - const rect = headCanvasRef.current.getBoundingClientRect(); - const ratio = (e.clientX - rect.left) / rect.width; - const newTime = ratio * duration; + const buffer = audioBufferRef.current; + const canvas = headCanvasRef.current; + if (!canvas || !buffer) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const pxPerSec = (rect.width * zoomRef.current) / buffer.duration; + const newTime = Math.max(0, Math.min(buffer.duration, scrollSecsRef.current + x / pxPerSec)); setCurrentTime(newTime); const video = document.querySelector('video'); if (video) video.currentTime = newTime; }, - [duration, setCurrentTime], + [setCurrentTime], ); if (!videoUrl) { @@ -228,22 +346,9 @@ export default function WaveformTimeline() { Timeline -
- - -
+ + Scroll · Ctrl+Scroll to zoom + {audioError ? (
@@ -257,6 +362,7 @@ export default function WaveformTimeline() { ref={headCanvasRef} className="absolute inset-0 w-full h-full cursor-crosshair" onClick={handleClick} + onWheel={handleWheel} />
)} diff --git a/frontend/src/store/editorStore.ts b/frontend/src/store/editorStore.ts index ffb7bb4..3fba00c 100644 --- a/frontend/src/store/editorStore.ts +++ b/frontend/src/store/editorStore.ts @@ -63,7 +63,7 @@ const initialState: EditorState = { transcriptionStatus: '', isExporting: false, exportProgress: 0, - backendUrl: 'http://localhost:8642', + backendUrl: 'http://127.0.0.1:8000', }; let nextRangeId = 1; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7a9a20..537e0e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,13 +9,10 @@ mod ai_provider; mod caption_generator; mod background_removal; -/// Returns the backend URL. Stubbed for now; will be replaced once the -/// Python/Rust backend is fully wired up. +/// Returns the backend URL. #[tauri::command] fn get_backend_url() -> String { - // During development the Python backend still runs on 8642. - // In production this will be replaced with a local Rust server or IPC. - "http://localhost:8642".to_string() + "http://127.0.0.1:8000".to_string() } /// Minimal encrypt: base64-encodes the string as a placeholder until a proper diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index e47f73e..fc3df6d 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -45,18 +45,6 @@ pub fn python_exe() -> PathBuf { root.join(".venv").join("bin").join("python3") } -/// Absolute path to the bundled ffmpeg binary. -/// Uses a sidecar in resources/bin/ when packaged, otherwise expects it on PATH. -pub fn ffmpeg_exe() -> PathBuf { - let root = project_root(); - let bundled = root.join("bin").join("ffmpeg"); - if bundled.exists() { - return bundled; - } - // Fallback to system ffmpeg during development - PathBuf::from("ffmpeg") -} - /// Absolute path to a script in the backend directory. pub fn backend_script(name: &str) -> PathBuf { project_root().join("backend").join(name) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8059f74..a34ddd1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:8000; media-src 'self' http://127.0.0.1:8000; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:" } }, "bundle": { diff --git a/test_api.py b/test_api.py index b2c0622..0553c6f 100755 --- a/test_api.py +++ b/test_api.py @@ -1,4 +1,4 @@ -#!/home/dillon/_code/TalkEdit/.venv/bin/python3.10 +#!/home/dillon/_code/TalkEdit/.venv312/bin/python3.12 """ Test script for the TalkEdit API. This script tests the new Tauri commands that expose all backend functions.