synops/frontend/src/routes/studio/[id]/+page.svelte
vegard b5aa5bb243 Fjern SpacetimeDB komplett (oppgave 22.4)
SpacetimeDB er nå helt fjernet fra Synops. Sanntid håndteres av
PG LISTEN/NOTIFY + WebSocket i portvokteren (maskinrommet).

Kode fjernet:
- spacetimedb/ Rust-modul og spacetime.json
- maskinrommet/src/stdb.rs (HTTP-klient for STDB-reducers)
- frontend module_bindings/ (23 auto-genererte filer)
- spacetimedb npm-avhengighet fra package.json
- scripts/test-sanntid.sh (testet STDB-flyt)

Infrastruktur:
- Docker-container stoppet og fjernet fra docker-compose.yml
- Caddy: fjernet /spacetime/* reverse proxy
- maskinrommet-env.sh: fjernet STDB_IP og SPACETIMEDB_*-variabler
- .env.example: fjernet SpacetimeDB-seksjoner

Dokumentasjon oppdatert:
- CLAUDE.md: stack, lagmodell, kjerneprinsipper, driftsmodell
- docs/arkitektur.md: skrivestien, lesestien, datalag, teknologivalg
- docs/retninger/datalaget.md: migrasjonshistorikk, status "fjernet"
- 37 andre docs oppdatert (features, concepts, infra, ops, retninger)
- Alle kode-kommentarer med STDB-referanser oppdatert

Verifisert: maskinrommet bygger og starter OK, frontend bygger OK,
helsesjekk returnerer 200. Caddy reloadet.
2026-03-18 13:39:09 +00:00

567 lines
18 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { connectionState, nodeStore, edgeStore } from '$lib/spacetime';
import {
casUrl,
audioAnalyze,
audioProcess,
fetchSegments,
createNode,
createEdge,
type EdlOperation,
type EdlDocument,
type LoudnessInfo,
type SilenceRegion,
type AudioInfo,
type Segment,
} from '$lib/api';
import StudioWaveform from '$lib/components/studio/StudioWaveform.svelte';
import OperationPanel from '$lib/components/studio/OperationPanel.svelte';
import RenderDialog from '$lib/components/studio/RenderDialog.svelte';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
const nodeId = $derived(session?.nodeId as string | undefined);
const connected = $derived(connectionState.current === 'connected');
const mediaNodeId = $derived($page.params.id ?? '');
// Media node from store
const mediaNode = $derived(connected ? nodeStore.get(mediaNodeId) : undefined);
const metadata = $derived.by(() => {
if (!mediaNode?.metadata) return null;
try {
return JSON.parse(mediaNode.metadata);
} catch {
console.error('Ugyldig metadata JSON for medienode:', mediaNodeId);
return null;
}
});
const casHash = $derived(metadata?.cas_hash as string | undefined);
const audioSrc = $derived(casHash ? casUrl(casHash) : '');
// Studio state
let operations: EdlOperation[] = $state([]);
let loudness: LoudnessInfo | null = $state(null);
let silenceRegions: SilenceRegion[] = $state([]);
let audioInfo: AudioInfo | null = $state(null);
let analyzing = $state(false);
let waveformRef: StudioWaveform | undefined = $state();
let currentTime = $state(0);
// Render state
let showRenderDialog = $state(false);
let rendering = $state(false);
let renderJobId: string | null = $state(null);
let resultNodeId: string | null = $state(null);
let renderError: string | null = $state(null);
// Polling cleanup
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
let pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
function cleanupPolling() {
if (pollIntervalId !== null) { clearInterval(pollIntervalId); pollIntervalId = null; }
if (pollTimeoutId !== null) { clearTimeout(pollTimeoutId); pollTimeoutId = null; }
}
// Mobile tool panel sheet
let showToolSheet = $state(false);
// Session persistence
let sessionNodeId: string | null = $state(null);
let saving = $state(false);
// Version history: processed nodes derived from this media node
const versions = $derived.by(() => {
if (!connected || !mediaNodeId) return [];
const nodes: { id: string; title: string; createdAt: bigint }[] = [];
for (const edge of edgeStore.byTarget(mediaNodeId)) {
if (edge.edgeType !== 'derived_from') continue;
const node = nodeStore.get(edge.sourceId);
if (node) {
nodes.push({
id: node.id,
title: node.title ?? 'Prosessert',
createdAt: node.createdAt?.microsSinceUnixEpoch ?? 0n,
});
}
}
nodes.sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1));
return nodes;
});
// Transcription
let segments: Segment[] = $state([]);
let segmentsLoaded = $state(false);
// Load existing studio session (if any)
$effect(() => {
if (!connected || !mediaNodeId) return;
for (const edge of edgeStore.byTarget(mediaNodeId)) {
if (edge.edgeType !== 'has_studio') continue;
const node = nodeStore.get(edge.sourceId);
if (node) {
try {
const meta = JSON.parse(node.metadata ?? '{}');
if (meta.edl?.operations) {
operations = meta.edl.operations;
sessionNodeId = node.id;
}
} catch { /* ignore */ }
break; // Use first session found
}
}
});
// Load segments on mount
$effect(() => {
if (accessToken && mediaNodeId && !segmentsLoaded) {
segmentsLoaded = true;
fetchSegments(accessToken, mediaNodeId)
.then((res) => {
segments = res.segments || [];
})
.catch(() => {
segments = [];
});
}
});
async function handleAnalyze() {
if (!accessToken || !casHash) return;
analyzing = true;
try {
const result = await audioAnalyze(accessToken, casHash);
loudness = result.loudness;
silenceRegions = result.silence_regions;
audioInfo = result.info;
// Show silence regions on waveform
waveformRef?.showSilenceRegions(silenceRegions);
} catch (e) {
console.error('Analyse feilet:', e);
} finally {
analyzing = false;
}
}
function handleCut() {
const region = waveformRef?.getActiveRegion();
if (!region) return;
operations = [
...operations,
{
type: 'cut',
start_ms: Math.round(region.start * 1000),
end_ms: Math.round(region.end * 1000),
},
];
// Convert selection to cut overlay
waveformRef?.clearActiveRegion();
waveformRef?.addCutRegion(
Math.round(region.start * 1000),
Math.round(region.end * 1000)
);
}
function handleAddOp(op: EdlOperation) {
// Prevent duplicate types for non-cut operations
if (op.type !== 'cut') {
operations = operations.filter((o) => o.type !== op.type);
}
operations = [...operations, op];
}
function handleRemoveOp(index: number) {
operations = operations.filter((_, i) => i !== index);
}
function handleUndo() {
if (operations.length === 0) return;
operations = operations.slice(0, -1);
}
function handleTrimSilence() {
operations = [
...operations,
{
type: 'trim_silence',
threshold_db: -30,
min_duration_ms: 500,
},
];
}
function handleRenderStart() {
showRenderDialog = true;
}
async function handleRenderConfirm(format: string) {
if (!accessToken || !casHash) return;
rendering = true;
const edl: EdlDocument = {
source_hash: casHash,
operations,
};
try {
const result = await audioProcess(accessToken, mediaNodeId, edl, format);
renderJobId = result.job_id;
// Poll for completion (simple approach)
pollJobResult(result.job_id);
} catch (e) {
console.error('Render feilet:', e);
rendering = false;
}
}
const MAX_POLL_FAILURES = 5;
function pollJobResult(jobId: string) {
// Poll job_queue for completion via simple interval
// In production this would use SSE or WebSocket
cleanupPolling();
let failures = 0;
pollIntervalId = setInterval(async () => {
try {
const res = await fetch(`/api/query/job_status?job_id=${encodeURIComponent(jobId)}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.ok) {
const data = await res.json();
failures = 0; // Reset on success
if (data.status === 'completed') {
cleanupPolling();
resultNodeId = data.result?.processed_node_id ?? null;
rendering = false;
} else if (data.status === 'error') {
cleanupPolling();
rendering = false;
renderError = data.error_msg ?? 'Jobb feilet';
}
} else {
failures++;
if (failures >= MAX_POLL_FAILURES) {
cleanupPolling();
rendering = false;
renderError = `Statussjekk feilet ${MAX_POLL_FAILURES} ganger — prøv igjen senere`;
}
}
} catch {
failures++;
if (failures >= MAX_POLL_FAILURES) {
cleanupPolling();
rendering = false;
renderError = `Mistet kontakt med serveren etter ${MAX_POLL_FAILURES} forsøk`;
}
}
}, 2000);
// Timeout after 5 minutes
pollTimeoutId = setTimeout(() => {
cleanupPolling();
if (rendering) {
rendering = false;
renderError = 'Rendering tok for lang tid (tidsavbrudd etter 5 minutter)';
}
}, 5 * 60 * 1000);
}
// Cleanup polling on component destroy (navigation away)
$effect(() => {
return () => cleanupPolling();
});
function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'Delete' || e.key === 'Backspace') {
const region = waveformRef?.getActiveRegion();
if (region) {
e.preventDefault();
handleCut();
}
}
if (e.ctrlKey && e.key === 'z') {
e.preventDefault();
handleUndo();
}
}
async function handleSaveSession() {
if (!accessToken || !casHash || operations.length === 0) return;
saving = true;
try {
const edl: EdlDocument = { source_hash: casHash, operations };
if (sessionNodeId) {
// Update existing session node
const { updateNode } = await import('$lib/api');
await updateNode(accessToken, {
node_id: sessionNodeId,
metadata: { edl },
});
} else {
// Create new session node + has_studio edge
const res = await createNode(accessToken, {
node_kind: 'content',
title: `Studio-sesjon: ${mediaNode?.title ?? 'Ukjent'}`,
visibility: 'hidden',
metadata: { edl },
});
sessionNodeId = res.node_id;
await createEdge(accessToken, {
source_id: res.node_id,
target_id: mediaNodeId,
edge_type: 'has_studio',
});
}
} catch (e) {
console.error('Kunne ikke lagre sesjon:', e);
} finally {
saving = false;
}
}
function handleSegmentClick(segment: Segment) {
waveformRef?.seekTo(segment.start_ms / 1000);
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="flex min-h-screen flex-col bg-gray-50">
<!-- Header -->
<header class="flex items-center justify-between border-b border-gray-200 bg-white px-3 py-2 sm:px-4 sm:py-3">
<div class="flex min-w-0 items-center gap-2 sm:gap-3">
<a href="/" class="shrink-0 text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</a>
<h1 class="truncate text-base font-semibold text-gray-800 sm:text-lg">
{mediaNode?.title ?? 'Lydstudio'}
</h1>
{#if audioInfo}
<span class="hidden rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 sm:inline">
{audioInfo.codec} / {audioInfo.sample_rate}Hz / {audioInfo.channels}ch
</span>
{/if}
</div>
<div class="flex items-center gap-2">
{#if operations.length > 0}
<button
onclick={handleSaveSession}
disabled={saving}
class="rounded bg-gray-100 px-3 py-1.5 text-sm text-gray-700 transition-colors hover:bg-gray-200 disabled:opacity-50"
>
{saving ? 'Lagrer...' : sessionNodeId ? 'Oppdater sesjon' : 'Lagre sesjon'}
</button>
{/if}
</div>
</header>
{#if !audioSrc}
<div class="flex flex-1 items-center justify-center">
<p class="text-gray-400">
{#if !connected}
Kobler til...
{:else if !mediaNode}
Finner ikke medienoden
{:else}
Ingen lydfil tilgjengelig
{/if}
</p>
</div>
{:else}
<div class="flex flex-1 flex-col gap-4 p-4 lg:flex-row">
<!-- Main area: waveform + transcription -->
<div class="flex flex-1 flex-col gap-4 min-w-0">
<StudioWaveform
bind:this={waveformRef}
src={audioSrc}
onready={(d) => { audioInfo = audioInfo ?? { duration_ms: d * 1000, sample_rate: 0, channels: 0, codec: '', format: '', bit_rate: null }; }}
ontimeupdate={(t) => { currentTime = t; }}
/>
<!-- Transcription segments -->
{#if segments.length > 0}
<div class="rounded-lg border border-gray-200 bg-white p-3">
<h3 class="mb-2 text-sm font-semibold text-gray-700">Transkripsjon</h3>
<div class="max-h-64 space-y-0.5 overflow-y-auto">
{#each segments as seg}
{@const active = currentTime >= seg.start_ms / 1000 && currentTime < seg.end_ms / 1000}
<button
onclick={() => handleSegmentClick(seg)}
class="flex w-full items-start gap-2 rounded px-2 py-1 text-left transition-colors
{active ? 'bg-blue-50 text-blue-800' : 'hover:bg-gray-50 text-gray-600'}"
>
<span class="mt-0.5 shrink-0 font-mono text-[10px] text-gray-400">
{Math.floor(seg.start_ms / 60000)}:{Math.floor((seg.start_ms % 60000) / 1000).toString().padStart(2, '0')}
</span>
<span class="text-xs">{seg.content}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Sidebar: operation panel (desktop) -->
<div class="hidden w-72 shrink-0 flex-col gap-4 overflow-y-auto lg:flex lg:w-80">
<OperationPanel
{loudness}
{silenceRegions}
{audioInfo}
{analyzing}
{operations}
activeRegion={waveformRef?.getActiveRegion() ?? null}
onanalyze={handleAnalyze}
oncut={handleCut}
onaddop={handleAddOp}
onremoveop={handleRemoveOp}
onrender={handleRenderStart}
onundo={handleUndo}
ontrimsilence={handleTrimSilence}
/>
<!-- Version history -->
{#if versions.length > 0}
<div class="rounded-lg border border-gray-200 bg-white p-4">
<h3 class="mb-2 text-sm font-semibold text-gray-700">Versjoner</h3>
<ul class="space-y-1">
<li class="flex items-center justify-between rounded bg-blue-50 px-2 py-1">
<span class="text-xs font-medium text-blue-700">Original</span>
<span class="text-[10px] text-blue-500">Navarende</span>
</li>
{#each versions as ver, i}
<li class="flex items-center justify-between rounded px-2 py-1 hover:bg-gray-50">
<a href="/studio/{ver.id}" class="text-xs text-gray-600 hover:text-blue-600">
v{i + 1}: {ver.title}
</a>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
<!-- Mobile: floating tool button + bottom sheet -->
<div class="lg:hidden">
<!-- Floating action button -->
<button
onclick={() => { showToolSheet = !showToolSheet; }}
class="fixed bottom-4 right-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition-transform hover:bg-blue-700 active:scale-95"
aria-label="Verktoy"
>
{#if showToolSheet}
<!-- Close icon -->
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{:else}
<!-- Settings/tool icon -->
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{/if}
{#if operations.length > 0 && !showToolSheet}
<span class="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold">
{operations.length}
</span>
{/if}
</button>
<!-- Bottom sheet backdrop -->
{#if showToolSheet}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 bg-black/30"
onclick={() => { showToolSheet = false; }}
onkeydown={(e) => { if (e.key === 'Escape') showToolSheet = false; }}
></div>
<!-- Bottom sheet -->
<div class="fixed inset-x-0 bottom-0 z-50 flex max-h-[85vh] flex-col rounded-t-2xl bg-gray-50 shadow-2xl">
<!-- Handle -->
<div class="flex justify-center py-2">
<div class="h-1 w-10 rounded-full bg-gray-300"></div>
</div>
<!-- Sheet content -->
<div class="flex-1 overflow-y-auto px-4 pb-8">
<OperationPanel
{loudness}
{silenceRegions}
{audioInfo}
{analyzing}
{operations}
activeRegion={waveformRef?.getActiveRegion() ?? null}
onanalyze={handleAnalyze}
oncut={handleCut}
onaddop={handleAddOp}
onremoveop={handleRemoveOp}
onrender={handleRenderStart}
onundo={handleUndo}
ontrimsilence={handleTrimSilence}
/>
<!-- Version history -->
{#if versions.length > 0}
<div class="mt-4 rounded-lg border border-gray-200 bg-white p-4">
<h3 class="mb-2 text-sm font-semibold text-gray-700">Versjoner</h3>
<ul class="space-y-1">
<li class="flex items-center justify-between rounded bg-blue-50 px-2 py-1">
<span class="text-xs font-medium text-blue-700">Original</span>
<span class="text-[10px] text-blue-500">Navarende</span>
</li>
{#each versions as ver, i}
<li class="flex items-center justify-between rounded px-2 py-1 hover:bg-gray-50">
<a href="/studio/{ver.id}" class="text-xs text-gray-600 hover:text-blue-600">
v{i + 1}: {ver.title}
</a>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- Render error toast (only when dialog is closed) -->
{#if renderError && !showRenderDialog}
<div class="fixed bottom-4 left-4 z-50 max-w-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 shadow-lg">
<div class="flex items-start gap-2">
<svg class="mt-0.5 h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-red-800">Rendering feilet</p>
<p class="mt-0.5 text-xs text-red-600">{renderError}</p>
</div>
<button onclick={() => { renderError = null; }} class="shrink-0 text-red-400 hover:text-red-600">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/if}
<!-- Render dialog -->
{#if showRenderDialog}
<RenderDialog
{operations}
{rendering}
jobId={renderJobId}
{resultNodeId}
errorMessage={renderError}
onconfirm={handleRenderConfirm}
onclose={() => { showRenderDialog = false; rendering = false; renderJobId = null; resultNodeId = null; renderError = null; }}
/>
{/if}