mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-02 00:25:15 -07:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
229841e05e | ||
|
|
123e8215e4 | ||
|
|
2a3afec2ca | ||
|
|
99ddd5a0b4 | ||
|
|
8d730621bc | ||
|
|
e23118f610 | ||
|
|
d4bfdc0d68 | ||
|
|
116c108906 | ||
|
|
2d23c8e06a | ||
|
|
3973a59ba3 | ||
|
|
d9aa75253a | ||
|
|
b22bf36565 | ||
|
|
33f4ed9b44 | ||
|
|
cc37e04221 | ||
|
|
2b4fbe5173 | ||
|
|
ea943876dc | ||
|
|
b55d8cc567 | ||
|
|
036d90dc8e | ||
|
|
27ae6dfbab |
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.1.6
|
current_version = 0.1.8
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
tag_name = v{new_version}
|
||||||
@@ -34,6 +34,6 @@ replace = "version": "{new_version}"
|
|||||||
search = "version": "{current_version}"
|
search = "version": "{current_version}"
|
||||||
replace = "version": "{new_version}"
|
replace = "version": "{new_version}"
|
||||||
|
|
||||||
[bumpversion:file:backend/main.py]
|
[bumpversion:file:backend/__init__.py]
|
||||||
search = "version": "{current_version}"
|
search = __version__ = "{current_version}"
|
||||||
replace = "version": "{new_version}"
|
replace = __version__ = "{new_version}"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://voicebox.sh">
|
<a href="https://voicebox.sh">
|
||||||
<img src=".github/assets/screenshot.webp" alt="Voicebox App Screenshot" width="800" />
|
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -32,17 +32,29 @@
|
|||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
## Why Voicebox?
|
<p align="center">
|
||||||
|
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
Voice AI is exploding, but most tools are either cloud-locked, expensive, or a nightmare to set up. Voicebox is different:
|
<p align="center">
|
||||||
|
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
- **100% Local** — Your voice data never leaves your machine
|
<br/>
|
||||||
- **Lightweight** — No bloated Electron, native Tauri performance
|
|
||||||
- **Fast** — Near-instant on CUDA, optimized for Apple Silicon
|
|
||||||
- **Flexible** — Use the app, integrate the API, or both
|
|
||||||
- **Open Source** — No subscriptions, no limits, no lock-in
|
|
||||||
|
|
||||||
Built with **Tauri** (Rust), **TypeScript**, **React**, and **Python**. Native performance meets modern DX.
|
## What is Voicebox?
|
||||||
|
|
||||||
|
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
||||||
|
|
||||||
|
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
||||||
|
|
||||||
|
- **Complete privacy** — models and voice data stay on your machine
|
||||||
|
- **Professional tools** — multi-track timeline editor, audio trimming, conversation mixing
|
||||||
|
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
||||||
|
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
||||||
|
- **Native performance** — built with Tauri (Rust), not Electron
|
||||||
|
|
||||||
|
Download a voice model, clone any voice from a few seconds of audio, and compose multi-voice projects with studio-grade editing tools. No Python install required, no cloud dependency, no limits.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,6 +87,7 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
|||||||
|
|
||||||
- **Create profiles** from audio files or record directly in-app
|
- **Create profiles** from audio files or record directly in-app
|
||||||
- **Import/Export** profiles to share or backup
|
- **Import/Export** profiles to share or backup
|
||||||
|
- **Multi-sample support** — combine multiple samples for higher quality cloning
|
||||||
- **Organize** with descriptions and language tags
|
- **Organize** with descriptions and language tags
|
||||||
|
|
||||||
### Speech Generation
|
### Speech Generation
|
||||||
@@ -83,9 +96,19 @@ Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-p
|
|||||||
- **Batch generation** for long-form content
|
- **Batch generation** for long-form content
|
||||||
- **Smart caching** — regenerate instantly with voice prompt caching
|
- **Smart caching** — regenerate instantly with voice prompt caching
|
||||||
|
|
||||||
|
### Stories Editor
|
||||||
|
|
||||||
|
Create multi-voice narratives, podcasts, and conversations with a timeline-based editor.
|
||||||
|
|
||||||
|
- **Multi-track composition** — arrange multiple voice tracks in a single project
|
||||||
|
- **Inline audio editing** — trim and split clips directly in the timeline
|
||||||
|
- **Auto-playback** — preview stories with synchronized playhead
|
||||||
|
- **Voice mixing** — build conversations with multiple participants
|
||||||
|
|
||||||
### Recording & Transcription
|
### Recording & Transcription
|
||||||
|
|
||||||
- **In-app recording** with waveform visualization
|
- **In-app recording** with waveform visualization
|
||||||
|
- **System audio capture** — record desktop audio on macOS and Windows
|
||||||
- **Automatic transcription** powered by Whisper
|
- **Automatic transcription** powered by Whisper
|
||||||
- **Export recordings** in multiple formats
|
- **Export recordings** in multiple formats
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.6",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -357,14 +357,22 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard WaveSurfer auto-play
|
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
|
||||||
// Use a small delay to ensure audio element is fully ready
|
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
|
||||||
setTimeout(() => {
|
if (shouldAutoPlayNow) {
|
||||||
wavesurfer.play().catch((error) => {
|
// Clear the flag first
|
||||||
debug.error('Failed to autoplay:', error);
|
usePlayerStore.getState().clearAutoPlayFlag();
|
||||||
// Don't show error for autoplay failures (browser restrictions)
|
|
||||||
});
|
// Use a small delay to ensure audio element is fully ready
|
||||||
}, 100);
|
setTimeout(() => {
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to autoplay:', error);
|
||||||
|
// Don't show error for autoplay failures (browser restrictions)
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
debug.log('Skipping auto-play - shouldAutoPlay is false');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle play/pause
|
// Handle play/pause
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function HistoryTable() {
|
|||||||
const exportGeneration = useExportGeneration();
|
const exportGeneration = useExportGeneration();
|
||||||
const exportGenerationAudio = useExportGenerationAudio();
|
const exportGenerationAudio = useExportGenerationAudio();
|
||||||
const importGeneration = useImportGeneration();
|
const importGeneration = useImportGeneration();
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
||||||
@@ -77,9 +77,9 @@ export function HistoryTable() {
|
|||||||
if (currentAudioId === audioId) {
|
if (currentAudioId === audioId) {
|
||||||
restartCurrentAudio();
|
restartCurrentAudio();
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, load the new audio
|
// Otherwise, load the new audio and auto-play it
|
||||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
const audioUrl = apiClient.getAudioUrl(audioId);
|
||||||
setAudio(audioUrl, audioId, profileId, text.substring(0, 50));
|
setAudioWithAutoPlay(audioUrl, audioId, profileId, text.substring(0, 50));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,7 +233,11 @@ export function HistoryTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Far right - Ellipsis actions */}
|
{/* Far right - Ellipsis actions */}
|
||||||
<div className="w-10 shrink-0 flex justify-end">
|
<div
|
||||||
|
className="w-10 shrink-0 flex justify-end"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -241,7 +245,6 @@ export function HistoryTable() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
aria-label="Actions"
|
aria-label="Actions"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -131,13 +131,13 @@ export function StoryContent() {
|
|||||||
}
|
}
|
||||||
}, [isPlaying]);
|
}, [isPlaying]);
|
||||||
|
|
||||||
const handleRemoveItem = (generationId: string) => {
|
const handleRemoveItem = (itemId: string) => {
|
||||||
if (!story) return;
|
if (!story) return;
|
||||||
|
|
||||||
removeItem.mutate(
|
removeItem.mutate(
|
||||||
{
|
{
|
||||||
storyId: story.id,
|
storyId: story.id,
|
||||||
generationId,
|
itemId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -360,7 +360,7 @@ export function StoryContent() {
|
|||||||
item={item}
|
item={item}
|
||||||
storyId={story.id}
|
storyId={story.id}
|
||||||
index={index}
|
index={index}
|
||||||
onRemove={() => handleRemoveItem(item.generation_id)}
|
onRemove={() => handleRemoveItem(item.id)}
|
||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
isPlaying={isPlaying && playbackStoryId === story.id}
|
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,21 +1,59 @@
|
|||||||
import { GripHorizontal, Minus, Pause, Play, Plus, Square } from 'lucide-react';
|
import {
|
||||||
|
Copy,
|
||||||
|
GripHorizontal,
|
||||||
|
Minus,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Scissors,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useMoveStoryItem } from '@/lib/hooks/useStories';
|
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
import {
|
||||||
|
useDuplicateStoryItem,
|
||||||
|
useMoveStoryItem,
|
||||||
|
useRemoveStoryItem,
|
||||||
|
useSplitStoryItem,
|
||||||
|
useTrimStoryItem,
|
||||||
|
} from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
// Clip waveform component
|
// Clip waveform component with trim support
|
||||||
function ClipWaveform({ generationId, width }: { generationId: string; width: number }) {
|
function ClipWaveform({
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
generationId,
|
||||||
|
width,
|
||||||
|
trimStartMs,
|
||||||
|
trimEndMs,
|
||||||
|
duration,
|
||||||
|
}: {
|
||||||
|
generationId: string;
|
||||||
|
width: number;
|
||||||
|
trimStartMs: number;
|
||||||
|
trimEndMs: number;
|
||||||
|
duration: number;
|
||||||
|
}) {
|
||||||
|
const waveformRef = useRef<HTMLDivElement>(null);
|
||||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||||
|
|
||||||
|
// Calculate the full waveform width based on the original duration
|
||||||
|
// The visible portion (width) represents the effective duration after trimming
|
||||||
|
const effectiveDurationMs = duration * 1000 - trimStartMs - trimEndMs;
|
||||||
|
const fullWaveformWidth =
|
||||||
|
effectiveDurationMs > 0 ? (width / effectiveDurationMs) * (duration * 1000) : width;
|
||||||
|
|
||||||
|
// Calculate how much to offset the waveform to hide the trimmed start
|
||||||
|
const offsetX =
|
||||||
|
effectiveDurationMs > 0 ? (trimStartMs / (duration * 1000)) * fullWaveformWidth : 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || width < 20) return;
|
if (!waveformRef.current || fullWaveformWidth < 20) return;
|
||||||
|
|
||||||
// Get CSS colors
|
// Get CSS colors
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -27,7 +65,7 @@ function ClipWaveform({ generationId, width }: { generationId: string; width: nu
|
|||||||
const waveColor = getCSSVar('--accent-foreground');
|
const waveColor = getCSSVar('--accent-foreground');
|
||||||
|
|
||||||
const wavesurfer = WaveSurfer.create({
|
const wavesurfer = WaveSurfer.create({
|
||||||
container: containerRef.current,
|
container: waveformRef.current,
|
||||||
waveColor,
|
waveColor,
|
||||||
progressColor: waveColor,
|
progressColor: waveColor,
|
||||||
cursorWidth: 0,
|
cursorWidth: 0,
|
||||||
@@ -50,9 +88,21 @@ function ClipWaveform({ generationId, width }: { generationId: string; width: nu
|
|||||||
wavesurfer.destroy();
|
wavesurfer.destroy();
|
||||||
wavesurferRef.current = null;
|
wavesurferRef.current = null;
|
||||||
};
|
};
|
||||||
}, [generationId, width]);
|
}, [generationId, fullWaveformWidth]);
|
||||||
|
|
||||||
return <div ref={containerRef} className="w-full h-full opacity-60" />;
|
return (
|
||||||
|
<div className="w-full h-full opacity-60 overflow-hidden">
|
||||||
|
{/* Inner container that holds the full waveform, offset to show only visible portion */}
|
||||||
|
<div
|
||||||
|
ref={waveformRef}
|
||||||
|
style={{
|
||||||
|
width: `${fullWaveformWidth}px`,
|
||||||
|
transform: `translateX(-${offsetX}px)`,
|
||||||
|
}}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StoryTrackEditorProps {
|
interface StoryTrackEditorProps {
|
||||||
@@ -80,8 +130,25 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const resizeStartY = useRef(0);
|
const resizeStartY = useRef(0);
|
||||||
const resizeStartHeight = useRef(0);
|
const resizeStartHeight = useRef(0);
|
||||||
const moveItem = useMoveStoryItem();
|
const moveItem = useMoveStoryItem();
|
||||||
|
const trimItem = useTrimStoryItem();
|
||||||
|
const splitItem = useSplitStoryItem();
|
||||||
|
const duplicateItem = useDuplicateStoryItem();
|
||||||
|
const removeItem = useRemoveStoryItem();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||||
|
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
||||||
|
|
||||||
|
// Trim state
|
||||||
|
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
||||||
|
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
||||||
|
const [trimStartX, setTrimStartX] = useState(0);
|
||||||
|
const [tempTrimValues, setTempTrimValues] = useState<{
|
||||||
|
trim_start_ms: number;
|
||||||
|
trim_end_ms: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Track editor height from store (shared with FloatingGenerateBox)
|
// Track editor height from store (shared with FloatingGenerateBox)
|
||||||
const editorHeight = useStoryStore((state) => state.trackEditorHeight);
|
const editorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
|
const setEditorHeight = useStoryStore((state) => state.setTrackEditorHeight);
|
||||||
@@ -89,16 +156,32 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
// Playback state
|
// Playback state
|
||||||
const isPlaying = useStoryStore((state) => state.isPlaying);
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||||
const storeTotalDurationMs = useStoryStore((state) => state.totalDurationMs);
|
|
||||||
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||||
const play = useStoryStore((state) => state.play);
|
const play = useStoryStore((state) => state.play);
|
||||||
const pause = useStoryStore((state) => state.pause);
|
const pause = useStoryStore((state) => state.pause);
|
||||||
const stop = useStoryStore((state) => state.stop);
|
const stop = useStoryStore((state) => state.stop);
|
||||||
const seek = useStoryStore((state) => state.seek);
|
const seek = useStoryStore((state) => state.seek);
|
||||||
|
const setActiveStory = useStoryStore((state) => state.setActiveStory);
|
||||||
|
|
||||||
const isActiveStory = playbackStoryId === storyId;
|
const isActiveStory = playbackStoryId === storyId;
|
||||||
const isCurrentlyPlaying = isPlaying && isActiveStory;
|
const isCurrentlyPlaying = isPlaying && isActiveStory;
|
||||||
|
|
||||||
|
// Auto-activate this story when the editor is shown so playhead is visible
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length > 0 && !isActiveStory) {
|
||||||
|
const totalDuration = Math.max(
|
||||||
|
...items.map((item) => {
|
||||||
|
const trimStart = item.trim_start_ms || 0;
|
||||||
|
const trimEnd = item.trim_end_ms || 0;
|
||||||
|
const effectiveDuration = item.duration * 1000 - trimStart - trimEnd;
|
||||||
|
return item.start_time_ms + effectiveDuration;
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
setActiveStory(storyId, items, totalDuration);
|
||||||
|
}
|
||||||
|
}, [storyId, items, isActiveStory, setActiveStory]);
|
||||||
|
|
||||||
// Sort items by start time for play
|
// Sort items by start time for play
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
return [...items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
return [...items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
||||||
@@ -140,14 +223,16 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Calculate total duration
|
// Calculate effective duration (accounting for trims)
|
||||||
|
const getEffectiveDuration = (item: StoryItemDetail) => {
|
||||||
|
return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate total duration (using effective durations)
|
||||||
const totalDurationMs = useMemo(() => {
|
const totalDurationMs = useMemo(() => {
|
||||||
if (items.length === 0) return 10000; // Default 10 seconds
|
if (items.length === 0) return 10000; // Default 10 seconds
|
||||||
return Math.max(
|
return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000);
|
||||||
...items.map((item) => item.start_time_ms + item.duration * 1000),
|
}, [items, getEffectiveDuration]);
|
||||||
10000
|
|
||||||
);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
// Calculate timeline width - at least full container width
|
// Calculate timeline width - at least full container width
|
||||||
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
|
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
|
||||||
@@ -175,15 +260,9 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const msToPixels = useCallback(
|
const msToPixels = useCallback((ms: number) => (ms / 1000) * pixelsPerSecond, [pixelsPerSecond]);
|
||||||
(ms: number) => (ms / 1000) * pixelsPerSecond,
|
|
||||||
[pixelsPerSecond]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pixelsToMs = useCallback(
|
const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]);
|
||||||
(px: number) => (px / pixelsPerSecond) * 1000,
|
|
||||||
[pixelsPerSecond]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
|
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
|
||||||
@@ -194,22 +273,28 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Resize handlers
|
// Resize handlers
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
const handleResizeStart = useCallback(
|
||||||
e.preventDefault();
|
(e: React.MouseEvent) => {
|
||||||
setIsResizing(true);
|
e.preventDefault();
|
||||||
resizeStartY.current = e.clientY;
|
setIsResizing(true);
|
||||||
resizeStartHeight.current = editorHeight;
|
resizeStartY.current = e.clientY;
|
||||||
}, [editorHeight]);
|
resizeStartHeight.current = editorHeight;
|
||||||
|
},
|
||||||
|
[editorHeight],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
const handleResizeMove = useCallback(
|
||||||
if (!isResizing) return;
|
(e: MouseEvent) => {
|
||||||
const deltaY = resizeStartY.current - e.clientY;
|
if (!isResizing) return;
|
||||||
const newHeight = Math.min(
|
const deltaY = resizeStartY.current - e.clientY;
|
||||||
MAX_EDITOR_HEIGHT,
|
const newHeight = Math.min(
|
||||||
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY)
|
MAX_EDITOR_HEIGHT,
|
||||||
);
|
Math.max(MIN_EDITOR_HEIGHT, resizeStartHeight.current + deltaY),
|
||||||
setEditorHeight(newHeight);
|
);
|
||||||
}, [isResizing, setEditorHeight]);
|
setEditorHeight(newHeight);
|
||||||
|
},
|
||||||
|
[isResizing, setEditorHeight],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResizeEnd = useCallback(() => {
|
const handleResizeEnd = useCallback(() => {
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
@@ -228,17 +313,269 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
}, [isResizing, handleResizeMove, handleResizeEnd]);
|
}, [isResizing, handleResizeMove, handleResizeEnd]);
|
||||||
|
|
||||||
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!tracksRef.current || draggingItem) return;
|
if (!tracksRef.current || draggingItem || trimmingItem) return;
|
||||||
const rect = tracksRef.current.getBoundingClientRect();
|
const rect = tracksRef.current.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
|
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
|
||||||
const timeMs = Math.max(0, pixelsToMs(x));
|
const timeMs = Math.max(0, pixelsToMs(x));
|
||||||
seek(timeMs);
|
seek(timeMs);
|
||||||
|
// Deselect clip when clicking on timeline
|
||||||
|
setSelectedClipId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragStart = (
|
const handleClipClick = (e: React.MouseEvent, item: StoryItemDetail) => {
|
||||||
e: React.MouseEvent,
|
e.stopPropagation();
|
||||||
item: StoryItemDetail
|
if (draggingItem || trimmingItem) return;
|
||||||
) => {
|
setSelectedClipId(item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrimStart = (e: React.MouseEvent, item: StoryItemDetail, side: 'start' | 'end') => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!tracksRef.current) return;
|
||||||
|
setTrimmingItem(item.id);
|
||||||
|
setTrimSide(side);
|
||||||
|
setSelectedClipId(item.id);
|
||||||
|
setTrimStartX(e.clientX);
|
||||||
|
trimStartItemRef.current = {
|
||||||
|
item,
|
||||||
|
initialTrimStart: item.trim_start_ms || 0,
|
||||||
|
initialTrimEnd: item.trim_end_ms || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimStartItemRef = useRef<{
|
||||||
|
item: StoryItemDetail;
|
||||||
|
initialTrimStart: number;
|
||||||
|
initialTrimEnd: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleTrimMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!trimmingItem || !trimSide || !trimStartItemRef.current) return;
|
||||||
|
|
||||||
|
const deltaX = e.clientX - trimStartX;
|
||||||
|
const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds
|
||||||
|
|
||||||
|
const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
||||||
|
const originalDurationMs = item.duration * 1000;
|
||||||
|
|
||||||
|
let newTrimStart = initialTrimStart;
|
||||||
|
let newTrimEnd = initialTrimEnd;
|
||||||
|
|
||||||
|
if (trimSide === 'start') {
|
||||||
|
// Moving right increases trim_start (trims more from start)
|
||||||
|
// Moving left decreases trim_start (restores from start)
|
||||||
|
newTrimStart = Math.round(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(initialTrimStart + deltaMs, originalDurationMs - initialTrimEnd - 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Moving right decreases trim_end (restores from end)
|
||||||
|
// Moving left increases trim_end (trims more from end)
|
||||||
|
newTrimEnd = Math.round(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(initialTrimEnd - deltaMs, originalDurationMs - initialTrimStart - 100),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we don't exceed duration
|
||||||
|
if (newTrimStart + newTrimEnd >= originalDurationMs - 100) {
|
||||||
|
return; // Don't allow trimming to less than 100ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update temporary trim values for visual feedback
|
||||||
|
setTempTrimValues({
|
||||||
|
trim_start_ms: newTrimStart,
|
||||||
|
trim_end_ms: newTrimEnd,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[trimmingItem, trimSide, trimStartX, pixelsToMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTrimEnd = useCallback(() => {
|
||||||
|
if (!trimmingItem || !trimSide || !trimStartItemRef.current) {
|
||||||
|
setTrimmingItem(null);
|
||||||
|
setTrimSide(null);
|
||||||
|
setTempTrimValues(null);
|
||||||
|
trimStartItemRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
||||||
|
|
||||||
|
// Use temporary trim values if available, otherwise use initial values
|
||||||
|
// Ensure values are integers for the backend
|
||||||
|
const finalTrimStart = Math.round(tempTrimValues?.trim_start_ms ?? initialTrimStart);
|
||||||
|
const finalTrimEnd = Math.round(tempTrimValues?.trim_end_ms ?? initialTrimEnd);
|
||||||
|
|
||||||
|
// Only update if values changed
|
||||||
|
if (finalTrimStart !== initialTrimStart || finalTrimEnd !== initialTrimEnd) {
|
||||||
|
trimItem.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: trimmingItem,
|
||||||
|
data: {
|
||||||
|
trim_start_ms: finalTrimStart,
|
||||||
|
trim_end_ms: finalTrimEnd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to trim clip',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrimmingItem(null);
|
||||||
|
setTrimSide(null);
|
||||||
|
setTempTrimValues(null);
|
||||||
|
trimStartItemRef.current = null;
|
||||||
|
}, [trimmingItem, trimSide, tempTrimValues, storyId, trimItem, toast]);
|
||||||
|
|
||||||
|
const handleSplit = useCallback(() => {
|
||||||
|
if (!selectedClipId) return;
|
||||||
|
|
||||||
|
const item = items.find((i) => i.id === selectedClipId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const splitTimeMs = currentTimeMs - item.start_time_ms;
|
||||||
|
const effectiveDuration = getEffectiveDuration(item);
|
||||||
|
|
||||||
|
if (splitTimeMs <= 0 || splitTimeMs >= effectiveDuration) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid split point',
|
||||||
|
description: 'Playhead must be within the selected clip',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
splitItem.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: selectedClipId,
|
||||||
|
data: { split_time_ms: splitTimeMs },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedClipId(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to split clip',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
selectedClipId,
|
||||||
|
items,
|
||||||
|
currentTimeMs,
|
||||||
|
getEffectiveDuration,
|
||||||
|
storyId,
|
||||||
|
splitItem,
|
||||||
|
toast,
|
||||||
|
setSelectedClipId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (!selectedClipId) return;
|
||||||
|
|
||||||
|
duplicateItem.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: selectedClipId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to duplicate clip',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [selectedClipId, storyId, duplicateItem, toast]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!selectedClipId) return;
|
||||||
|
|
||||||
|
removeItem.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: selectedClipId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedClipId(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete clip',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Only handle shortcuts when editor is focused or no input is focused
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setSelectedClipId(null);
|
||||||
|
} else if (e.key === 's' || e.key === 'S') {
|
||||||
|
if (selectedClipId) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSplit();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'd' || e.key === 'D') {
|
||||||
|
if (selectedClipId && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDuplicate();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (selectedClipId) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [selectedClipId, handleSplit, handleDuplicate, handleDelete, setSelectedClipId]);
|
||||||
|
|
||||||
|
// Add global mouse listeners for trimming
|
||||||
|
useEffect(() => {
|
||||||
|
if (trimmingItem) {
|
||||||
|
window.addEventListener('mousemove', handleTrimMove);
|
||||||
|
window.addEventListener('mouseup', handleTrimEnd);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleTrimMove);
|
||||||
|
window.removeEventListener('mouseup', handleTrimEnd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [trimmingItem, handleTrimMove, handleTrimEnd]);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.MouseEvent, item: StoryItemDetail) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!tracksRef.current) return;
|
if (!tracksRef.current) return;
|
||||||
|
|
||||||
@@ -251,7 +588,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
||||||
y: rect.top - tracksRef.current.getBoundingClientRect().top,
|
y: rect.top - tracksRef.current.getBoundingClientRect().top,
|
||||||
});
|
});
|
||||||
setDraggingItem(item.generation_id);
|
setDraggingItem(item.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragMove = useCallback(
|
const handleDragMove = useCallback(
|
||||||
@@ -264,7 +601,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
setDragPosition({ x: Math.max(0, x), y });
|
setDragPosition({ x: Math.max(0, x), y });
|
||||||
},
|
},
|
||||||
[draggingItem, dragOffset]
|
[draggingItem, dragOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
@@ -273,7 +610,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = items.find((i) => i.generation_id === draggingItem);
|
const item = items.find((i) => i.id === draggingItem);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
setDraggingItem(null);
|
setDraggingItem(null);
|
||||||
return;
|
return;
|
||||||
@@ -292,7 +629,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
moveItem.mutate(
|
moveItem.mutate(
|
||||||
{
|
{
|
||||||
storyId,
|
storyId,
|
||||||
generationId: item.generation_id,
|
itemId: item.id,
|
||||||
data: {
|
data: {
|
||||||
start_time_ms: newTimeMs,
|
start_time_ms: newTimeMs,
|
||||||
track: newTrack,
|
track: newTrack,
|
||||||
@@ -302,11 +639,11 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to move item',
|
title: 'Failed to move item',
|
||||||
description: error.message,
|
description: error instanceof Error ? error.message : String(error),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +655,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
// Calculate clip position and dimensions
|
// Calculate clip position and dimensions
|
||||||
const getClipStyle = (item: StoryItemDetail) => {
|
const getClipStyle = (item: StoryItemDetail) => {
|
||||||
const isDragging = draggingItem === item.generation_id;
|
const isDragging = draggingItem === item.id;
|
||||||
const trackIndex = getTrackIndex(item.track);
|
const trackIndex = getTrackIndex(item.track);
|
||||||
const width = msToPixels(item.duration * 1000);
|
const effectiveDuration = getEffectiveDuration(item);
|
||||||
|
const width = msToPixels(effectiveDuration);
|
||||||
const left = isDragging ? dragPosition.x : msToPixels(item.start_time_ms);
|
const left = isDragging ? dragPosition.x : msToPixels(item.start_time_ms);
|
||||||
const top = isDragging ? dragPosition.y : trackIndex * TRACK_HEIGHT;
|
const top = isDragging ? dragPosition.y : trackIndex * TRACK_HEIGHT;
|
||||||
|
|
||||||
@@ -345,176 +683,271 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
|
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
|
||||||
<div className="border-t bg-background/30 backdrop-blur-2xl overflow-hidden relative" ref={containerRef}>
|
<div
|
||||||
{/* Resize handle at top */}
|
className="border-t bg-background/30 backdrop-blur-2xl overflow-hidden relative"
|
||||||
<button
|
ref={containerRef}
|
||||||
type="button"
|
|
||||||
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center hover:bg-muted/50 transition-colors z-20 group"
|
|
||||||
onMouseDown={handleResizeStart}
|
|
||||||
aria-label="Resize track editor"
|
|
||||||
>
|
>
|
||||||
<GripHorizontal className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
{/* Resize handle at top */}
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
{/* Toolbar */}
|
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center hover:bg-muted/50 transition-colors z-20 group"
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
|
onMouseDown={handleResizeStart}
|
||||||
{/* Play controls - left side */}
|
aria-label="Resize track editor"
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
|
|
||||||
{isCurrentlyPlaying ? (
|
|
||||||
<Pause className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleStop} disabled={!isActiveStory}>
|
|
||||||
<Square className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
|
||||||
{formatTime(isActiveStory ? currentTimeMs : 0)} / {formatTime(isActiveStory ? storeTotalDurationMs : 0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zoom controls - right side */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">Zoom:</span>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
|
||||||
<Minus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline container with track labels sidebar */}
|
|
||||||
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
|
|
||||||
{/* Track labels sidebar - fixed width */}
|
|
||||||
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
|
|
||||||
{/* Spacer for time ruler */}
|
|
||||||
<div className="h-6 border-b bg-muted/30" />
|
|
||||||
{/* Track labels */}
|
|
||||||
<div style={{ height: `${tracksAreaHeight}px` }}>
|
|
||||||
{tracks.map((trackNumber, index) => (
|
|
||||||
<div
|
|
||||||
key={trackNumber}
|
|
||||||
className={cn(
|
|
||||||
'border-b flex items-center justify-center',
|
|
||||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
|
||||||
)}
|
|
||||||
style={{ height: `${TRACK_HEIGHT}px` }}
|
|
||||||
>
|
|
||||||
<span className="text-[10px] text-muted-foreground select-none">
|
|
||||||
{trackNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable timeline area */}
|
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
|
||||||
<div
|
|
||||||
ref={tracksRef}
|
|
||||||
className="overflow-auto relative flex-1"
|
|
||||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
|
||||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
|
||||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
|
||||||
>
|
>
|
||||||
{/* Time ruler */}
|
<GripHorizontal className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
||||||
<div
|
</button>
|
||||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10"
|
|
||||||
style={{ width: `${timelineWidth}px` }}
|
{/* Toolbar */}
|
||||||
>
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
|
||||||
{timeMarkers.map((ms) => (
|
{/* Play controls - left side */}
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
key={ms}
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
|
||||||
className="absolute top-0 h-full flex flex-col justify-end"
|
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
style={{ left: `${msToPixels(ms)}px` }}
|
</Button>
|
||||||
>
|
<Button
|
||||||
<div className="h-2 w-px bg-border" />
|
variant="ghost"
|
||||||
<span className="text-[10px] text-muted-foreground ml-1 select-none">
|
size="icon"
|
||||||
{formatTime(ms)}
|
className="h-7 w-7"
|
||||||
</span>
|
onClick={handleStop}
|
||||||
</div>
|
disabled={!isCurrentlyPlaying}
|
||||||
))}
|
>
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
||||||
|
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tracks area */}
|
{/* Clip editing controls - center */}
|
||||||
<div
|
{selectedClipId && (
|
||||||
className="relative"
|
<div className="flex items-center gap-1">
|
||||||
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
{/* Track backgrounds */}
|
size="icon"
|
||||||
{tracks.map((trackNumber, index) => (
|
className="h-7 w-7"
|
||||||
<div
|
onClick={handleSplit}
|
||||||
key={trackNumber}
|
title="Split at playhead (S)"
|
||||||
className={cn(
|
|
||||||
'absolute left-0 right-0 border-b',
|
|
||||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
top: `${index * TRACK_HEIGHT}px`,
|
|
||||||
height: `${TRACK_HEIGHT}px`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Click area for seeking - z-index lower than clips */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute inset-0 z-0 cursor-pointer"
|
|
||||||
onClick={handleTimelineClick}
|
|
||||||
aria-label="Seek timeline"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Audio clips */}
|
|
||||||
{items.map((item) => {
|
|
||||||
const isDragging = draggingItem === item.generation_id;
|
|
||||||
const style = getClipStyle(item);
|
|
||||||
const clipWidth = msToPixels(item.duration * 1000);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={item.generation_id}
|
|
||||||
className={cn(
|
|
||||||
'absolute rounded cursor-move select-none overflow-hidden z-10',
|
|
||||||
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
|
|
||||||
'flex flex-col justify-center',
|
|
||||||
isDragging && 'opacity-80 shadow-lg z-20',
|
|
||||||
!isDragging && 'transition-all duration-100'
|
|
||||||
)}
|
|
||||||
style={style}
|
|
||||||
onMouseDown={(e) => handleDragStart(e, item)}
|
|
||||||
>
|
>
|
||||||
{/* Clip label */}
|
<Scissors className="h-4 w-4" />
|
||||||
<div className="absolute top-0 left-1 right-1 z-10">
|
</Button>
|
||||||
<p className="text-[9px] font-medium text-accent-foreground truncate">
|
<Button
|
||||||
{item.profile_name}
|
variant="ghost"
|
||||||
</p>
|
size="icon"
|
||||||
</div>
|
className="h-7 w-7"
|
||||||
{/* Waveform */}
|
onClick={handleDuplicate}
|
||||||
<div className="absolute inset-0 top-3">
|
title="Duplicate (Cmd/Ctrl+D)"
|
||||||
<ClipWaveform generationId={item.generation_id} width={clipWidth} />
|
>
|
||||||
</div>
|
<Copy className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
);
|
<Button
|
||||||
})}
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
{/* Playhead */}
|
className="h-7 w-7"
|
||||||
{isActiveStory && (
|
onClick={handleDelete}
|
||||||
<div
|
title="Delete (Delete/Backspace)"
|
||||||
className="absolute top-0 bottom-0 w-1 bg-accent z-30 pointer-events-none rounded-full"
|
>
|
||||||
style={{ left: `${playheadLeft}px` }}
|
<Trash2 className="h-4 w-4" />
|
||||||
>
|
</Button>
|
||||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 bg-accent rounded-full" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Zoom controls - right side */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Zoom:</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline container with track labels sidebar */}
|
||||||
|
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
|
||||||
|
{/* Track labels sidebar - fixed width */}
|
||||||
|
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
|
||||||
|
{/* Spacer for time ruler */}
|
||||||
|
<div className="h-6 border-b bg-muted/30" />
|
||||||
|
{/* Track labels */}
|
||||||
|
<div style={{ height: `${tracksAreaHeight}px` }}>
|
||||||
|
{tracks.map((trackNumber, index) => (
|
||||||
|
<div
|
||||||
|
key={trackNumber}
|
||||||
|
className={cn(
|
||||||
|
'border-b flex items-center justify-center',
|
||||||
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||||
|
)}
|
||||||
|
style={{ height: `${TRACK_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-muted-foreground select-none">
|
||||||
|
{trackNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable timeline area */}
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
||||||
|
<div
|
||||||
|
ref={tracksRef}
|
||||||
|
className="overflow-auto relative flex-1"
|
||||||
|
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||||
|
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||||
|
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||||
|
>
|
||||||
|
{/* Time ruler - clickable to seek */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||||
|
style={{ width: `${timelineWidth}px` }}
|
||||||
|
onClick={handleTimelineClick}
|
||||||
|
aria-label="Seek timeline"
|
||||||
|
>
|
||||||
|
{timeMarkers.map((ms) => (
|
||||||
|
<div
|
||||||
|
key={ms}
|
||||||
|
className="absolute top-0 h-full flex flex-col justify-end pointer-events-none"
|
||||||
|
style={{ left: `${msToPixels(ms)}px` }}
|
||||||
|
>
|
||||||
|
<div className="h-2 w-px bg-border" />
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1 select-none">
|
||||||
|
{formatTime(ms)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tracks area */}
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
||||||
|
>
|
||||||
|
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
|
||||||
|
{tracks.map((trackNumber, index) => (
|
||||||
|
<div
|
||||||
|
key={trackNumber}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 right-0 border-b pointer-events-none',
|
||||||
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: `${index * TRACK_HEIGHT}px`,
|
||||||
|
height: `${TRACK_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Click area for seeking - z-index lower than clips */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 z-0 cursor-pointer"
|
||||||
|
onClick={handleTimelineClick}
|
||||||
|
aria-label="Seek timeline"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Audio clips */}
|
||||||
|
{items.map((item) => {
|
||||||
|
const isDragging = draggingItem === item.id;
|
||||||
|
const isSelected = selectedClipId === item.id;
|
||||||
|
const isTrimming = trimmingItem === item.id;
|
||||||
|
|
||||||
|
// Use temporary trim values during trimming for visual feedback
|
||||||
|
const displayTrimStart =
|
||||||
|
isTrimming && tempTrimValues
|
||||||
|
? tempTrimValues.trim_start_ms
|
||||||
|
: item.trim_start_ms || 0;
|
||||||
|
const displayTrimEnd =
|
||||||
|
isTrimming && tempTrimValues ? tempTrimValues.trim_end_ms : item.trim_end_ms || 0;
|
||||||
|
const effectiveDuration = item.duration * 1000 - displayTrimStart - displayTrimEnd;
|
||||||
|
|
||||||
|
const style = getClipStyle({
|
||||||
|
...item,
|
||||||
|
trim_start_ms: displayTrimStart,
|
||||||
|
trim_end_ms: displayTrimEnd,
|
||||||
|
});
|
||||||
|
const clipWidth = msToPixels(effectiveDuration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
'absolute rounded select-none overflow-visible z-10',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-1',
|
||||||
|
isTrimming && 'ring-2 ring-accent',
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full rounded cursor-move overflow-hidden',
|
||||||
|
'bg-accent/80 hover:bg-accent border border-accent-foreground/20',
|
||||||
|
'flex flex-col justify-center',
|
||||||
|
isDragging && 'opacity-80 shadow-lg z-20',
|
||||||
|
!isDragging && 'transition-all duration-100',
|
||||||
|
)}
|
||||||
|
onClick={(e) => handleClipClick(e, item)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Only start drag if not clicking on trim handles
|
||||||
|
if (!(e.target as HTMLElement).closest('.trim-handle')) {
|
||||||
|
handleDragStart(e, item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Clip label */}
|
||||||
|
<div className="absolute top-0 left-1 right-1 z-10">
|
||||||
|
<p className="text-[9px] font-medium text-accent-foreground truncate">
|
||||||
|
{item.profile_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Waveform */}
|
||||||
|
<div className="absolute inset-0 top-3">
|
||||||
|
<ClipWaveform
|
||||||
|
generationId={item.generation_id}
|
||||||
|
width={clipWidth}
|
||||||
|
trimStartMs={displayTrimStart}
|
||||||
|
trimEndMs={displayTrimEnd}
|
||||||
|
duration={item.duration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Trim handles */}
|
||||||
|
{isSelected && (
|
||||||
|
<>
|
||||||
|
{/* Left trim handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="trim-handle absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-l"
|
||||||
|
onMouseDown={(e) => handleTrimStart(e, item, 'start')}
|
||||||
|
aria-label="Trim start"
|
||||||
|
/>
|
||||||
|
{/* Right trim handle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="trim-handle absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-primary/30 bg-primary/20 z-30 rounded-r"
|
||||||
|
onMouseDown={(e) => handleTrimStart(e, item, 'end')}
|
||||||
|
aria-label="Trim end"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Playhead - always visible */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-1 bg-accent z-30 pointer-events-none rounded-full"
|
||||||
|
style={{ left: `${playheadLeft}px` }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 bg-accent rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Plus, Trash2, Play } from 'lucide-react';
|
import { Plus, Trash2, Play } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { apiClient } from '@/lib/api/client';
|
|
||||||
import { SampleUpload } from './SampleUpload';
|
import { SampleUpload } from './SampleUpload';
|
||||||
|
|
||||||
interface SampleListProps {
|
interface SampleListProps {
|
||||||
@@ -14,7 +14,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||||
const deleteSample = useDeleteSample();
|
const deleteSample = useDeleteSample();
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
|
|
||||||
const handlePlay = (referenceText: string, sampleId: string) => {
|
const handlePlay = (referenceText: string, sampleId: string) => {
|
||||||
const audioUrl = apiClient.getSampleUrl(sampleId);
|
const audioUrl = apiClient.getSampleUrl(sampleId);
|
||||||
setAudio(audioUrl, sampleId, referenceText.substring(0, 50));
|
setAudioWithAutoPlay(audioUrl, sampleId, null, referenceText.substring(0, 50));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
StoryItemBatchUpdate,
|
StoryItemBatchUpdate,
|
||||||
StoryItemReorder,
|
StoryItemReorder,
|
||||||
StoryItemMove,
|
StoryItemMove,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryItemSplit,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -406,8 +408,8 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeStoryItem(storyId: string, generationId: string): Promise<void> {
|
async removeStoryItem(storyId: string, itemId: string): Promise<void> {
|
||||||
await this.request<void>(`/stories/${storyId}/items/${generationId}`, {
|
await this.request<void>(`/stories/${storyId}/items/${itemId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -426,13 +428,33 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveStoryItem(storyId: string, generationId: string, data: StoryItemMove): Promise<StoryItemDetail> {
|
async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
|
||||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${generationId}/move`, {
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise<StoryItemDetail[]> {
|
||||||
|
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async duplicateStoryItem(storyId: string, itemId: string): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/duplicate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async exportStoryAudio(storyId: string): Promise<Blob> {
|
async exportStoryAudio(storyId: string): Promise<Blob> {
|
||||||
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export interface StoryItemDetail {
|
|||||||
generation_id: string;
|
generation_id: string;
|
||||||
start_time_ms: number;
|
start_time_ms: number;
|
||||||
track: number;
|
track: number;
|
||||||
|
trim_start_ms: number;
|
||||||
|
trim_end_ms: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
profile_name: string;
|
profile_name: string;
|
||||||
@@ -188,3 +190,12 @@ export interface StoryItemMove {
|
|||||||
start_time_ms: number;
|
start_time_ms: number;
|
||||||
track: number;
|
track: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoryItemTrim {
|
||||||
|
trim_start_ms: number;
|
||||||
|
trim_end_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemSplit {
|
||||||
|
split_time_ms: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface UseGenerationFormOptions {
|
|||||||
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const generation = useGeneration();
|
const generation = useGeneration();
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
||||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
@@ -97,7 +97,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
const audioUrl = apiClient.getAudioUrl(result.id);
|
||||||
setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
options.onSuccess?.(result.id);
|
options.onSuccess?.(result.id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove } from '@/lib/api/types';
|
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
|
||||||
export function useStories() {
|
export function useStories() {
|
||||||
@@ -70,8 +70,8 @@ export function useRemoveStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ storyId, generationId }: { storyId: string; generationId: string }) =>
|
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
|
||||||
apiClient.removeStoryItem(storyId, generationId),
|
apiClient.removeStoryItem(storyId, itemId),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
@@ -109,8 +109,47 @@ export function useMoveStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ storyId, generationId, data }: { storyId: string; generationId: string; data: StoryItemMove }) =>
|
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
|
||||||
apiClient.moveStoryItem(storyId, generationId, data),
|
apiClient.moveStoryItem(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrimStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
|
||||||
|
apiClient.trimStoryItem(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSplitStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
|
||||||
|
apiClient.splitStoryItem(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDuplicateStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
|
||||||
|
apiClient.duplicateStoryItem(storyId, itemId),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useStoryStore } from '@/stores/storyStore';
|
|||||||
|
|
||||||
interface ActiveSource {
|
interface ActiveSource {
|
||||||
source: AudioBufferSourceNode;
|
source: AudioBufferSourceNode;
|
||||||
|
itemId: string;
|
||||||
generationId: string;
|
generationId: string;
|
||||||
startTimeMs: number;
|
startTimeMs: number;
|
||||||
endTimeMs: number;
|
endTimeMs: number;
|
||||||
@@ -26,9 +27,9 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
// Master gain for volume control
|
// Master gain for volume control
|
||||||
const masterGainRef = useRef<GainNode | null>(null);
|
const masterGainRef = useRef<GainNode | null>(null);
|
||||||
// Preloaded AudioBuffers by generation_id
|
// Preloaded AudioBuffers by generation_id (audio file is shared between split clips)
|
||||||
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
|
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
|
||||||
// Currently playing AudioBufferSourceNodes by generation_id
|
// Currently playing AudioBufferSourceNodes by item.id (unique per clip)
|
||||||
const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map());
|
const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map());
|
||||||
// Animation frame for syncing visual playhead
|
// Animation frame for syncing visual playhead
|
||||||
const animationFrameRef = useRef<number | null>(null);
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
@@ -56,16 +57,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
return audioContextRef.current;
|
return audioContextRef.current;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Stop a source
|
// Stop a source by item id
|
||||||
const stopSource = useCallback((generationId: string) => {
|
const stopSource = useCallback((itemId: string) => {
|
||||||
const activeSource = activeSourcesRef.current.get(generationId);
|
const activeSource = activeSourcesRef.current.get(itemId);
|
||||||
if (activeSource) {
|
if (activeSource) {
|
||||||
try {
|
try {
|
||||||
activeSource.source.stop();
|
activeSource.source.stop();
|
||||||
} catch {
|
} catch {
|
||||||
// Source may have already stopped
|
// Source may have already stopped
|
||||||
}
|
}
|
||||||
activeSourcesRef.current.delete(generationId);
|
activeSourcesRef.current.delete(itemId);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -123,8 +124,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
// Stop all sources
|
// Stop all sources
|
||||||
for (const [generationId] of activeSourcesRef.current) {
|
for (const [itemId] of activeSourcesRef.current) {
|
||||||
stopSource(generationId);
|
stopSource(itemId);
|
||||||
}
|
}
|
||||||
activeSourcesRef.current.clear();
|
activeSourcesRef.current.clear();
|
||||||
|
|
||||||
@@ -151,7 +152,11 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
(storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => {
|
(storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => {
|
||||||
return itemList.filter((item) => {
|
return itemList.filter((item) => {
|
||||||
const itemStart = item.start_time_ms;
|
const itemStart = item.start_time_ms;
|
||||||
const itemEnd = item.start_time_ms + item.duration * 1000;
|
// Use effective duration (accounting for trims)
|
||||||
|
const trimStartMs = item.trim_start_ms || 0;
|
||||||
|
const trimEndMs = item.trim_end_ms || 0;
|
||||||
|
const effectiveDurationMs = item.duration * 1000 - trimStartMs - trimEndMs;
|
||||||
|
const itemEnd = item.start_time_ms + effectiveDurationMs;
|
||||||
return storyTimeMs >= itemStart && storyTimeMs < itemEnd;
|
return storyTimeMs >= itemStart && storyTimeMs < itemEnd;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -185,8 +190,8 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
// Stop all sources
|
// Stop all sources
|
||||||
const stopAllSources = useCallback(() => {
|
const stopAllSources = useCallback(() => {
|
||||||
console.log('[StoryPlayback] Stopping all sources');
|
console.log('[StoryPlayback] Stopping all sources');
|
||||||
for (const [generationId] of activeSourcesRef.current) {
|
for (const [itemId] of activeSourcesRef.current) {
|
||||||
stopSource(generationId);
|
stopSource(itemId);
|
||||||
}
|
}
|
||||||
activeSourcesRef.current.clear();
|
activeSourcesRef.current.clear();
|
||||||
}, [stopSource]);
|
}, [stopSource]);
|
||||||
@@ -199,18 +204,18 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
|
|
||||||
// Find all items that should be playing
|
// Find all items that should be playing
|
||||||
const shouldBePlaying = findActiveItems(storyTimeMs, itemList);
|
const shouldBePlaying = findActiveItems(storyTimeMs, itemList);
|
||||||
const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.generation_id));
|
const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.id));
|
||||||
|
|
||||||
// Stop sources that shouldn't be playing anymore
|
// Stop sources that shouldn't be playing anymore
|
||||||
for (const [generationId] of activeSourcesRef.current) {
|
for (const [itemId] of activeSourcesRef.current) {
|
||||||
if (!shouldBePlayingIds.has(generationId)) {
|
if (!shouldBePlayingIds.has(itemId)) {
|
||||||
stopSource(generationId);
|
stopSource(itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule new sources for items that should be playing
|
// Schedule new sources for items that should be playing
|
||||||
for (const item of shouldBePlaying) {
|
for (const item of shouldBePlaying) {
|
||||||
if (!activeSourcesRef.current.has(item.generation_id)) {
|
if (!activeSourcesRef.current.has(item.id)) {
|
||||||
const buffer = audioBuffersRef.current.get(item.generation_id);
|
const buffer = audioBuffersRef.current.get(item.generation_id);
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
||||||
@@ -219,16 +224,24 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
|
|
||||||
// Calculate when this item should start in AudioContext time
|
// Calculate when this item should start in AudioContext time
|
||||||
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
||||||
const itemEndStoryTime = item.start_time_ms + item.duration * 1000;
|
|
||||||
|
// Calculate effective duration and trim offsets
|
||||||
|
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
||||||
|
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
||||||
|
const effectiveDuration = item.duration - trimStartSec - trimEndSec;
|
||||||
|
const itemEndStoryTime = item.start_time_ms + effectiveDuration * 1000;
|
||||||
|
|
||||||
// Calculate offset into the buffer (if seeking mid-way)
|
// Calculate offset into the buffer (if seeking mid-way)
|
||||||
const offsetIntoBuffer = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000);
|
// Offset is relative to the trimmed start of the clip
|
||||||
const duration = item.duration - offsetIntoBuffer;
|
const offsetIntoEffectiveClip = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000);
|
||||||
|
const offsetIntoBuffer = trimStartSec + offsetIntoEffectiveClip;
|
||||||
|
const duration = effectiveDuration - offsetIntoEffectiveClip;
|
||||||
|
|
||||||
// If the item should have already started, schedule it to start immediately
|
// If the item should have already started, schedule it to start immediately
|
||||||
const startAtContextTime = Math.max(currentContextTime, itemStartContextTime);
|
const startAtContextTime = Math.max(currentContextTime, itemStartContextTime);
|
||||||
|
|
||||||
console.log('[StoryPlayback] Scheduling source:', {
|
console.log('[StoryPlayback] Scheduling source:', {
|
||||||
|
itemId: item.id,
|
||||||
generationId: item.generation_id,
|
generationId: item.generation_id,
|
||||||
storyTimeMs,
|
storyTimeMs,
|
||||||
itemStart: item.start_time_ms,
|
itemStart: item.start_time_ms,
|
||||||
@@ -243,20 +256,21 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
|
|
||||||
const activeSource: ActiveSource = {
|
const activeSource: ActiveSource = {
|
||||||
source,
|
source,
|
||||||
|
itemId: item.id,
|
||||||
generationId: item.generation_id,
|
generationId: item.generation_id,
|
||||||
startTimeMs: item.start_time_ms,
|
startTimeMs: item.start_time_ms,
|
||||||
endTimeMs: itemEndStoryTime,
|
endTimeMs: itemEndStoryTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
activeSourcesRef.current.set(item.generation_id, activeSource);
|
activeSourcesRef.current.set(item.id, activeSource);
|
||||||
|
|
||||||
// Schedule playback
|
// Schedule playback
|
||||||
source.start(startAtContextTime, offsetIntoBuffer, duration);
|
source.start(startAtContextTime, offsetIntoBuffer, duration);
|
||||||
|
|
||||||
// Clean up when source ends
|
// Clean up when source ends
|
||||||
source.onended = () => {
|
source.onended = () => {
|
||||||
console.log('[StoryPlayback] Source ended:', item.generation_id);
|
console.log('[StoryPlayback] Source ended:', item.id);
|
||||||
activeSourcesRef.current.delete(item.generation_id);
|
activeSourcesRef.current.delete(item.id);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ interface StoryPlaybackState {
|
|||||||
// Selection
|
// Selection
|
||||||
selectedStoryId: string | null;
|
selectedStoryId: string | null;
|
||||||
setSelectedStoryId: (id: string | null) => void;
|
setSelectedStoryId: (id: string | null) => void;
|
||||||
|
selectedClipId: string | null;
|
||||||
|
setSelectedClipId: (id: string | null) => void;
|
||||||
|
|
||||||
// Track editor UI state
|
// Track editor UI state
|
||||||
trackEditorHeight: number;
|
trackEditorHeight: number;
|
||||||
@@ -26,6 +28,7 @@ interface StoryPlaybackState {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
seek: (timeMs: number) => void;
|
seek: (timeMs: number) => void;
|
||||||
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API
|
setPlaybackTiming: (contextTime: number, storyTime: number) => void; // Set timing anchors for Web Audio API
|
||||||
|
setActiveStory: (storyId: string, items: StoryItemDetail[], totalDurationMs: number) => void; // Activate story for seeking without playing
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TRACK_EDITOR_HEIGHT = 250;
|
const DEFAULT_TRACK_EDITOR_HEIGHT = 250;
|
||||||
@@ -34,6 +37,8 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
// Selection
|
// Selection
|
||||||
selectedStoryId: null,
|
selectedStoryId: null,
|
||||||
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
|
setSelectedStoryId: (id) => set({ selectedStoryId: id }),
|
||||||
|
selectedClipId: null,
|
||||||
|
setSelectedClipId: (id) => set({ selectedClipId: id }),
|
||||||
|
|
||||||
// Track editor UI state
|
// Track editor UI state
|
||||||
trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT,
|
trackEditorHeight: DEFAULT_TRACK_EDITOR_HEIGHT,
|
||||||
@@ -53,14 +58,11 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
// Calculate total duration from items
|
// Calculate total duration from items
|
||||||
const maxEndTimeMs = Math.max(
|
const maxEndTimeMs = Math.max(
|
||||||
...items.map((item) => item.start_time_ms + item.duration * 1000),
|
...items.map((item) => item.start_time_ms + item.duration * 1000),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find the minimum start time (first item)
|
// Find the minimum start time (first item)
|
||||||
const minStartTimeMs = Math.min(
|
const minStartTimeMs = Math.min(...items.map((item) => item.start_time_ms), 0);
|
||||||
...items.map((item) => item.start_time_ms),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
// If resuming the same story, keep position; otherwise start at first item
|
// If resuming the same story, keep position; otherwise start at first item
|
||||||
const currentState = get();
|
const currentState = get();
|
||||||
@@ -70,7 +72,11 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
console.log('[StoryStore] Play called:', {
|
console.log('[StoryStore] Play called:', {
|
||||||
storyId,
|
storyId,
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
items: items.map(i => ({ id: i.generation_id, start: i.start_time_ms, duration: i.duration })),
|
items: items.map((i) => ({
|
||||||
|
id: i.generation_id,
|
||||||
|
start: i.start_time_ms,
|
||||||
|
duration: i.duration,
|
||||||
|
})),
|
||||||
maxEndTimeMs,
|
maxEndTimeMs,
|
||||||
minStartTimeMs,
|
minStartTimeMs,
|
||||||
startTimeMs,
|
startTimeMs,
|
||||||
@@ -122,4 +128,18 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
playbackStartStoryTime: storyTime,
|
playbackStartStoryTime: storyTime,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setActiveStory: (storyId, items, totalDurationMs) => {
|
||||||
|
const currentState = get();
|
||||||
|
// Only update if switching to a different story
|
||||||
|
if (currentState.playbackStoryId !== storyId) {
|
||||||
|
set({
|
||||||
|
playbackStoryId: storyId,
|
||||||
|
playbackItems: items,
|
||||||
|
totalDurationMs,
|
||||||
|
currentTimeMs: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
# Backend package
|
# Backend package
|
||||||
|
|
||||||
|
__version__ = "0.1.8"
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ class StoryItem(Base):
|
|||||||
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
||||||
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
|
start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
|
||||||
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
|
track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
|
||||||
|
trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
|
||||||
|
trim_end_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from end
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
@@ -257,6 +259,24 @@ def _run_migrations(engine):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
print("Added track column to story_items")
|
print("Added track column to story_items")
|
||||||
|
|
||||||
|
# Migration: Add trim columns if they don't exist
|
||||||
|
# Re-check columns after potential track migration
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||||
|
if 'trim_start_ms' not in columns:
|
||||||
|
print("Migrating story_items: adding trim_start_ms column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_start_ms INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added trim_start_ms column to story_items")
|
||||||
|
|
||||||
|
columns = {col['name'] for col in inspector.get_columns('story_items')}
|
||||||
|
if 'trim_end_ms' not in columns:
|
||||||
|
print("Migrating story_items: adding trim_end_ms column")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_end_ms INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added trim_end_ms column to story_items")
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""Get database session (generator for dependency injection)."""
|
"""Get database session (generator for dependency injection)."""
|
||||||
|
|||||||
+166
-31
@@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import asyncio
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import argparse
|
import argparse
|
||||||
import torch
|
import torch
|
||||||
@@ -18,8 +19,11 @@ import tempfile
|
|||||||
import io
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import os
|
||||||
|
|
||||||
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories
|
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__
|
||||||
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
from .utils.progress import get_progress_manager
|
from .utils.progress import get_progress_manager
|
||||||
from .utils.tasks import get_task_manager
|
from .utils.tasks import get_task_manager
|
||||||
@@ -27,7 +31,7 @@ from .utils.tasks import get_task_manager
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="voicebox API",
|
title="voicebox API",
|
||||||
description="Production-quality Qwen3-TTS voice cloning API",
|
description="Production-quality Qwen3-TTS voice cloning API",
|
||||||
version="0.1.0",
|
version=__version__,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS middleware
|
# CORS middleware
|
||||||
@@ -47,13 +51,24 @@ app.add_middleware(
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint."""
|
"""Root endpoint."""
|
||||||
return {"message": "voicebox API", "version": "0.1.6"}
|
return {"message": "voicebox API", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/shutdown")
|
||||||
|
async def shutdown():
|
||||||
|
"""Gracefully shutdown the server."""
|
||||||
|
async def shutdown_async():
|
||||||
|
await asyncio.sleep(0.1) # Give response time to send
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
|
||||||
|
asyncio.create_task(shutdown_async())
|
||||||
|
return {"message": "Shutting down..."}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=models.HealthResponse)
|
@app.get("/health", response_model=models.HealthResponse)
|
||||||
async def health():
|
async def health():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
from huggingface_hub import hf_hub_download
|
from huggingface_hub import hf_hub_download, constants as hf_constants
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -64,6 +79,12 @@ async def health():
|
|||||||
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||||
gpu_available = has_cuda or has_mps
|
gpu_available = has_cuda or has_mps
|
||||||
|
|
||||||
|
gpu_type = None
|
||||||
|
if has_cuda:
|
||||||
|
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||||
|
elif has_mps:
|
||||||
|
gpu_type = "MPS (Apple Silicon)"
|
||||||
|
|
||||||
vram_used = None
|
vram_used = None
|
||||||
if has_cuda:
|
if has_cuda:
|
||||||
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
||||||
@@ -101,8 +122,8 @@ async def health():
|
|||||||
model_downloaded = True
|
model_downloaded = True
|
||||||
break
|
break
|
||||||
except (ImportError, Exception):
|
except (ImportError, Exception):
|
||||||
# Method 2: Check cache directory
|
# Method 2: Check cache directory (using HuggingFace's OS-specific cache location)
|
||||||
cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
|
cache_dir = hf_constants.HF_HUB_CACHE
|
||||||
repo_cache = Path(cache_dir) / "models--" + default_model_id.replace("/", "--")
|
repo_cache = Path(cache_dir) / "models--" + default_model_id.replace("/", "--")
|
||||||
if repo_cache.exists():
|
if repo_cache.exists():
|
||||||
has_model_files = (
|
has_model_files = (
|
||||||
@@ -121,6 +142,7 @@ async def health():
|
|||||||
model_downloaded=model_downloaded,
|
model_downloaded=model_downloaded,
|
||||||
model_size=model_size,
|
model_size=model_size,
|
||||||
gpu_available=gpu_available,
|
gpu_available=gpu_available,
|
||||||
|
gpu_type=gpu_type,
|
||||||
vram_used_mb=vram_used,
|
vram_used_mb=vram_used,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -451,6 +473,36 @@ async def generate_speech(
|
|||||||
tts_model = tts.get_tts_model()
|
tts_model = tts.get_tts_model()
|
||||||
# Load the requested model size if different from current (async to not block)
|
# Load the requested model size if different from current (async to not block)
|
||||||
model_size = data.model_size or "1.7B"
|
model_size = data.model_size or "1.7B"
|
||||||
|
|
||||||
|
# Check if model needs to be downloaded first
|
||||||
|
model_path = tts_model._get_model_path(model_size)
|
||||||
|
if model_path.startswith("Qwen/"):
|
||||||
|
# Model not cached - check if it exists remotely or needs download
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||||
|
if not repo_cache.exists():
|
||||||
|
# Start download in background
|
||||||
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
|
async def download_model_background():
|
||||||
|
try:
|
||||||
|
await tts_model.load_model_async(model_size)
|
||||||
|
except Exception as e:
|
||||||
|
task_manager.error_download(model_name, str(e))
|
||||||
|
|
||||||
|
task_manager.start_download(model_name)
|
||||||
|
asyncio.create_task(download_model_background())
|
||||||
|
|
||||||
|
# Return 202 Accepted with download info
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=202,
|
||||||
|
detail={
|
||||||
|
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
|
||||||
|
"model_name": model_name,
|
||||||
|
"downloading": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
await tts_model.load_model_async(model_size)
|
await tts_model.load_model_async(model_size)
|
||||||
audio, sample_rate = await tts_model.generate(
|
audio, sample_rate = await tts_model.generate(
|
||||||
data.text,
|
data.text,
|
||||||
@@ -684,6 +736,37 @@ async def transcribe_audio(
|
|||||||
|
|
||||||
# Transcribe
|
# Transcribe
|
||||||
whisper_model = transcribe.get_whisper_model()
|
whisper_model = transcribe.get_whisper_model()
|
||||||
|
|
||||||
|
# Check if Whisper model is downloaded (uses default size "base")
|
||||||
|
model_size = whisper_model.model_size
|
||||||
|
model_name = f"openai/whisper-{model_size}"
|
||||||
|
|
||||||
|
# Check if model is cached
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
|
||||||
|
if not repo_cache.exists():
|
||||||
|
# Start download in background
|
||||||
|
progress_model_name = f"whisper-{model_size}"
|
||||||
|
|
||||||
|
async def download_whisper_background():
|
||||||
|
try:
|
||||||
|
await whisper_model.load_model_async(model_size)
|
||||||
|
except Exception as e:
|
||||||
|
get_task_manager().error_download(progress_model_name, str(e))
|
||||||
|
|
||||||
|
get_task_manager().start_download(progress_model_name)
|
||||||
|
asyncio.create_task(download_whisper_background())
|
||||||
|
|
||||||
|
# Return 202 Accepted
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=202,
|
||||||
|
detail={
|
||||||
|
"message": f"Whisper model {model_size} is being downloaded. Please wait and try again.",
|
||||||
|
"model_name": progress_model_name,
|
||||||
|
"downloading": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
text = await whisper_model.transcribe(tmp_path, language)
|
text = await whisper_model.transcribe(tmp_path, language)
|
||||||
|
|
||||||
return models.TranscriptionResponse(
|
return models.TranscriptionResponse(
|
||||||
@@ -770,14 +853,14 @@ async def add_story_item(
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/stories/{story_id}/items/{generation_id}")
|
@app.delete("/stories/{story_id}/items/{item_id}")
|
||||||
async def remove_story_item(
|
async def remove_story_item(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
generation_id: str,
|
item_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Remove a generation from a story."""
|
"""Remove a story item from a story."""
|
||||||
success = await stories.remove_item_from_story(story_id, generation_id, db)
|
success = await stories.remove_item_from_story(story_id, item_id, db)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=404, detail="Story item not found")
|
raise HTTPException(status_code=404, detail="Story item not found")
|
||||||
return {"message": "Item removed successfully"}
|
return {"message": "Item removed successfully"}
|
||||||
@@ -809,15 +892,56 @@ async def reorder_story_items(
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@app.put("/stories/{story_id}/items/{generation_id}/move", response_model=models.StoryItemDetail)
|
@app.put("/stories/{story_id}/items/{item_id}/move", response_model=models.StoryItemDetail)
|
||||||
async def move_story_item(
|
async def move_story_item(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
generation_id: str,
|
item_id: str,
|
||||||
data: models.StoryItemMove,
|
data: models.StoryItemMove,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Move a story item (update position and/or track)."""
|
"""Move a story item (update position and/or track)."""
|
||||||
item = await stories.move_story_item(story_id, generation_id, data, db)
|
item = await stories.move_story_item(story_id, item_id, data, db)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/stories/{story_id}/items/{item_id}/trim", response_model=models.StoryItemDetail)
|
||||||
|
async def trim_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: models.StoryItemTrim,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Trim a story item (update trim_start_ms and trim_end_ms)."""
|
||||||
|
item = await stories.trim_story_item(story_id, item_id, data, db)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found or invalid trim values")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/stories/{story_id}/items/{item_id}/split", response_model=List[models.StoryItemDetail])
|
||||||
|
async def split_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: models.StoryItemSplit,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Split a story item at a given time, creating two clips."""
|
||||||
|
items = await stories.split_story_item(story_id, item_id, data, db)
|
||||||
|
if items is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Story item not found or invalid split point")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/stories/{story_id}/items/{item_id}/duplicate", response_model=models.StoryItemDetail)
|
||||||
|
async def duplicate_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Duplicate a story item, creating a copy with all properties."""
|
||||||
|
item = await stories.duplicate_story_item(story_id, item_id, db)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Story item not found")
|
raise HTTPException(status_code=404, detail="Story item not found")
|
||||||
return item
|
return item
|
||||||
@@ -953,7 +1077,7 @@ async def get_model_progress(model_name: str):
|
|||||||
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
||||||
async def get_model_status():
|
async def get_model_status():
|
||||||
"""Get status of all available models."""
|
"""Get status of all available models."""
|
||||||
from huggingface_hub import hf_hub_download
|
from huggingface_hub import hf_hub_download, constants as hf_constants
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -1056,10 +1180,10 @@ async def get_model_status():
|
|||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
|
|
||||||
# Method 2: Fallback to checking cache directory directly
|
# Method 2: Fallback to checking cache directory directly (using HuggingFace's OS-specific cache location)
|
||||||
if not downloaded:
|
if not downloaded:
|
||||||
try:
|
try:
|
||||||
cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
|
cache_dir = hf_constants.HF_HUB_CACHE
|
||||||
repo_cache = Path(cache_dir) / "models--" + config["hf_repo_id"].replace("/", "--")
|
repo_cache = Path(cache_dir) / "models--" + config["hf_repo_id"].replace("/", "--")
|
||||||
|
|
||||||
if repo_cache.exists():
|
if repo_cache.exists():
|
||||||
@@ -1168,22 +1292,22 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
|||||||
|
|
||||||
config = model_configs[request.model_name]
|
config = model_configs[request.model_name]
|
||||||
|
|
||||||
try:
|
async def download_in_background():
|
||||||
# Start tracking download
|
"""Download model in background without blocking the HTTP request."""
|
||||||
task_manager.start_download(request.model_name)
|
try:
|
||||||
|
await asyncio.to_thread(config["load_func"])
|
||||||
|
task_manager.complete_download(request.model_name)
|
||||||
|
except Exception as e:
|
||||||
|
task_manager.error_download(request.model_name, str(e))
|
||||||
|
|
||||||
# Trigger download by loading the model (which will download if not cached)
|
# Start tracking download
|
||||||
# Run in background to avoid blocking
|
task_manager.start_download(request.model_name)
|
||||||
await asyncio.to_thread(config["load_func"])
|
|
||||||
|
|
||||||
# Mark download as complete
|
# Start download in background task (don't await)
|
||||||
task_manager.complete_download(request.model_name)
|
asyncio.create_task(download_in_background())
|
||||||
|
|
||||||
return {"message": f"Model {request.model_name} download started"}
|
# Return immediately - frontend should poll progress endpoint
|
||||||
except Exception as e:
|
return {"message": f"Model {request.model_name} download started"}
|
||||||
# Mark download as failed
|
|
||||||
task_manager.error_download(request.model_name, str(e))
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/models/{model_name}")
|
@app.delete("/models/{model_name}")
|
||||||
@@ -1191,6 +1315,7 @@ async def delete_model(model_name: str):
|
|||||||
"""Delete a downloaded model from the HuggingFace cache."""
|
"""Delete a downloaded model from the HuggingFace cache."""
|
||||||
import shutil
|
import shutil
|
||||||
import os
|
import os
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
|
||||||
# Map model names to HuggingFace repo IDs
|
# Map model names to HuggingFace repo IDs
|
||||||
model_configs = {
|
model_configs = {
|
||||||
@@ -1243,8 +1368,8 @@ async def delete_model(model_name: str):
|
|||||||
if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
|
if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
|
||||||
transcribe.unload_whisper_model()
|
transcribe.unload_whisper_model()
|
||||||
|
|
||||||
# Find and delete the cache directory
|
# Find and delete the cache directory (using HuggingFace's OS-specific cache location)
|
||||||
cache_dir = os.path.expanduser("~/.cache/huggingface/hub")
|
cache_dir = hf_constants.HF_HUB_CACHE
|
||||||
repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
|
repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
|
||||||
|
|
||||||
# Check if the cache directory exists
|
# Check if the cache directory exists
|
||||||
@@ -1355,6 +1480,16 @@ async def startup_event():
|
|||||||
print(f"Database initialized at {database._db_path}")
|
print(f"Database initialized at {database._db_path}")
|
||||||
print(f"GPU available: {_get_gpu_status()}")
|
print(f"GPU available: {_get_gpu_status()}")
|
||||||
|
|
||||||
|
# Ensure HuggingFace cache directory exists
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"HuggingFace cache directory: {cache_dir}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not create HuggingFace cache directory: {e}")
|
||||||
|
print("Model downloads may fail. Please ensure the directory exists and has write permissions.")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ class HealthResponse(BaseModel):
|
|||||||
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
|
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
|
||||||
model_size: Optional[str] = None # Current model size if loaded
|
model_size: Optional[str] = None # Current model size if loaded
|
||||||
gpu_available: bool
|
gpu_available: bool
|
||||||
|
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
|
||||||
vram_used_mb: Optional[float] = None
|
vram_used_mb: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -221,6 +222,8 @@ class StoryItemDetail(BaseModel):
|
|||||||
generation_id: str
|
generation_id: str
|
||||||
start_time_ms: int
|
start_time_ms: int
|
||||||
track: int = 0
|
track: int = 0
|
||||||
|
trim_start_ms: int = 0
|
||||||
|
trim_end_ms: int = 0
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
# Generation details
|
# Generation details
|
||||||
profile_id: str
|
profile_id: str
|
||||||
@@ -277,3 +280,14 @@ class StoryItemMove(BaseModel):
|
|||||||
"""Request model for moving a story item (position and/or track)."""
|
"""Request model for moving a story item (position and/or track)."""
|
||||||
start_time_ms: int = Field(..., ge=0)
|
start_time_ms: int = Field(..., ge=0)
|
||||||
track: int = 0
|
track: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemTrim(BaseModel):
|
||||||
|
"""Request model for trimming a story item."""
|
||||||
|
trim_start_ms: int = Field(..., ge=0)
|
||||||
|
trim_end_ms: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemSplit(BaseModel):
|
||||||
|
"""Request model for splitting a story item."""
|
||||||
|
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
|
||||||
|
|||||||
+311
-11
@@ -18,6 +18,8 @@ from .models import (
|
|||||||
StoryItemCreate,
|
StoryItemCreate,
|
||||||
StoryItemBatchUpdate,
|
StoryItemBatchUpdate,
|
||||||
StoryItemMove,
|
StoryItemMove,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryItemSplit,
|
||||||
)
|
)
|
||||||
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
from .utils.audio import load_audio, save_audio
|
from .utils.audio import load_audio, save_audio
|
||||||
@@ -129,6 +131,8 @@ async def get_story(
|
|||||||
generation_id=item.generation_id,
|
generation_id=item.generation_id,
|
||||||
start_time_ms=item.start_time_ms,
|
start_time_ms=item.start_time_ms,
|
||||||
track=item.track,
|
track=item.track,
|
||||||
|
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||||
created_at=item.created_at,
|
created_at=item.created_at,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
@@ -252,6 +256,8 @@ async def add_item_to_story(
|
|||||||
generation_id=existing.generation_id,
|
generation_id=existing.generation_id,
|
||||||
start_time_ms=existing.start_time_ms,
|
start_time_ms=existing.start_time_ms,
|
||||||
track=existing.track,
|
track=existing.track,
|
||||||
|
trim_start_ms=getattr(existing, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(existing, 'trim_end_ms', 0),
|
||||||
created_at=existing.created_at,
|
created_at=existing.created_at,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
profile_name=profile.name if profile else "Unknown",
|
profile_name=profile.name if profile else "Unknown",
|
||||||
@@ -321,6 +327,8 @@ async def add_item_to_story(
|
|||||||
generation_id=item.generation_id,
|
generation_id=item.generation_id,
|
||||||
start_time_ms=item.start_time_ms,
|
start_time_ms=item.start_time_ms,
|
||||||
track=item.track,
|
track=item.track,
|
||||||
|
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||||
created_at=item.created_at,
|
created_at=item.created_at,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
profile_name=profile.name if profile else "Unknown",
|
profile_name=profile.name if profile else "Unknown",
|
||||||
@@ -336,7 +344,7 @@ async def add_item_to_story(
|
|||||||
|
|
||||||
async def move_story_item(
|
async def move_story_item(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
generation_id: str,
|
item_id: str,
|
||||||
data: StoryItemMove,
|
data: StoryItemMove,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> Optional[StoryItemDetail]:
|
) -> Optional[StoryItemDetail]:
|
||||||
@@ -345,7 +353,7 @@ async def move_story_item(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
story_id: Story ID
|
story_id: Story ID
|
||||||
generation_id: Generation ID of the item to move
|
item_id: Story item ID
|
||||||
data: New position and track data
|
data: New position and track data
|
||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
@@ -354,14 +362,14 @@ async def move_story_item(
|
|||||||
"""
|
"""
|
||||||
# Get the item
|
# Get the item
|
||||||
item = db.query(DBStoryItem).filter_by(
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
story_id=story_id,
|
story_id=story_id,
|
||||||
generation_id=generation_id
|
|
||||||
).first()
|
).first()
|
||||||
if not item:
|
if not item:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get the generation
|
# Get the generation
|
||||||
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||||
if not generation:
|
if not generation:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -386,6 +394,8 @@ async def move_story_item(
|
|||||||
generation_id=item.generation_id,
|
generation_id=item.generation_id,
|
||||||
start_time_ms=item.start_time_ms,
|
start_time_ms=item.start_time_ms,
|
||||||
track=item.track,
|
track=item.track,
|
||||||
|
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||||
created_at=item.created_at,
|
created_at=item.created_at,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
profile_name=profile.name if profile else "Unknown",
|
profile_name=profile.name if profile else "Unknown",
|
||||||
@@ -401,23 +411,23 @@ async def move_story_item(
|
|||||||
|
|
||||||
async def remove_item_from_story(
|
async def remove_item_from_story(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
generation_id: str,
|
item_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Remove a generation from a story.
|
Remove a story item from a story.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
story_id: Story ID
|
story_id: Story ID
|
||||||
generation_id: Generation ID to remove
|
item_id: Story item ID to remove
|
||||||
db: Database session
|
db: Database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if removed, False if not found
|
True if removed, False if not found
|
||||||
"""
|
"""
|
||||||
item = db.query(DBStoryItem).filter_by(
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
story_id=story_id,
|
story_id=story_id,
|
||||||
generation_id=generation_id
|
|
||||||
).first()
|
).first()
|
||||||
if not item:
|
if not item:
|
||||||
return False
|
return False
|
||||||
@@ -434,6 +444,277 @@ async def remove_item_from_story(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def trim_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: StoryItemTrim,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryItemDetail]:
|
||||||
|
"""
|
||||||
|
Trim a story item (update trim_start_ms and trim_end_ms).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
item_id: Story item ID
|
||||||
|
data: Trim data (trim_start_ms, trim_end_ms)
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated item detail or None if not found
|
||||||
|
"""
|
||||||
|
# Get the item
|
||||||
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
|
story_id=story_id,
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the generation
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate trim values don't exceed duration
|
||||||
|
max_duration_ms = int(generation.duration * 1000)
|
||||||
|
if data.trim_start_ms + data.trim_end_ms >= max_duration_ms:
|
||||||
|
return None # Invalid trim - would result in zero or negative duration
|
||||||
|
|
||||||
|
# Update trim values
|
||||||
|
item.trim_start_ms = data.trim_start_ms
|
||||||
|
item.trim_end_ms = data.trim_end_ms
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
|
||||||
|
# Get profile name
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
|
return StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=item.trim_start_ms,
|
||||||
|
trim_end_ms=item.trim_end_ms,
|
||||||
|
created_at=item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile.name if profile else "Unknown",
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def split_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
data: StoryItemSplit,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[List[StoryItemDetail]]:
|
||||||
|
"""
|
||||||
|
Split a story item at a given time, creating two clips.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
item_id: Story item ID to split
|
||||||
|
data: Split data (split_time_ms - time within clip to split at)
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of two updated item details (original and new) or None if not found/invalid
|
||||||
|
"""
|
||||||
|
# Get the item
|
||||||
|
item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
|
story_id=story_id,
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the generation
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate effective duration and validate split point
|
||||||
|
current_trim_start = getattr(item, 'trim_start_ms', 0)
|
||||||
|
current_trim_end = getattr(item, 'trim_end_ms', 0)
|
||||||
|
original_duration_ms = int(generation.duration * 1000)
|
||||||
|
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
|
||||||
|
|
||||||
|
# Validate split_time_ms is within the effective duration
|
||||||
|
if data.split_time_ms <= 0 or data.split_time_ms >= effective_duration_ms:
|
||||||
|
return None # Invalid split point
|
||||||
|
|
||||||
|
# Calculate the absolute time in the original audio where we're splitting
|
||||||
|
absolute_split_ms = current_trim_start + data.split_time_ms
|
||||||
|
|
||||||
|
# Update original clip: trim from the end
|
||||||
|
item.trim_end_ms = original_duration_ms - absolute_split_ms
|
||||||
|
|
||||||
|
# Create new clip: starts after the split, trimmed from the start
|
||||||
|
new_item = DBStoryItem(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=item.generation_id, # Same generation, different trim
|
||||||
|
start_time_ms=item.start_time_ms + data.split_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=absolute_split_ms,
|
||||||
|
trim_end_ms=current_trim_end,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_item)
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
db.refresh(new_item)
|
||||||
|
|
||||||
|
# Get profile name
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
profile_name = profile.name if profile else "Unknown"
|
||||||
|
|
||||||
|
# Build response items
|
||||||
|
original_item_detail = StoryItemDetail(
|
||||||
|
id=item.id,
|
||||||
|
story_id=item.story_id,
|
||||||
|
generation_id=item.generation_id,
|
||||||
|
start_time_ms=item.start_time_ms,
|
||||||
|
track=item.track,
|
||||||
|
trim_start_ms=item.trim_start_ms,
|
||||||
|
trim_end_ms=item.trim_end_ms,
|
||||||
|
created_at=item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_item_detail = StoryItemDetail(
|
||||||
|
id=new_item.id,
|
||||||
|
story_id=new_item.story_id,
|
||||||
|
generation_id=new_item.generation_id,
|
||||||
|
start_time_ms=new_item.start_time_ms,
|
||||||
|
track=new_item.track,
|
||||||
|
trim_start_ms=new_item.trim_start_ms,
|
||||||
|
trim_end_ms=new_item.trim_end_ms,
|
||||||
|
created_at=new_item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [original_item_detail, new_item_detail]
|
||||||
|
|
||||||
|
|
||||||
|
async def duplicate_story_item(
|
||||||
|
story_id: str,
|
||||||
|
item_id: str,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[StoryItemDetail]:
|
||||||
|
"""
|
||||||
|
Duplicate a story item, creating a copy with all properties.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story_id: Story ID
|
||||||
|
item_id: Story item ID to duplicate
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New item detail or None if not found
|
||||||
|
"""
|
||||||
|
# Get the original item
|
||||||
|
original_item = db.query(DBStoryItem).filter_by(
|
||||||
|
id=item_id,
|
||||||
|
story_id=story_id,
|
||||||
|
).first()
|
||||||
|
if not original_item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the generation
|
||||||
|
generation = db.query(DBGeneration).filter_by(id=original_item.generation_id).first()
|
||||||
|
if not generation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate effective duration
|
||||||
|
current_trim_start = getattr(original_item, 'trim_start_ms', 0)
|
||||||
|
current_trim_end = getattr(original_item, 'trim_end_ms', 0)
|
||||||
|
original_duration_ms = int(generation.duration * 1000)
|
||||||
|
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
|
||||||
|
|
||||||
|
# Create duplicate item - place it right after the original
|
||||||
|
new_item = DBStoryItem(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
story_id=story_id,
|
||||||
|
generation_id=original_item.generation_id, # Same generation as original
|
||||||
|
start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap
|
||||||
|
track=original_item.track,
|
||||||
|
trim_start_ms=current_trim_start,
|
||||||
|
trim_end_ms=current_trim_end,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_item)
|
||||||
|
|
||||||
|
# Update story updated_at
|
||||||
|
story = db.query(DBStory).filter_by(id=story_id).first()
|
||||||
|
if story:
|
||||||
|
story.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_item)
|
||||||
|
|
||||||
|
# Get profile name
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
|
||||||
|
|
||||||
|
return StoryItemDetail(
|
||||||
|
id=new_item.id,
|
||||||
|
story_id=new_item.story_id,
|
||||||
|
generation_id=new_item.generation_id,
|
||||||
|
start_time_ms=new_item.start_time_ms,
|
||||||
|
track=new_item.track,
|
||||||
|
trim_start_ms=new_item.trim_start_ms,
|
||||||
|
trim_end_ms=new_item.trim_end_ms,
|
||||||
|
created_at=new_item.created_at,
|
||||||
|
profile_id=generation.profile_id,
|
||||||
|
profile_name=profile.name if profile else "Unknown",
|
||||||
|
text=generation.text,
|
||||||
|
language=generation.language,
|
||||||
|
audio_path=generation.audio_path,
|
||||||
|
duration=generation.duration,
|
||||||
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
|
generation_created_at=generation.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def update_story_item_times(
|
async def update_story_item_times(
|
||||||
story_id: str,
|
story_id: str,
|
||||||
data: StoryItemBatchUpdate,
|
data: StoryItemBatchUpdate,
|
||||||
@@ -538,6 +819,8 @@ async def reorder_story_items(
|
|||||||
generation_id=item.generation_id,
|
generation_id=item.generation_id,
|
||||||
start_time_ms=item.start_time_ms,
|
start_time_ms=item.start_time_ms,
|
||||||
track=item.track,
|
track=item.track,
|
||||||
|
trim_start_ms=getattr(item, 'trim_start_ms', 0),
|
||||||
|
trim_end_ms=getattr(item, 'trim_end_ms', 0),
|
||||||
created_at=item.created_at,
|
created_at=item.created_at,
|
||||||
profile_id=generation.profile_id,
|
profile_id=generation.profile_id,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
@@ -602,14 +885,31 @@ async def export_story_audio(
|
|||||||
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
|
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
|
||||||
sample_rate = sr # Use actual sample rate from first file
|
sample_rate = sr # Use actual sample rate from first file
|
||||||
|
|
||||||
|
# Get trim values
|
||||||
|
trim_start_ms = getattr(item, 'trim_start_ms', 0)
|
||||||
|
trim_end_ms = getattr(item, 'trim_end_ms', 0)
|
||||||
|
|
||||||
|
# Calculate effective duration
|
||||||
|
original_duration_ms = int(generation.duration * 1000)
|
||||||
|
effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms
|
||||||
|
|
||||||
|
# Slice audio based on trim values
|
||||||
|
trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate)
|
||||||
|
trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate)
|
||||||
|
|
||||||
|
# Extract the trimmed portion
|
||||||
|
if trim_end_ms > 0:
|
||||||
|
trimmed_audio = audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:]
|
||||||
|
else:
|
||||||
|
trimmed_audio = audio[trim_start_sample:]
|
||||||
|
|
||||||
# Store audio with its timecode info
|
# Store audio with its timecode info
|
||||||
start_time_ms = item.start_time_ms
|
start_time_ms = item.start_time_ms
|
||||||
duration_ms = int(generation.duration * 1000)
|
|
||||||
|
|
||||||
audio_data.append({
|
audio_data.append({
|
||||||
'audio': audio,
|
'audio': trimmed_audio,
|
||||||
'start_time_ms': start_time_ms,
|
'start_time_ms': start_time_ms,
|
||||||
'duration_ms': duration_ms,
|
'duration_ms': effective_duration_ms,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
# Skip files that can't be loaded
|
# Skip files that can't be loaded
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.6",
|
"version": "0.1.8",
|
||||||
"description": "Landing page for voicebox.sh",
|
"description": "Landing page for voicebox.sh",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --bun next dev --turbo",
|
"dev": "bun --bun next dev --turbo",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
@@ -99,7 +99,7 @@ export default function Home() {
|
|||||||
<div className="flex justify-center lg:hidden my-8">
|
<div className="flex justify-center lg:hidden my-8">
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<Image
|
<Image
|
||||||
src="/VoiceBoxAppScreenshot.webp"
|
src="/assets/app-screenshot-1.webp"
|
||||||
alt="Voicebox Application Screenshot"
|
alt="Voicebox Application Screenshot"
|
||||||
width={1920}
|
width={1920}
|
||||||
height={1080}
|
height={1080}
|
||||||
@@ -180,7 +180,7 @@ export default function Home() {
|
|||||||
<div className="hidden lg:block relative">
|
<div className="hidden lg:block relative">
|
||||||
<div className="absolute right-0 top-0 -mt-10 w-[200%] -mr-[100%]">
|
<div className="absolute right-0 top-0 -mt-10 w-[200%] -mr-[100%]">
|
||||||
<Image
|
<Image
|
||||||
src="/VoiceBoxAppScreenshot.webp"
|
src="/assets/app-screenshot-1.webp"
|
||||||
alt="Voicebox Application Screenshot"
|
alt="Voicebox Application Screenshot"
|
||||||
width={1920}
|
width={1920}
|
||||||
height={1080}
|
height={1080}
|
||||||
@@ -193,6 +193,41 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Screenshots Section */}
|
||||||
|
<section className="py-12 sm:py-16 md:py-20">
|
||||||
|
<div className="w-full md:w-[150%] md:-ml-[25%]">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 px-8">
|
||||||
|
<div className="w-full">
|
||||||
|
<Image
|
||||||
|
src="/assets/app-screenshot-2.webp"
|
||||||
|
alt="Voicebox Screenshot 2"
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<Image
|
||||||
|
src="/assets/app-screenshot-1.webp"
|
||||||
|
alt="Voicebox Screenshot 1"
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<Image
|
||||||
|
src="/assets/app-screenshot-3.webp"
|
||||||
|
alt="Voicebox Screenshot 3"
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Demo Video Section */}
|
{/* Demo Video Section */}
|
||||||
<section className="py-12 sm:py-16 md:py-20">
|
<section className="py-12 sm:py-16 md:py-20">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
@@ -207,7 +242,7 @@ export default function Home() {
|
|||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
poster="/VoiceBoxAppScreenshot.webp"
|
poster="/assets/app-screenshot-1.webp"
|
||||||
>
|
>
|
||||||
<source
|
<source
|
||||||
src="/voicebox-demo.webm"
|
src="/voicebox-demo.webm"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "voicebox",
|
"name": "voicebox",
|
||||||
"version": "0.1.6",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"app",
|
"app",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.6",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+219
-6
@@ -406,6 +406,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -429,9 +439,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -442,7 +452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -889,6 +899,15 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -896,7 +915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -910,6 +929,12 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -942,6 +967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1311,6 +1337,25 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.13.0",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1408,6 +1453,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1436,6 +1482,22 @@ dependencies = [
|
|||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
@@ -1455,9 +1517,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2024,6 +2088,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -2430,6 +2511,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.75"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.111"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3077,16 +3202,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -3097,6 +3228,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -3232,6 +3364,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3295,6 +3436,29 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ccf069cb109cf8e01ebdca0d55dfce45dbbf669e8c56ed5c62150b056d3ec9f"
|
checksum = "6ccf069cb109cf8e01ebdca0d55dfce45dbbf669e8c56ed5c62150b056d3ec9f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -3931,6 +4095,27 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3952,7 +4137,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
@@ -4495,6 +4680,16 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -4826,6 +5021,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -4840,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.5"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
@@ -4848,6 +5049,7 @@ dependencies = [
|
|||||||
"cpal",
|
"cpal",
|
||||||
"hound",
|
"hound",
|
||||||
"objc",
|
"objc",
|
||||||
|
"reqwest",
|
||||||
"scopeguard",
|
"scopeguard",
|
||||||
"screencapturekit",
|
"screencapturekit",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5330,6 +5532,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-result 0.4.1",
|
||||||
|
"windows-strings 0.5.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
@@ -20,6 +20,7 @@ tauri-plugin-shell = "2.0"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
hound = "3.5"
|
hound = "3.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
|
|||||||
Binary file not shown.
@@ -5948,6 +5948,36 @@
|
|||||||
"const": "fs:write-files",
|
"const": "fs:write-files",
|
||||||
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:default",
|
||||||
|
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the exit command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:allow-exit",
|
||||||
|
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the restart command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:allow-restart",
|
||||||
|
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the exit command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:deny-exit",
|
||||||
|
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the restart command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:deny-restart",
|
||||||
|
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
+153
-10
@@ -303,6 +303,60 @@ async fn start_server(
|
|||||||
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
|
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a Windows process is still running
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_process_running(pid: u32) -> bool {
|
||||||
|
use std::process::Command;
|
||||||
|
if let Ok(output) = Command::new("tasklist")
|
||||||
|
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
// If process exists, tasklist returns it in output
|
||||||
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||||
|
return !output_str.trim().is_empty() && output_str.contains(&pid.to_string());
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill entire Windows process tree by enumerating children
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn kill_windows_process_tree(parent_pid: u32) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Find all child processes using WMIC
|
||||||
|
let output = Command::new("wmic")
|
||||||
|
.args([
|
||||||
|
"process",
|
||||||
|
"where",
|
||||||
|
&format!("ParentProcessId={}", parent_pid),
|
||||||
|
"get",
|
||||||
|
"ProcessId"
|
||||||
|
])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
if let Ok(output) = output {
|
||||||
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||||
|
for line in output_str.lines().skip(1) { // Skip header
|
||||||
|
if let Ok(child_pid) = line.trim().parse::<u32>() {
|
||||||
|
println!("Found child process: {}", child_pid);
|
||||||
|
// Recursively kill child's children
|
||||||
|
let _ = kill_windows_process_tree(child_pid);
|
||||||
|
// Kill the child
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.args(["/PID", &child_pid.to_string(), "/F"])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill the parent process
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.args(["/PID", &parent_pid.to_string(), "/F"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
||||||
let pid = state.server_pid.lock().unwrap().take();
|
let pid = state.server_pid.lock().unwrap().take();
|
||||||
@@ -332,13 +386,59 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use std::process::Command;
|
// Layer 1: Try graceful HTTP shutdown first
|
||||||
let _ = Command::new("taskkill")
|
println!("Attempting graceful shutdown via HTTP...");
|
||||||
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
let client = reqwest::blocking::Client::builder()
|
||||||
.output();
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let shutdown_result = client
|
||||||
|
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
|
||||||
|
.send();
|
||||||
|
|
||||||
|
if shutdown_result.is_ok() {
|
||||||
|
println!("HTTP shutdown sent, waiting for graceful exit...");
|
||||||
|
// Wait up to 3 seconds for graceful shutdown
|
||||||
|
for i in 0..30 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
if !is_process_running(pid) {
|
||||||
|
println!("Process exited gracefully after {}ms", i * 100);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Graceful shutdown timed out, forcing kill...");
|
||||||
|
} else {
|
||||||
|
println!("HTTP shutdown failed, forcing kill...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: Kill process tree with enumeration
|
||||||
|
println!("Killing process tree for wrapper PID {}...", pid);
|
||||||
|
kill_windows_process_tree(pid)?;
|
||||||
|
|
||||||
|
// Layer 3: Verify and kill by name if still running
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
if is_process_running(pid) {
|
||||||
|
println!("Process tree kill failed, killing by name...");
|
||||||
|
use std::process::Command;
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.args(["/IM", "voicebox-server.exe", "/T", "/F"])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 4: Final verification
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
if is_process_running(pid) {
|
||||||
|
eprintln!("WARNING: Failed to kill server after all attempts");
|
||||||
|
} else {
|
||||||
|
println!("Server killed successfully");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("stop_server: Process group kill completed");
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
println!("stop_server: Process group kill completed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -560,11 +660,54 @@ pub fn run() {
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// On Windows, use taskkill with /T to kill child processes
|
// Layer 1: Try graceful HTTP shutdown first
|
||||||
use std::process::Command;
|
println!("Attempting graceful shutdown via HTTP...");
|
||||||
let _ = Command::new("taskkill")
|
let client = reqwest::blocking::Client::builder()
|
||||||
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
.output();
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let shutdown_result = client
|
||||||
|
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
|
||||||
|
.send();
|
||||||
|
|
||||||
|
if shutdown_result.is_ok() {
|
||||||
|
println!("HTTP shutdown sent, waiting for graceful exit...");
|
||||||
|
// Wait up to 3 seconds for graceful shutdown
|
||||||
|
for i in 0..30 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
if !is_process_running(pid) {
|
||||||
|
println!("Process exited gracefully after {}ms", i * 100);
|
||||||
|
println!("Server process tree kill completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Graceful shutdown timed out, forcing kill...");
|
||||||
|
} else {
|
||||||
|
println!("HTTP shutdown failed, forcing kill...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: Kill process tree with enumeration
|
||||||
|
println!("Killing process tree for wrapper PID {}...", pid);
|
||||||
|
let _ = kill_windows_process_tree(pid);
|
||||||
|
|
||||||
|
// Layer 3: Verify and kill by name if still running
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
if is_process_running(pid) {
|
||||||
|
println!("Process tree kill failed, killing by name...");
|
||||||
|
use std::process::Command;
|
||||||
|
let _ = Command::new("taskkill")
|
||||||
|
.args(["/IM", "voicebox-server.exe", "/T", "/F"])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 4: Final verification
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
if is_process_running(pid) {
|
||||||
|
eprintln!("WARNING: Failed to kill server after all attempts");
|
||||||
|
} else {
|
||||||
|
println!("Server killed successfully");
|
||||||
|
}
|
||||||
println!("Server process tree kill completed");
|
println!("Server process tree kill completed");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Voicebox",
|
"productName": "Voicebox",
|
||||||
"version": "0.1.6",
|
"version": "0.1.8",
|
||||||
"identifier": "sh.voicebox.app",
|
"identifier": "sh.voicebox.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.6",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user