Spaces:
Runtime error
Runtime error
<script lang="ts"> | |
import { onMount, onDestroy, createEventDispatcher } from 'svelte'; | |
import { Block } from '@gradio/atoms'; | |
import { StatusTracker } from '@gradio/statustracker'; | |
import type { LoadingStatus } from "@gradio/statustracker"; | |
import type { Gradio } from "@gradio/utils"; | |
import fixWebmDuration from 'fix-webm-duration'; | |
// Type definitions | |
interface MediaRecorderOptions { | |
mimeType?: string; | |
audioBitsPerSecond?: number; | |
videoBitsPerSecond?: number; | |
bitsPerSecond?: number; | |
} | |
interface MediaTrackConstraints { | |
displaySurface?: 'browser' | 'monitor' | 'window'; | |
cursor?: 'always' | 'motion' | 'never'; | |
} | |
// Type definitions | |
interface RecordingData { | |
video: string; | |
duration: number; | |
audio_enabled?: boolean; | |
status?: string; | |
orig_name?: string; | |
size?: number | null; | |
data?: string; // Base64 encoded data for Gradio | |
name?: string; // Alias for orig_name for Gradio compatibility | |
is_file?: boolean; | |
type?: string; // MIME type of the recording | |
} | |
interface Position { | |
x: number; | |
y: number; | |
} | |
// Event types for the component | |
type EventMap = { | |
'error': { message: string; error: string }; | |
'recording-started': void; | |
'recording-stopped': RecordingData; | |
'record_stop': RecordingData; | |
'change': RecordingData; | |
'webcam-error': { message: string; error: string }; | |
}; | |
// Component props with proper types and defaults | |
export let gradio: Gradio<any>; | |
export let value: Partial<RecordingData> | null = null; | |
export const elem_id = ''; // Marked as const since it's not modified | |
export let elem_classes: string[] = []; | |
export let scale: number | null = null; | |
export let min_width: number | null = null; | |
export let visible = true; | |
export let interactive = true; | |
export let loading_status: LoadingStatus | null = null; | |
export let audio_enabled = false; | |
export let webcam_overlay = false; | |
export let webcam_position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'bottom-right'; | |
export let recording_format: 'webm' | 'mp4' | 'gif' = 'webm'; | |
export let max_duration: number | null = null; | |
// Computed styles for the container | |
let containerStyle = ''; | |
// Component methods interface | |
interface ComponentMethods { | |
startRecording: () => Promise<void>; | |
stopRecording: () => void; | |
togglePause: () => void; | |
cleanup: () => void; | |
} | |
// Component state with explicit types and initial values | |
let isPaused = false; | |
let isRecording = false; | |
let recordingTime = 0; | |
let recordingTimer: number | null = null; | |
let recordedChunks: Blob[] = []; | |
// Media streams and elements | |
let screenStream: MediaStream | null = null; | |
let webcamStream: MediaStream | null = null; | |
let combinedStream: MediaStream | null = null; | |
let canvas: HTMLCanvasElement | null = null; | |
let ctx: CanvasRenderingContext2D | null = null; | |
let animationFrameId: number | null = null; | |
let previewVideo: HTMLVideoElement | null = null; | |
let webcamVideo: HTMLVideoElement | null = null; | |
let recordingStartTime = 0; | |
let mediaRecorder: MediaRecorder | null = null; | |
// Internal video elements | |
let webcamVideoInternal: HTMLVideoElement | null = null; | |
let screenVideoInternal: HTMLVideoElement | null = null; | |
// Bind canvas element | |
function bindCanvas(node: HTMLCanvasElement) { | |
canvas = node; | |
if (canvas) { | |
const context = canvas.getContext('2d', { willReadFrequently: true }); | |
if (context) { | |
ctx = context; | |
// Set canvas dimensions with null checks | |
const width = canvas.offsetWidth; | |
const height = canvas.offsetHeight; | |
if (width && height) { | |
canvas.width = width; | |
canvas.height = height; | |
} | |
} | |
} | |
return { | |
destroy() { | |
canvas = null; | |
ctx = null; | |
} | |
}; | |
} | |
// Canvas binding is now handled by the bindCanvas function | |
// Configuration | |
const webcam_size = 200; | |
const webcam_border = 10; | |
const webcam_radius = '50%'; | |
// Ensure max_duration has a default value if null | |
$: effectiveMaxDuration = max_duration ?? 0; | |
// Computed styles for the container | |
$: containerStyle = [ | |
scale !== null ? `--scale: ${scale};` : '', | |
min_width !== null ? `min-width: ${min_width}px;` : '' | |
].filter(Boolean).join(' '); | |
onDestroy(() => { | |
if (isRecording) { | |
componentMethods.stopRecording(); | |
} | |
componentMethods.cleanup(); | |
if (animationFrameId) { | |
cancelAnimationFrame(animationFrameId); | |
animationFrameId = null; | |
} | |
}); | |
// Component state and props are already declared above | |
// Event dispatcher with proper typing | |
const dispatch = createEventDispatcher<EventMap>(); | |
// Type guard for error handling | |
function isErrorWithMessage(error: unknown): error is Error { | |
return error instanceof Error; | |
} | |
// Component methods implementation | |
const componentMethods: ComponentMethods = { | |
startRecording: async (): Promise<void> => { | |
if (isRecording) return; | |
isRecording = true; | |
recordedChunks = []; | |
recordingTime = 0; | |
try { | |
// Composite screen and optional webcam overlay via hidden canvas | |
const screenStreamCapture = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); | |
screenStream = screenStreamCapture; | |
// Assign to hidden video for composition | |
if (screenVideoInternal) { | |
screenVideoInternal.srcObject = screenStreamCapture; | |
await screenVideoInternal.play().catch(() => {}); | |
} | |
let captureStream: MediaStream; | |
if (webcam_overlay && webcamVideoInternal && canvas && ctx) { | |
try { | |
webcamStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); | |
webcamVideoInternal.srcObject = webcamStream; | |
await webcamVideoInternal.play().catch(() => {}); | |
// Resize canvas to match screen video | |
canvas.width = screenVideoInternal!.videoWidth; | |
canvas.height = screenVideoInternal!.videoHeight; | |
const overlaySize = Math.min(canvas.width, canvas.height) / 4; | |
const posMap: Record<string, [number, number]> = { | |
'top-left': [10, 10], | |
'top-right': [canvas.width - overlaySize - 10, 10], | |
'bottom-left': [10, canvas.height - overlaySize - 10], | |
'bottom-right': [canvas.width - overlaySize - 10, canvas.height - overlaySize - 10] | |
}; | |
const [ox, oy] = posMap[webcam_position]; | |
function draw() { | |
ctx!.drawImage(screenVideoInternal!, 0, 0, canvas.width, canvas.height); | |
ctx!.drawImage(webcamVideoInternal!, ox, oy, overlaySize, overlaySize); | |
animationFrameId = requestAnimationFrame(draw); | |
} | |
draw(); | |
const canvasStream = canvas.captureStream(30); | |
const audioTracks = audio_enabled | |
? (await navigator.mediaDevices.getUserMedia({ audio: true })).getAudioTracks() | |
: screenStreamCapture.getAudioTracks(); | |
combinedStream = new MediaStream([...canvasStream.getVideoTracks(), ...audioTracks]); | |
captureStream = combinedStream; | |
} catch (err) { | |
console.warn('Webcam overlay failed, falling back to screen only', err); | |
captureStream = screenStreamCapture; | |
} | |
} else { | |
// No overlay: combine audio if enabled with screen | |
const audioTracks = audio_enabled | |
? (await navigator.mediaDevices.getUserMedia({ audio: true })).getAudioTracks() | |
: screenStreamCapture.getAudioTracks(); | |
combinedStream = new MediaStream([...screenStreamCapture.getVideoTracks(), ...audioTracks]); | |
captureStream = combinedStream; | |
} | |
// Handle track ended event | |
screenStreamCapture.getVideoTracks()[0].onended = () => { | |
if (isRecording) { | |
componentMethods.stopRecording(); | |
} | |
}; | |
// Start recording | |
const options: MediaRecorderOptions = { | |
mimeType: recording_format === 'webm' ? 'video/webm;codecs=vp9' : 'video/mp4' | |
}; | |
mediaRecorder = new MediaRecorder(captureStream, options); | |
mediaRecorder.ondataavailable = handleDataAvailable; | |
mediaRecorder.onstop = handleRecordingStop; | |
mediaRecorder.start(); | |
recordingStartTime = Date.now(); | |
updateRecordingTime(); | |
dispatch('recording-started'); | |
} catch (error) { | |
isRecording = false; | |
if (isErrorWithMessage(error)) { | |
dispatch('error', { | |
message: 'Failed to start recording', | |
error: error.message | |
}); | |
} | |
} | |
}, | |
stopRecording: (): void => { | |
if (!isRecording || !mediaRecorder) return; | |
try { | |
mediaRecorder.stop(); | |
isRecording = false; | |
// Stop all tracks | |
[screenStream, webcamStream, combinedStream].forEach(stream => { | |
if (stream) { | |
stream.getTracks().forEach(track => track.stop()); | |
} | |
}); | |
if (recordingTimer) { | |
clearTimeout(recordingTimer); | |
recordingTimer = null; | |
} | |
const recordingData: RecordingData = { | |
video: '', | |
duration: recordingTime / 1000, | |
audio_enabled: audio_enabled, | |
status: 'completed' | |
}; | |
dispatch('recording-stopped', recordingData); | |
dispatch('record_stop', recordingData); | |
dispatch('change', recordingData); | |
} catch (error) { | |
isRecording = false; | |
if (isErrorWithMessage(error)) { | |
dispatch('error', { | |
message: 'Error stopping recording', | |
error: error.message | |
}); | |
} | |
} | |
}, | |
togglePause: (): void => { | |
if (!mediaRecorder) return; | |
isPaused = !isPaused; | |
if (isPaused) { | |
mediaRecorder.pause(); | |
if (recordingTimer) { | |
clearTimeout(recordingTimer); | |
recordingTimer = null; | |
} | |
} else { | |
mediaRecorder.resume(); | |
updateRecordingTime(); | |
} | |
if (isPaused) { | |
// Pause logic | |
} else { | |
// Resume logic | |
} | |
}, | |
cleanup: (): void => { | |
// Stop all media streams | |
[screenStream, webcamStream, combinedStream].forEach(stream => { | |
if (stream) { | |
stream.getTracks().forEach(track => track.stop()); | |
} | |
}); | |
// Clear media recorder | |
if (mediaRecorder) { | |
if (mediaRecorder.state !== 'inactive') { | |
mediaRecorder.stop(); | |
} | |
mediaRecorder = null; | |
} | |
// Clear canvas | |
if (ctx) { | |
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); | |
} | |
// Reset state | |
isRecording = false; | |
isPaused = false; | |
recordingTime = 0; | |
recordedChunks = []; | |
// Clear timers | |
if (recordingTimer) { | |
clearInterval(recordingTimer); | |
recordingTimer = null; | |
} | |
if (animationFrameId) { | |
cancelAnimationFrame(animationFrameId); | |
animationFrameId = null; | |
} | |
} | |
}; | |
// Handle data available event | |
function handleDataAvailable(event: BlobEvent): void { | |
if (event.data && event.data.size > 0) { | |
recordedChunks.push(event.data); | |
} | |
} | |
// Handle recording stop | |
function handleRecordingStop(): void { | |
if (recordedChunks.length === 0) { | |
console.warn('No recording data available'); | |
return; | |
} | |
const mimeType = recording_format === 'webm' ? 'video/webm' : 'video/mp4'; | |
const blob = new Blob(recordedChunks, { type: mimeType }); | |
const url = URL.createObjectURL(blob); | |
console.log('Recording stopped. Blob size:', blob.size, 'bytes'); | |
// Create a file reader to read the blob as base64 | |
const reader = new FileReader(); | |
reader.onload = (e) => { | |
const base64data = e.target?.result as string; | |
// Extract the base64 data (remove the data URL prefix) | |
const base64Content = base64data.split(',')[1]; | |
const fileName = `recording_${Date.now()}.${recording_format}`; | |
// Dispatch event with recording data | |
const recordingData: RecordingData = { | |
video: url, | |
duration: recordingTime, | |
audio_enabled: audio_enabled, | |
status: 'completed', | |
size: blob.size > 0 ? blob.size : undefined, | |
orig_name: fileName, | |
name: fileName, // Alias for Gradio compatibility | |
is_file: true, | |
type: mimeType, | |
data: base64Content | |
}; | |
console.log('Dispatching recording-stopped event'); | |
dispatch('recording-stopped', recordingData); | |
dispatch('record_stop', recordingData); | |
dispatch('change', recordingData); | |
// Update the value prop to trigger re-render | |
value = { ...value, ...recordingData }; | |
}; | |
reader.onerror = (error) => { | |
console.error('Error reading blob:', error); | |
dispatch('error', { | |
message: 'Failed to process recording', | |
error: 'Could not read recording data' | |
}); | |
}; | |
// Read the blob as data URL | |
reader.readAsDataURL(blob); | |
} | |
// Update recording time | |
function updateRecordingTime(): void { | |
if (!isRecording) return; | |
recordingTime = Math.floor((Date.now() - recordingStartTime) / 1000); | |
// Check if max duration has been reached | |
if (max_duration !== null && max_duration > 0 && recordingTime >= max_duration) { | |
console.log('Max duration reached, stopping'); | |
componentMethods.stopRecording(); | |
return; | |
} | |
// Schedule the next update | |
recordingTimer = window.setTimeout(updateRecordingTime, 1000); | |
} | |
function stopTimer(): void { | |
if (recordingTimer) { | |
clearTimeout(recordingTimer); | |
recordingTimer = null; | |
} | |
} | |
// Format time as MM:SS | |
function formatTime(seconds: number): string { | |
const mins = Math.floor(seconds / 60); | |
const secs = Math.floor(seconds % 60); | |
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; | |
} | |
// Format file size in human-readable format | |
function formatFileSize(bytes: number | string | null | undefined): string { | |
if (bytes === null || bytes === undefined) return '0 B'; | |
const numBytes = Number(bytes); | |
if (isNaN(numBytes) || numBytes === 0) return '0 B'; | |
const k = 1024; | |
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; | |
const i = Math.floor(Math.log(numBytes) / Math.log(k)); | |
return parseFloat((numBytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
} | |
</script> | |
<div class="screen-recorder-container {!visible ? 'invisible' : ''} {elem_classes.join(' ')}" style="{containerStyle}"> | |
{#if loading_status} | |
<StatusTracker | |
autoscroll={gradio.autoscroll} | |
i18n={gradio.i18n} | |
{...loading_status} | |
/> | |
{/if} | |
<div class="screen-recorder"> | |
<div class="controls"> | |
{#if !isRecording} | |
<button | |
class="record-btn start" | |
on:click={componentMethods.startRecording} | |
disabled={!interactive} | |
> | |
<span class="recording-icon">●</span> Start Recording | |
</button> | |
{:else} | |
<button | |
class="record-btn stop" | |
on:click={componentMethods.stopRecording} | |
> | |
<span class="stop-icon">■</span> Stop Recording | |
</button> | |
<span class="recording-time"> | |
{formatTime(recordingTime)} | |
</span> | |
{#if max_duration} | |
<span class="max-duration">/ {formatTime(max_duration)}</span> | |
{/if} | |
{/if} | |
</div> | |
<!-- Live Preview - Always show when recording --> | |
{#if isRecording} | |
<div class="preview-container"> | |
<video | |
bind:this={previewVideo} | |
class="preview-video" | |
autoplay | |
muted | |
playsinline | |
aria-label="Live preview" | |
on:loadedmetadata={() => { | |
if (previewVideo) { | |
previewVideo.play().catch(console.warn); | |
} | |
}} | |
> | |
<track kind="captions" /> | |
</video> | |
{#if webcam_overlay} | |
<video | |
bind:this={webcamVideo} | |
class="webcam-overlay {webcam_position}" | |
style="width: 200px; height: 200px;" | |
autoplay | |
muted | |
playsinline | |
aria-label="Webcam overlay" | |
> | |
<track kind="captions" /> | |
</video> | |
{/if} | |
<div class="recording-indicator"> | |
<span class="pulse">●</span> RECORDING | |
</div> | |
</div> | |
{/if} | |
{#if value?.video} | |
<div class="recording-preview" style="position: relative;"> | |
{#if audio_enabled} | |
<div class="speaker-overlay">🔊</div> | |
{/if} | |
<video | |
src={value.video} | |
controls | |
class="preview-video" | |
aria-label="Recording preview" | |
on:loadedmetadata | |
on:loadeddata | |
on:error={(e) => console.error('Video error:', e)} | |
> | |
<track kind="captions" /> | |
</video> | |
<div class="recording-info"> | |
<div>Duration: {value.duration ? value.duration.toFixed(1) : '0.0'}s</div> | |
{#if value.size} | |
<div>Size: {formatFileSize(value.size)}</div> | |
{/if} | |
</div> | |
</div> | |
{/if} | |
<!-- Configuration Display --> | |
<div class="config-info"> | |
<span>Audio: {audio_enabled ? '🔊' : '🔇'}</span> | |
<span>Format: {recording_format.toUpperCase()}</span> | |
{#if max_duration} | |
<span>Max: {formatTime(max_duration)}</span> | |
{/if} | |
</div> | |
<!-- Debug info --> | |
{#if value} | |
<div class="debug-info"> | |
<small>Last recording: {value.orig_name} ({Math.round(value.size / 1024)}KB)</small> | |
</div> | |
{/if} | |
</div> | |
<video bind:this={screenVideoInternal} hidden muted playsinline style="display:none"></video> | |
{#if webcam_overlay} | |
<video bind:this={webcamVideoInternal} hidden muted playsinline style="display:none"></video> | |
{/if} | |
<canvas bind:this={canvas} use:bindCanvas hidden style="display:none"></canvas> | |
</div> | |
<style> | |
.screen-recorder-container { | |
display: block; | |
width: 100%; | |
box-sizing: border-box; | |
} | |
.screen-recorder-container.invisible { | |
display: none; | |
} | |
.screen-recorder { | |
border: 2px solid #e0e0e0; | |
border-radius: 8px; | |
padding: 16px; | |
background: #f9f9f9; | |
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
} | |
.controls { | |
display: flex; | |
align-items: center; | |
gap: 12px; | |
margin-bottom: 12px; | |
flex-wrap: wrap; | |
} | |
.record-btn { | |
padding: 10px 20px; | |
border: none; | |
border-radius: 6px; | |
font-size: 14px; | |
font-weight: 500; | |
cursor: pointer; | |
transition: all 0.2s; | |
box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
} | |
.record-btn.start { | |
background: #4CAF50; | |
color: white; | |
} | |
.record-btn.start:hover { | |
background: #45a049; | |
} | |
.record-btn.stop { | |
background: #f44336; | |
color: white; | |
} | |
.record-btn.stop:hover { | |
background: #da190b; | |
} | |
.record-btn:disabled { | |
opacity: 0.5; | |
cursor: not-allowed; | |
} | |
.recording-time { | |
font-family: 'Courier New', monospace; | |
font-size: 18px; | |
font-weight: bold; | |
color: #f44336; | |
} | |
.max-duration { | |
font-family: 'Courier New', monospace; | |
font-size: 14px; | |
color: #666; | |
} | |
.preview-container { | |
position: relative; | |
margin: 12px 0; | |
border-radius: 6px; | |
overflow: hidden; | |
background: black; | |
min-height: 200px; | |
} | |
.preview-video { | |
width: 100%; | |
max-height: 400px; | |
display: block; | |
object-fit: contain; | |
} | |
.recording-indicator { | |
position: absolute; | |
top: 10px; | |
left: 10px; | |
background: rgba(244, 67, 54, 0.9); | |
color: white; | |
padding: 6px 12px; | |
border-radius: 4px; | |
font-size: 12px; | |
font-weight: bold; | |
animation: pulse 1s infinite; | |
box-shadow: 0 2px 4px rgba(0,0,0,0.3); | |
} | |
@keyframes pulse { | |
0%, 100% { opacity: 1; } | |
50% { opacity: 0.7; } | |
} | |
.config-info { | |
display: flex; | |
gap: 8px; | |
font-size: 12px; | |
color: #666; | |
margin-top: 8px; | |
flex-wrap: wrap; | |
} | |
.config-info span { | |
padding: 4px 8px; | |
background: #e8e8e8; | |
border-radius: 4px; | |
border: 1px solid #ddd; | |
} | |
.debug-info { | |
margin-top: 8px; | |
padding: 8px; | |
background: #e8f5e8; | |
border-radius: 4px; | |
border: 1px solid #c8e6c8; | |
} | |
.speaker-overlay { | |
position: absolute; | |
top: 8px; | |
right: 8px; | |
background: rgba(0,0,0,0.5); | |
color: white; | |
padding: 4px; | |
border-radius: 4px; | |
font-size: 14px; | |
pointer-events: none; | |
} | |
</style> | |