Ny funksjonalitet for å kjøre re-transkripsjon på eksisterende media-noder og sammenligne gammel vs ny versjon per segment. Manuelt redigerte segmenter fra forrige versjon blir uthevet, og brukeren velger per segment hvilken versjon som skal beholdes. Backend (Rust): - POST /intentions/retranscribe — trigger ny Whisper-jobb for media-node - GET /query/transcription_versions — list alle versjoner for en node - GET /query/segments_version — hent segmenter for spesifikk versjon - POST /intentions/resolve_retranscription — anvend per-segment-valg Frontend (Svelte): - RetranscriptionCompare.svelte — side-om-side visning med per-segment-valg - TranscriptionView: re-transkriber-knapp, auto-detect nye versjoner, polling - API-klient: nye funksjoner for alle re-transkripsjonsendepunkter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
171 lines
4.8 KiB
Svelte
171 lines
4.8 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import WaveSurfer from 'wavesurfer.js';
|
|
import TranscriptionView from './TranscriptionView.svelte';
|
|
|
|
interface Props {
|
|
/** CAS URL for the audio file */
|
|
src: string;
|
|
/** Duration in seconds (from transcription metadata, used as fallback) */
|
|
duration?: number;
|
|
/** Flat transcript text (fallback if no segments available) */
|
|
transcript?: string;
|
|
/** Compact mode for chat bubbles */
|
|
compact?: boolean;
|
|
/** Media node ID — enables segment-level transcription view */
|
|
nodeId?: string;
|
|
/** Access token for fetching segments */
|
|
accessToken?: string;
|
|
}
|
|
|
|
let { src, duration, transcript, compact = false, nodeId, accessToken }: Props = $props();
|
|
|
|
let container: HTMLDivElement | undefined = $state();
|
|
let wavesurfer: WaveSurfer | undefined = $state();
|
|
let playing = $state(false);
|
|
let currentTime = $state(0);
|
|
let totalDuration = $state(0);
|
|
$effect(() => { if (duration && !ready) totalDuration = duration; });
|
|
let ready = $state(false);
|
|
let loadError = $state(false);
|
|
let showTranscript = $state(false);
|
|
|
|
/** Indikerer om noden har segment-data (satt via metadata) */
|
|
const hasSegments = $derived(!!nodeId && !!accessToken);
|
|
|
|
function formatTime(seconds: number): string {
|
|
if (!seconds || !isFinite(seconds)) return '0:00';
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function seekTo(timeSeconds: number) {
|
|
if (!wavesurfer || !ready) return;
|
|
wavesurfer.setTime(timeSeconds);
|
|
if (!playing) {
|
|
wavesurfer.play();
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
if (!container) return;
|
|
|
|
wavesurfer = WaveSurfer.create({
|
|
container,
|
|
height: compact ? 32 : 48,
|
|
waveColor: '#93c5fd',
|
|
progressColor: '#2563eb',
|
|
cursorColor: '#1d4ed8',
|
|
cursorWidth: 2,
|
|
barWidth: 2,
|
|
barGap: 1,
|
|
barRadius: 2,
|
|
normalize: true,
|
|
url: src,
|
|
});
|
|
|
|
wavesurfer.on('ready', () => {
|
|
ready = true;
|
|
totalDuration = wavesurfer!.getDuration();
|
|
});
|
|
|
|
wavesurfer.on('timeupdate', (time: number) => {
|
|
currentTime = time;
|
|
});
|
|
|
|
wavesurfer.on('finish', () => {
|
|
playing = false;
|
|
});
|
|
|
|
wavesurfer.on('error', () => {
|
|
loadError = true;
|
|
});
|
|
|
|
wavesurfer.on('play', () => {
|
|
playing = true;
|
|
});
|
|
|
|
wavesurfer.on('pause', () => {
|
|
playing = false;
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
wavesurfer?.destroy();
|
|
});
|
|
|
|
function togglePlayback() {
|
|
wavesurfer?.playPause();
|
|
}
|
|
</script>
|
|
|
|
<div class="audio-player flex flex-col gap-1 {compact ? 'min-w-[200px]' : 'min-w-[240px]'}">
|
|
<div class="flex items-center gap-2">
|
|
<!-- Play/pause button -->
|
|
<button
|
|
onclick={togglePlayback}
|
|
disabled={!ready && !loadError}
|
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors
|
|
{ready ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-200 text-gray-400'}
|
|
disabled:cursor-not-allowed"
|
|
aria-label={playing ? 'Pause' : 'Spill av'}
|
|
>
|
|
{#if !ready && !loadError}
|
|
<svg class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
{:else if playing}
|
|
<svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
</svg>
|
|
{:else}
|
|
<svg class="h-3.5 w-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Waveform -->
|
|
<div class="flex-1 min-w-0">
|
|
<div bind:this={container} class="w-full"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Time display -->
|
|
<div class="flex items-center justify-between px-1">
|
|
<span class="text-[10px] text-gray-400">{formatTime(currentTime)}</span>
|
|
<span class="text-[10px] text-gray-400">{formatTime(totalDuration)}</span>
|
|
</div>
|
|
|
|
{#if loadError}
|
|
<p class="text-[10px] text-red-400 px-1">Kunne ikke laste lydfilen</p>
|
|
{/if}
|
|
|
|
<!-- Transcript toggle -->
|
|
{#if hasSegments || transcript}
|
|
<button
|
|
onclick={() => { showTranscript = !showTranscript; }}
|
|
class="flex items-center gap-1 px-1 text-[10px] text-gray-400 hover:text-gray-600 transition-colors"
|
|
>
|
|
<svg class="h-3 w-3 transition-transform {showTranscript ? 'rotate-90' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
Transkripsjon
|
|
</button>
|
|
{#if showTranscript}
|
|
{#if hasSegments}
|
|
<TranscriptionView
|
|
nodeId={nodeId!}
|
|
accessToken={accessToken!}
|
|
{currentTime}
|
|
onseek={seekTo}
|
|
/>
|
|
{:else if transcript}
|
|
<p class="text-xs text-gray-500 px-1 py-1 bg-gray-50 rounded whitespace-pre-wrap">{transcript}</p>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
</div>
|