Video-opptak i frontend: webcam/skjermopptak via MediaRecorder API (oppgave 29.7)

VideoRecorder-komponent med to moduser:
- Kamera: getUserMedia med video+lyd, 720p
- Skjerm: getDisplayMedia med valgfri lyd, 1080p

Funksjoner:
- Modus-velger (kamera/skjerm) før opptak starter
- Live forhåndsvisning under opptak
- Konfigurerbar maks varighet (default 5 min), advarsel siste 30 sek
- Automatisk stopp ved maks varighet
- Upload til CAS → media-node med metadata (source, record_type, duration)
- Integrert i ChatInput ved siden av VoiceRecorder
This commit is contained in:
vegard 2026-03-18 22:19:30 +00:00
parent 532bd4b3ec
commit fd40d51466
3 changed files with 314 additions and 2 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import VoiceRecorder from './VoiceRecorder.svelte'; import VoiceRecorder from './VoiceRecorder.svelte';
import VideoRecorder from './VideoRecorder.svelte';
import { uploadMedia, casUrl } from '$lib/api'; import { uploadMedia, casUrl } from '$lib/api';
interface Props { interface Props {
@ -136,6 +137,12 @@
disabled={disabled || submitting} disabled={disabled || submitting}
onerror={(msg) => { error = msg; }} onerror={(msg) => { error = msg; }}
/> />
<VideoRecorder
{accessToken}
sourceId={contextId}
disabled={disabled || submitting}
onerror={(msg) => { error = msg; }}
/>
<button <button
onclick={handleSubmit} onclick={handleSubmit}
disabled={isEmpty || submitting || disabled} disabled={isEmpty || submitting || disabled}

View file

@ -0,0 +1,306 @@
<script lang="ts">
import { uploadMedia } from '$lib/api';
interface Props {
accessToken?: string;
/** Called when upload completes. Returns media_node_id + cas_hash. */
onrecorded?: (result: { media_node_id: string; cas_hash: string }) => void;
/** Called on error */
onerror?: (message: string) => void;
/** Optional context node to attach media to */
sourceId?: string;
disabled?: boolean;
/** Max recording duration in seconds (default: 300 = 5 min) */
maxDuration?: number;
}
let {
accessToken,
onrecorded,
onerror,
sourceId,
disabled = false,
maxDuration = 300,
}: Props = $props();
type RecordingState = 'idle' | 'picking' | 'recording' | 'uploading';
type RecordMode = 'webcam' | 'screen';
let recState: RecordingState = $state('idle');
let mode: RecordMode = $state('webcam');
let mediaRecorder: MediaRecorder | undefined = $state();
let stream: MediaStream | undefined = $state();
let chunks: Blob[] = [];
let duration = $state(0);
let durationInterval: ReturnType<typeof setInterval> | undefined;
let previewEl: HTMLVideoElement | undefined = $state();
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function showPicker() {
if (!accessToken) {
onerror?.('Ikke innlogget — kan ikke ta opp video');
return;
}
recState = 'picking';
}
function hidePicker() {
recState = 'idle';
}
async function startRecording(selectedMode: RecordMode) {
mode = selectedMode;
recState = 'picking'; // keep UI while acquiring stream
try {
if (selectedMode === 'webcam') {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } },
audio: true,
});
} else {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: true,
});
// Screen share can be stopped by the browser's own UI
stream.getVideoTracks()[0]?.addEventListener('ended', () => {
if (recState === 'recording') stopRecording();
});
}
} catch {
const msg = selectedMode === 'webcam'
? 'Ingen tilgang til kamera. Sjekk nettleserinnstillinger.'
: 'Skjermdeling ble avbrutt eller nektet.';
onerror?.(msg);
recState = 'idle';
return;
}
// Show preview
if (previewEl && stream) {
previewEl.srcObject = stream;
}
chunks = [];
duration = 0;
// Prefer webm VP8/VP9
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus'
: MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')
? 'video/webm;codecs=vp8,opus'
: MediaRecorder.isTypeSupported('video/webm')
? 'video/webm'
: '';
mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
mediaRecorder.onstop = async () => {
stopTimer();
stopStream();
if (chunks.length === 0) {
recState = 'idle';
return;
}
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType || 'video/webm' });
chunks = [];
recState = 'uploading';
try {
const ext = blob.type.includes('mp4') ? 'mp4' : 'webm';
const prefix = mode === 'webcam' ? 'video-memo' : 'screen-recording';
const file = new File([blob], `${prefix}.${ext}`, { type: blob.type });
const result = await uploadMedia(accessToken!, {
file,
title: mode === 'webcam' ? 'Videomelding' : 'Skjermopptak',
visibility: 'hidden',
source_id: sourceId,
metadata_extra: {
source: 'video_recording',
record_type: mode,
duration_seconds: duration,
},
});
onrecorded?.({
media_node_id: result.media_node_id,
cas_hash: result.cas_hash,
});
} catch (e) {
onerror?.(e instanceof Error ? e.message : 'Feil ved opplasting av video');
} finally {
recState = 'idle';
}
};
mediaRecorder.start(1000);
recState = 'recording';
startTimer();
}
function stopRecording() {
if (mediaRecorder?.state === 'recording') {
mediaRecorder.stop();
}
}
function cancelRecording() {
chunks = [];
if (mediaRecorder?.state === 'recording') {
mediaRecorder.stop();
}
stopTimer();
stopStream();
recState = 'idle';
}
function startTimer() {
durationInterval = setInterval(() => {
duration++;
if (duration >= maxDuration) {
stopRecording();
}
}, 1000);
}
function stopTimer() {
if (durationInterval) {
clearInterval(durationInterval);
durationInterval = undefined;
}
}
function stopStream() {
stream?.getTracks().forEach((t) => t.stop());
stream = undefined;
if (previewEl) previewEl.srcObject = null;
}
const remaining = $derived(maxDuration - duration);
const isNearLimit = $derived(remaining <= 30);
</script>
{#if recState === 'idle'}
<button
onclick={showPicker}
disabled={disabled || !accessToken}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-purple-50 hover:text-purple-500 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Ta opp video"
title="Ta opp video"
>
<!-- Video camera icon -->
<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="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
{:else if recState === 'picking'}
<div class="flex items-center gap-1">
<!-- Close picker -->
<button
onclick={hidePicker}
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Avbryt"
title="Avbryt"
>
<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>
<!-- Webcam -->
<button
onclick={() => startRecording('webcam')}
class="flex items-center gap-1 rounded-full bg-purple-100 px-3 py-1.5 text-xs font-medium text-purple-700 hover:bg-purple-200"
aria-label="Webcam"
title="Ta opp med kamera"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Kamera
</button>
<!-- Screen -->
<button
onclick={() => startRecording('screen')}
class="flex items-center gap-1 rounded-full bg-purple-100 px-3 py-1.5 text-xs font-medium text-purple-700 hover:bg-purple-200"
aria-label="Skjermopptak"
title="Ta opp skjermen"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Skjerm
</button>
</div>
{:else if recState === 'recording'}
<div class="flex items-center gap-2">
<!-- Small preview -->
<div class="relative h-12 w-16 overflow-hidden rounded border border-gray-200 bg-black">
<video
bind:this={previewEl}
autoplay
muted
playsinline
class="h-full w-full object-cover"
></video>
</div>
<!-- Cancel button -->
<button
onclick={cancelRecording}
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Avbryt opptak"
title="Avbryt opptak"
>
<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>
<!-- Recording indicator + duration -->
<div class="flex items-center gap-1.5">
<span class="h-2 w-2 animate-pulse rounded-full bg-red-500"></span>
<span class="text-xs font-medium {isNearLimit ? 'text-orange-600' : 'text-red-600'}">
{formatDuration(duration)}
</span>
{#if isNearLimit}
<span class="text-[10px] text-orange-500">({formatDuration(remaining)})</span>
{/if}
</div>
<!-- Stop/send button -->
<button
onclick={stopRecording}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-600 text-white transition-colors hover:bg-red-700"
aria-label="Stopp og send opptak"
title="Stopp og send opptak"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
</div>
{:else if recState === 'uploading'}
<div class="flex items-center gap-1.5">
<svg class="h-4 w-4 animate-spin text-purple-600" 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>
<span class="text-xs text-purple-600">Laster opp video…</span>
</div>
{/if}

View file

@ -402,8 +402,7 @@ noden er det som lever videre.
- [x] 29.6 Webhook-templates: forhåndsdefinerte mappinger for kjente tjenester (GitHub → commits/issues, Slack → meldinger, CI/CD → build-status). Template mapper JSON-felt til node title/content/metadata. - [x] 29.6 Webhook-templates: forhåndsdefinerte mappinger for kjente tjenester (GitHub → commits/issues, Slack → meldinger, CI/CD → build-status). Template mapper JSON-felt til node title/content/metadata.
### Video ### Video
- [~] 29.7 Video-opptak i frontend: webcam/skjermopptak via MediaRecorder API → upload til CAS → media-node. Start/stopp-knapp i input-komponenten. Maks varighet konfigurerbar. - [x] 29.7 Video-opptak i frontend: webcam/skjermopptak via MediaRecorder API → upload til CAS → media-node. Start/stopp-knapp i input-komponenten. Maks varighet konfigurerbar.
> Påbegynt: 2026-03-18T22:15
- [ ] 29.8 Video-prosessering: `synops-video` CLI for transcode (H.264), thumbnail-generering, og varighet-uttrekk. Input: `--cas-hash <hash>`. Output: ny CAS-hash (trancodet) + thumbnail CAS-hash. - [ ] 29.8 Video-prosessering: `synops-video` CLI for transcode (H.264), thumbnail-generering, og varighet-uttrekk. Input: `--cas-hash <hash>`. Output: ny CAS-hash (trancodet) + thumbnail CAS-hash.
### Geolokasjon ### Geolokasjon