added close; fixed some issues

This commit is contained in:
2026-03-28 15:09:56 -06:00
parent 2ffc406b10
commit 246d816f84
8 changed files with 164 additions and 69 deletions

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:* ws://localhost:*; media-src 'self' file: blob: http://localhost:*;" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' ipc: http://ipc.localhost http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; media-src 'self' file: blob: http://localhost:* http://127.0.0.1:*; img-src 'self' data: blob:;" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />

View File

@ -1,4 +1,5 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useEditorStore } from './store/editorStore'; import { useEditorStore } from './store/editorStore';
import VideoPlayer from './components/VideoPlayer'; import VideoPlayer from './components/VideoPlayer';
import TranscriptEditor from './components/TranscriptEditor'; import TranscriptEditor from './components/TranscriptEditor';
@ -18,6 +19,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const IS_ELECTRON = !!window.electronAPI; const IS_ELECTRON = !!window.electronAPI;
const IS_TAURI = !IS_ELECTRON && '__TAURI_INTERNALS__' in window;
type Panel = 'ai' | 'settings' | 'export' | null; type Panel = 'ai' | 'settings' | 'export' | null;
@ -45,6 +47,8 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (IS_ELECTRON) { if (IS_ELECTRON) {
window.electronAPI!.getBackendUrl().then(setBackendUrl); window.electronAPI!.getBackendUrl().then(setBackendUrl);
} else if (IS_TAURI) {
invoke<string>('get_backend_url').then(setBackendUrl).catch(console.error);
} }
}, [setBackendUrl]); }, [setBackendUrl]);

View File

