Skip to content

Commit 8f05669

Browse files
committed
feat: video clipping
1 parent 882ec93 commit 8f05669

File tree

10 files changed

+741
-5
lines changed

10 files changed

+741
-5
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ all: ui creamy-nvr
22

33
.PHONY: ui
44
ui:
5-
cd ui && npm ci && npm run build
5+
cd ui && npm ci && cp node_modules/@ffmpeg/core/dist/esm/* public/ffmpeg/ && npm run build
66

77
.PHONY: creamy-nvr
88
creamy-nvr:

ui/package-lock.json

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"lint": "eslint . --fix"
1313
},
1414
"dependencies": {
15+
"@ffmpeg/core": "^0.12.10",
16+
"@ffmpeg/ffmpeg": "^0.12.15",
17+
"@ffmpeg/util": "^0.12.2",
1518
"@tailwindcss/postcss": "^4.1.5",
1619
"@tailwindcss/vite": "^4.1.5",
1720
"hls.js": "^1.6.2",

ui/public/ffmpeg/ffmpeg-core.js

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/public/ffmpeg/ffmpeg-core.wasm

30.7 MB
Binary file not shown.

ui/public/ffmpeg/ffmpeg-core.worker.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/components/VideoClipper.vue

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue';
3+
import { X, Scissors, Download, Loader2, AlertCircle } from 'lucide-vue-next';
4+
import { useFFmpeg } from '@/composables/useFFmpeg';
5+
import type { Recording } from '@/stores/streamTypes';
6+
7+
const props = defineProps<{
8+
visible: boolean;
9+
clipStart: number; // Unix timestamp in milliseconds
10+
clipEnd: number; // Unix timestamp in milliseconds
11+
recordings: Recording[]; // Recordings that overlap with the clip range
12+
}>();
13+
14+
const emit = defineEmits<{
15+
close: [];
16+
}>();
17+
18+
const ffmpeg = useFFmpeg();
19+
const isProcessing = ref(false);
20+
const error = ref<string | null>(null);
21+
const outputBlob = ref<Blob | null>(null);
22+
23+
const clipDuration = computed(() => {
24+
const durationMs = props.clipEnd - props.clipStart;
25+
const seconds = Math.floor(durationMs / 1000);
26+
const minutes = Math.floor(seconds / 60);
27+
const remainingSeconds = seconds % 60;
28+
return `${minutes}m ${remainingSeconds}s`;
29+
});
30+
31+
const estimatedSize = computed(() => {
32+
const durationSeconds = (props.clipEnd - props.clipStart) / 1000;
33+
// Rough estimate: ~1MB per minute at typical recording bitrate
34+
const sizeMB = (durationSeconds / 60) * 1.0;
35+
if (sizeMB < 1) {
36+
return `~${(sizeMB * 1024).toFixed(0)} KB`;
37+
}
38+
return `~${sizeMB.toFixed(1)} MB`;
39+
});
40+
41+
const formatTimestamp = (timestamp: number) => {
42+
return new Date(timestamp).toLocaleString();
43+
};
44+
45+
const handleClose = () => {
46+
if (isProcessing.value) {
47+
const confirm = window.confirm('Processing is in progress. Are you sure you want to cancel?');
48+
if (!confirm) return;
49+
}
50+
emit('close');
51+
// Reset state
52+
outputBlob.value = null;
53+
error.value = null;
54+
};
55+
56+
const createClip = async () => {
57+
isProcessing.value = true;
58+
error.value = null;
59+
outputBlob.value = null;
60+
61+
try {
62+
// Load FFmpeg if not already loaded
63+
if (!ffmpeg.isLoaded.value) {
64+
await ffmpeg.loadFFmpeg();
65+
}
66+
67+
// Fetch all required recordings
68+
const videoBlobs: Blob[] = [];
69+
let accumulatedDuration = 0; // Duration in seconds from start of first recording
70+
let trimStartInConcatenated: number | undefined;
71+
let trimDuration: number | undefined;
72+
73+
for (const recording of props.recordings) {
74+
const recStart = new Date(recording.start).getTime();
75+
const recEnd = new Date(recording.end).getTime();
76+
const recDuration = (recEnd - recStart) / 1000; // seconds
77+
78+
// Fetch the video file
79+
const response = await fetch(recording.path);
80+
if (!response.ok) {
81+
throw new Error(`Failed to fetch recording: ${recording.path}`);
82+
}
83+
const blob = await response.blob();
84+
videoBlobs.push(blob);
85+
86+
// Calculate trim points if this is the first recording
87+
if (videoBlobs.length === 1) {
88+
// Calculate start offset within the first recording
89+
const startOffsetMs = props.clipStart - recStart;
90+
if (startOffsetMs > 0) {
91+
trimStartInConcatenated = startOffsetMs / 1000; // convert to seconds
92+
} else {
93+
trimStartInConcatenated = 0;
94+
}
95+
}
96+
97+
accumulatedDuration += recDuration;
98+
}
99+
100+
// Calculate the trim duration
101+
const totalClipDuration = (props.clipEnd - props.clipStart) / 1000; // seconds
102+
trimDuration = totalClipDuration;
103+
104+
let resultBlob: Blob;
105+
106+
if (videoBlobs.length === 1) {
107+
// Single recording - just trim it
108+
resultBlob = await ffmpeg.trimVideo(
109+
videoBlobs[0],
110+
trimStartInConcatenated!,
111+
trimDuration,
112+
'clip.mp4'
113+
);
114+
} else {
115+
// Multiple recordings - concatenate then trim
116+
resultBlob = await ffmpeg.concatenateVideos(
117+
videoBlobs,
118+
trimStartInConcatenated,
119+
trimDuration,
120+
'clip.mp4'
121+
);
122+
}
123+
124+
outputBlob.value = resultBlob;
125+
} catch (err) {
126+
console.error('Clip creation failed:', err);
127+
error.value = err instanceof Error ? err.message : 'Failed to create clip';
128+
} finally {
129+
isProcessing.value = false;
130+
}
131+
};
132+
133+
const downloadClip = () => {
134+
if (!outputBlob.value) return;
135+
136+
const filename = `clip_${new Date(props.clipStart).toISOString().replace(/[:.]/g, '-')}_${clipDuration.value.replace(' ', '')}.mp4`;
137+
ffmpeg.downloadBlob(outputBlob.value, filename);
138+
};
139+
140+
const handleBackdropClick = (event: MouseEvent) => {
141+
if (event.target === event.currentTarget) {
142+
handleClose();
143+
}
144+
};
145+
</script>
146+
147+
<template>
148+
<div
149+
v-if="visible"
150+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
151+
@click="handleBackdropClick"
152+
>
153+
<div class="bg-gray-900 rounded-lg shadow-xl w-full max-w-lg mx-4 text-white">
154+
<!-- Header -->
155+
<div class="flex items-center justify-between p-6 border-b border-gray-800">
156+
<div class="flex items-center gap-2">
157+
<Scissors :size="24" />
158+
<h2 class="text-xl font-semibold">Create Video Clip</h2>
159+
</div>
160+
<button
161+
@click="handleClose"
162+
class="text-gray-400 hover:text-white transition-colors"
163+
:disabled="isProcessing"
164+
>
165+
<X :size="24" />
166+
</button>
167+
</div>
168+
169+
<!-- Content -->
170+
<div class="p-6 space-y-4">
171+
<!-- Clip info -->
172+
<div class="space-y-2">
173+
<div class="flex justify-between text-sm">
174+
<span class="text-gray-400">Start:</span>
175+
<span class="font-mono">{{ formatTimestamp(clipStart) }}</span>
176+
</div>
177+
<div class="flex justify-between text-sm">
178+
<span class="text-gray-400">End:</span>
179+
<span class="font-mono">{{ formatTimestamp(clipEnd) }}</span>
180+
</div>
181+
<div class="flex justify-between text-sm">
182+
<span class="text-gray-400">Duration:</span>
183+
<span class="font-mono">{{ clipDuration }}</span>
184+
</div>
185+
<!-- <div class="flex justify-between text-sm">
186+
<span class="text-gray-400">Estimated size:</span>
187+
<span class="font-mono">{{ estimatedSize }}</span>
188+
</div> -->
189+
<div class="flex justify-between text-sm">
190+
<span class="text-gray-400">Recordings:</span>
191+
<span class="font-mono">{{ recordings.length }}</span>
192+
</div>
193+
</div>
194+
195+
<!-- Warning for large clips -->
196+
<div
197+
v-if="recordings.length > 5 || (clipEnd - clipStart) / 1000 > 600"
198+
class="flex items-start gap-2 p-3 bg-yellow-900/30 border border-yellow-600/50 rounded text-sm"
199+
>
200+
<AlertCircle :size="16" class="mt-0.5 flex-shrink-0" />
201+
<div>
202+
<p class="font-medium">Large clip detected</p>
203+
<p class="text-gray-300 text-xs mt-1">
204+
This clip is large and may take several minutes to process. Processing happens entirely in your browser.
205+
</p>
206+
</div>
207+
</div>
208+
209+
<!-- Progress -->
210+
<div v-if="isProcessing" class="space-y-2">
211+
<div class="flex items-center gap-2 text-sm text-gray-300">
212+
<Loader2 :size="16" class="animate-spin" />
213+
<span>{{ ffmpeg.currentOperation.value || 'Processing...' }}</span>
214+
</div>
215+
<div class="w-full bg-gray-800 rounded-full h-2">
216+
<div
217+
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
218+
:style="{ width: `${ffmpeg.progress.value}%` }"
219+
></div>
220+
</div>
221+
<p class="text-xs text-gray-400">
222+
{{ Math.round(ffmpeg.progress.value) }}%
223+
</p>
224+
</div>
225+
226+
<!-- Error -->
227+
<div
228+
v-if="error"
229+
class="flex items-start gap-2 p-3 bg-red-900/30 border border-red-600/50 rounded text-sm"
230+
>
231+
<AlertCircle :size="16" class="mt-0.5 flex-shrink-0" />
232+
<div>
233+
<p class="font-medium">Error</p>
234+
<p class="text-gray-300 text-xs mt-1">{{ error }}</p>
235+
</div>
236+
</div>
237+
238+
<!-- Success -->
239+
<div
240+
v-if="outputBlob && !isProcessing"
241+
class="flex items-center gap-2 p-3 bg-green-900/30 border border-green-600/50 rounded text-sm"
242+
>
243+
<div class="flex-1">
244+
<p class="font-medium">Clip ready!</p>
245+
<p class="text-gray-300 text-xs mt-1">
246+
Size: {{ (outputBlob.size / 1024 / 1024).toFixed(2) }} MB
247+
</p>
248+
</div>
249+
</div>
250+
</div>
251+
252+
<!-- Footer -->
253+
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-800">
254+
<button
255+
@click="handleClose"
256+
class="px-4 py-2 text-gray-300 hover:text-white transition-colors"
257+
:disabled="isProcessing"
258+
>
259+
{{ outputBlob ? 'Close' : 'Cancel' }}
260+
</button>
261+
<button
262+
v-if="!outputBlob"
263+
@click="createClip"
264+
:disabled="isProcessing || recordings.length === 0"
265+
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded transition-colors"
266+
>
267+
<Scissors v-if="!isProcessing" :size="16" />
268+
<Loader2 v-else :size="16" class="animate-spin" />
269+
<span>{{ isProcessing ? 'Creating...' : 'Create Clip' }}</span>
270+
</button>
271+
<button
272+
v-else
273+
@click="downloadClip"
274+
class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 rounded transition-colors"
275+
>
276+
<Download :size="16" />
277+
<span>Download</span>
278+
</button>
279+
</div>
280+
</div>
281+
</div>
282+
</template>
283+
284+
<style scoped>
285+
/* Add any additional custom styles if needed */
286+
</style>

0 commit comments

Comments
 (0)