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:
parent
532bd4b3ec
commit
fd40d51466
3 changed files with 314 additions and 2 deletions
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import VoiceRecorder from './VoiceRecorder.svelte';
|
||||
import VideoRecorder from './VideoRecorder.svelte';
|
||||
import { uploadMedia, casUrl } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -136,6 +137,12 @@
|
|||
disabled={disabled || submitting}
|
||||
onerror={(msg) => { error = msg; }}
|
||||
/>
|
||||
<VideoRecorder
|
||||
{accessToken}
|
||||
sourceId={contextId}
|
||||
disabled={disabled || submitting}
|
||||
onerror={(msg) => { error = msg; }}
|
||||
/>
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={isEmpty || submitting || disabled}
|
||||
|
|
|
|||
306
frontend/src/lib/components/VideoRecorder.svelte
Normal file
306
frontend/src/lib/components/VideoRecorder.svelte
Normal 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}
|
||||
3
tasks.md
3
tasks.md
|
|
@ -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.
|
||||
|
||||
### 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.
|
||||
> Påbegynt: 2026-03-18T22:15
|
||||
- [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.
|
||||
- [ ] 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue