Phase 4: Export to SRT, WebVTT, ASS, plain text, and Markdown
- Implement ExportService using pysubs2 for caption formats (SRT, VTT, ASS) and custom formatters for plain text and Markdown - SRT exports with [Speaker]: prefix, WebVTT with <v Speaker> voice tags, ASS with color-coded speaker styles - Plain text groups by speaker with labels, Markdown adds timestamps - Add export.start IPC handler and export_transcript Tauri command - Add export dropdown menu in header (appears after transcription) - Uses native save dialog for output file selection - Add pysubs2 dependency - Tests: 30 Python (6 export tests), 6 Rust, 0 Svelte errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { open, save } from '@tauri-apps/plugin-dialog';
|
||||
import WaveformPlayer from '$lib/components/WaveformPlayer.svelte';
|
||||
import TranscriptEditor from '$lib/components/TranscriptEditor.svelte';
|
||||
import SpeakerManager from '$lib/components/SpeakerManager.svelte';
|
||||
@@ -109,6 +109,56 @@
|
||||
isTranscribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
const exportFormats = [
|
||||
{ name: 'SubRip Subtitle', ext: 'srt', format: 'srt' },
|
||||
{ name: 'WebVTT', ext: 'vtt', format: 'vtt' },
|
||||
{ name: 'Advanced SubStation Alpha', ext: 'ass', format: 'ass' },
|
||||
{ name: 'Plain Text', ext: 'txt', format: 'txt' },
|
||||
{ name: 'Markdown', ext: 'md', format: 'md' },
|
||||
];
|
||||
|
||||
let showExportMenu = $state(false);
|
||||
|
||||
async function handleExport(format: string, ext: string, filterName: string) {
|
||||
showExportMenu = false;
|
||||
|
||||
const outputPath = await save({
|
||||
filters: [{ name: filterName, extensions: [ext] }],
|
||||
});
|
||||
if (!outputPath) return;
|
||||
|
||||
// Build speaker lookup: speaker_id → display_name
|
||||
const speakerMap: Record<string, string> = {};
|
||||
for (const s of $speakers) {
|
||||
speakerMap[s.label] = s.display_name || s.label;
|
||||
}
|
||||
|
||||
// Build export segments from store
|
||||
const exportSegments = $segments.map(seg => {
|
||||
const speaker = $speakers.find(s => s.id === seg.speaker_id);
|
||||
return {
|
||||
text: seg.text,
|
||||
start_ms: seg.start_ms,
|
||||
end_ms: seg.end_ms,
|
||||
speaker: speaker?.label ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await invoke('export_transcript', {
|
||||
segments: exportSegments,
|
||||
speakers: speakerMap,
|
||||
format,
|
||||
outputPath,
|
||||
title: 'Voice to Notes Transcript',
|
||||
});
|
||||
alert(`Exported to ${outputPath}`);
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
alert(`Export failed: ${err}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-header">
|
||||
@@ -117,6 +167,22 @@
|
||||
<button class="import-btn" onclick={handleFileImport}>
|
||||
Import Audio/Video
|
||||
</button>
|
||||
{#if $segments.length > 0}
|
||||
<div class="export-dropdown">
|
||||
<button class="export-btn" onclick={() => showExportMenu = !showExportMenu}>
|
||||
Export
|
||||
</button>
|
||||
{#if showExportMenu}
|
||||
<div class="export-menu">
|
||||
{#each exportFormats as fmt}
|
||||
<button class="export-option" onclick={() => handleExport(fmt.format, fmt.ext, fmt.name)}>
|
||||
{fmt.name} (.{fmt.ext})
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +230,53 @@
|
||||
.import-btn:hover {
|
||||
background: #d63851;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.export-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
.export-btn {
|
||||
background: #0f3460;
|
||||
border: 1px solid #4a5568;
|
||||
color: #e0e0e0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.export-btn:hover {
|
||||
background: #1a4a7a;
|
||||
}
|
||||
.export-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background: #16213e;
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
min-width: 220px;
|
||||
}
|
||||
.export-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.export-option:hover {
|
||||
background: rgba(233, 69, 96, 0.2);
|
||||
}
|
||||
.workspace {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
Reference in New Issue
Block a user