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>
<meta charset="UTF-8" />
<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.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" />

View File

@ -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<string>('get_backend_url').then(setBackendUrl).catch(console.error);
}
}, [setBackendUrl]);

View File

@ -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<HTMLCanvasElement>(null);
@ -17,7 +39,8 @@ export default function WaveformTimeline() {
const audioContextRef = useRef<AudioContext | 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);
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<HTMLCanvasElement>) => {
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() {
<span className="text-[10px] text-editor-text-muted font-medium uppercase tracking-wider">
Timeline
</span>
<div className="flex items-center gap-1">
<button
onClick={() => { zoomRef.current = Math.max(0.5, zoomRef.current - 0.5); drawStaticWaveform(); }}
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>
<span className="text-[10px] text-editor-text-muted">
Scroll · Ctrl+Scroll to zoom
</span>
</div>
{audioError ? (
<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}
className="absolute inset-0 w-full h-full cursor-crosshair"
onClick={handleClick}
onWheel={handleWheel}
/>
</div>
)}

View File

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