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:
2026-02-26 16:18:54 -08:00
parent 44480906a4
commit 415a648a2b
9 changed files with 557 additions and 9 deletions

View File

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