@ -1,6 +1,28 @@
import { useRef, useEffect, useCallback, useState } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import { useEditorStore } from '../store/editorStore'; 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() { export default function WaveformTimeline() {
const waveCanvasRef = useRef<HTMLCanvasElement>(null); const waveCanvasRef = useRef<HTMLCanvasElement>(null);
@ -17,7 +39,8 @@ export default function WaveformTimeline() {
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null); const audioBufferRef = useRef<AudioBuffer | null>(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); const rafRef = useRef(0);
useEffect(() => { useEffect(() => {
@ -27,7 +50,8 @@ export default function WaveformTimeline() {
const loadAudio = async () => { const loadAudio = async () => {
try { try {
const waveformUrl = `${backendUrl}/audio/waveform?path=${encodeURIComponent(videoPath!)}`; 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(); const ctx = new AudioContext();
audioContextRef.current = ctx; audioContextRef.current = ctx;
@ -81,7 +105,8 @@ export default function WaveformTimeline() {
drawStaticWaveform(); drawStaticWaveform();
} catch (err) { } catch (err) {
console.error('[WaveformTimeline] Waveform load failed:', 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 width = rect.width;
const height = rect.height; 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 channelData = buffer.getChannelData(0);
const samplesPerPixel = Math.floor(channelData.length / width);
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
for (const range of deletedRanges) { // --- Ruler background ---
const x1 = (range.start / buffer.duration) * width; ctx.fillStyle = '#13141f';
const x2 = (range.end / buffer.duration) * width; ctx.fillRect(0, 0, width, RULER_H);
ctx.fillStyle = 'rgba(239, 68, 68, 0.15)';
ctx.fillRect(x1, 0, x2 - x1, height); // 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.beginPath();
ctx.strokeStyle = '#4a4d5e'; ctx.strokeStyle = '#4a4d5e';
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let x = 0; x < width; x++) { for (let x = 0; x < width; x++) {
const start = x * samplesPerPixel; const tStart = scroll + x / pxPerSec;
const end = Math.min(start + samplesPerPixel, channelData.length); 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 min = 0, max = 0;
let max = 0; for (let i = sStart; i < sEnd; i++) {
for (let i = start; i < end; i++) {
if (channelData[i] < min) min = channelData[i]; if (channelData[i] < min) min = channelData[i];
if (channelData[i] > max) max = channelData[i]; if (channelData[i] > max) max = channelData[i];
} }
const yMin = mid + min * mid * 0.9; const amp = (waveH / 2) * 0.9;
const yMax = mid + max * mid * 0.9; ctx.moveTo(x, mid + min * amp);
ctx.moveTo(x, yMin); ctx.lineTo(x, mid + max * amp);
ctx.lineTo(x, yMax);
} }
ctx.stroke(); ctx.stroke();
}, [deletedRanges]); }, [deletedRanges]);
@ -177,7 +259,9 @@ export default function WaveformTimeline() {
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
if (dur > 0 && video) { if (dur > 0 && video) {
const px = (video.currentTime / dur) * width; const pxPerSec = (width * zoomRef.current) / dur;
const px = (video.currentTime - scrollSecsRef.current) * pxPerSec;
if (px >= 0 && px <= width) {
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = '#6366f1'; ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2; ctx.lineWidth = 2;
@ -185,6 +269,7 @@ export default function WaveformTimeline() {
ctx.lineTo(px, height); ctx.lineTo(px, height);
ctx.stroke(); ctx.stroke();
} }
}
rafRef.current = requestAnimationFrame(tick); rafRef.current = requestAnimationFrame(tick);
}; };
@ -201,17 +286,50 @@ export default function WaveformTimeline() {
return () => observer.disconnect(); return () => observer.disconnect();
}, [drawStaticWaveform]); }, [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( const handleClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => { (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!headCanvasRef.current || duration === 0) return; const buffer = audioBufferRef.current;
const rect = headCanvasRef.current.getBoundingClientRect(); const canvas = headCanvasRef.current;
const ratio = (e.clientX - rect.left) / rect.width; if (!canvas || !buffer) return;
const newTime = ratio * duration; 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); setCurrentTime(newTime);
const video = document.querySelector('video'); const video = document.querySelector('video');
if (video) video.currentTime = newTime; if (video) video.currentTime = newTime;
}, },
[duration, setCurrentTime], [setCurrentTime],
); );
if (!videoUrl) { if (!videoUrl) {
@ -228,22 +346,9 @@ export default function WaveformTimeline() {
<span className="text-[10px] text-editor-text-muted font-medium uppercase tracking-wider"> <span className="text-[10px] text-editor-text-muted font-medium uppercase tracking-wider">
Timeline Timeline
</span> </span>
<div className="flex items-center gap-1"> <span className="text-[10px] text-editor-text-muted">
<button Scroll · Ctrl+Scroll to zoom
onClick={() => { zoomRef.current = Math.max(0.5, zoomRef.current - 0.5); drawStaticWaveform(); }} </span>
className="p-0.5 text-editor-text-muted hover:text-editor-text"
title="Zoom out"
>
<ZoomOut className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { zoomRef.current = Math.min(10, zoomRef.current + 0.5); drawStaticWaveform(); }}
className="p-0.5 text-editor-text-muted hover:text-editor-text"
title="Zoom in"
>
<ZoomIn className="w-3.5 h-3.5" />
</button>
</div>
</div> </div>
{audioError ? ( {audioError ? (
<div className="flex-1 flex items-center justify-center gap-2 text-editor-text-muted text-xs"> <div className="flex-1 flex items-center justify-center gap-2 text-editor-text-muted text-xs">
@ -257,6 +362,7 @@ export default function WaveformTimeline() {
ref={headCanvasRef} ref={headCanvasRef}
className="absolute inset-0 w-full h-full cursor-crosshair" className="absolute inset-0 w-full h-full cursor-crosshair"
onClick={handleClick} onClick={handleClick}
onWheel={handleWheel}
/> />
</div> </div>
)} )}

View File

@ -63,7 +63,7 @@ const initialState: EditorState = {
transcriptionStatus: '', transcriptionStatus: '',
isExporting: false, isExporting: false,
exportProgress: 0, exportProgress: 0,
backendUrl: 'http://localhost:8642', backendUrl: 'http://127.0.0.1:8000',
}; };
let nextRangeId = 1; let nextRangeId = 1;

View File

@ -9,13 +9,10 @@ mod ai_provider;
mod caption_generator; mod caption_generator;
mod background_removal; mod background_removal;
/// Returns the backend URL. Stubbed for now; will be replaced once the /// Returns the backend URL.
/// Python/Rust backend is fully wired up.
#[tauri::command] #[tauri::command]
fn get_backend_url() -> String { fn get_backend_url() -> String {
// During development the Python backend still runs on 8642. "http://127.0.0.1:8000".to_string()
// In production this will be replaced with a local Rust server or IPC.
"http://localhost:8642".to_string()
} }
/// Minimal encrypt: base64-encodes the string as a placeholder until a proper /// Minimal encrypt: base64-encodes the string as a placeholder until a proper

View File

@ -45,18 +45,6 @@ pub fn python_exe() -> PathBuf {
root.join(".venv").join("bin").join("python3") 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. /// Absolute path to a script in the backend directory.
pub fn backend_script(name: &str) -> PathBuf { pub fn backend_script(name: &str) -> PathBuf {
project_root().join("backend").join(name) project_root().join("backend").join(name)

View File

@ -23,7 +23,7 @@
} }
], ],
"security": { "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": { "bundle": {

View File

@ -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. Test script for the TalkEdit API.
This script tests the new Tauri commands that expose all backend functions. This script tests the new Tauri commands that expose all backend functions.