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">
|
<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}
|
||||||
|
|
|
||||||
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.
|
- [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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue