mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-04-24 08:46:15 -04:00
feat(new tool): camera screenshot and recorder
This commit is contained in:
parent
8515c24264
commit
34d8e5ce2c
15 changed files with 448 additions and 10 deletions
202
src/tools/camera-recorder/camera-recorder.vue
Normal file
202
src/tools/camera-recorder/camera-recorder.vue
Normal file
|
@ -0,0 +1,202 @@
|
|||
<template>
|
||||
<div>
|
||||
<c-card v-if="!isSupported"> Your browser does not support recording video from camera </c-card>
|
||||
|
||||
<c-card v-else-if="!permissionGranted" text-center>
|
||||
You need to grant permission to use your camera and microphone
|
||||
|
||||
<c-alert v-if="permissionCannotBePrompted" mt-4 text-left>
|
||||
Your browser has blocked permission request or does not support it. You need to grant permission manually in
|
||||
your browser settings (usually the lock icon in the address bar).
|
||||
</c-alert>
|
||||
|
||||
<div v-else mt-4 flex justify-center>
|
||||
<c-button @click="requestPermissions">Grant permission</c-button>
|
||||
</div>
|
||||
</c-card>
|
||||
|
||||
<c-card v-else>
|
||||
<div flex gap-2>
|
||||
<div flex-1>
|
||||
<div>Video</div>
|
||||
<n-select
|
||||
v-model:value="currentCamera"
|
||||
:options="cameras.map(({ deviceId, label }) => ({ value: deviceId, label }))"
|
||||
placeholder="Select camera"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="currentMicrophone && microphones.length > 0" flex-1>
|
||||
<div>Audio</div>
|
||||
<n-select
|
||||
v-model:value="currentMicrophone"
|
||||
:options="microphones.map(({ deviceId, label }) => ({ value: deviceId, label }))"
|
||||
placeholder="Select microphone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isMediaStreamAvailable" mt-3 flex justify-center>
|
||||
<c-button type="primary" @click="start">Start webcam</c-button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div my-2>
|
||||
<video ref="video" autoplay controls playsinline max-h-full w-full />
|
||||
</div>
|
||||
|
||||
<div flex items-center justify-between gap-2>
|
||||
<c-button :disabled="!isMediaStreamAvailable" @click="takeScreenshot">
|
||||
<span mr-2> <icon-mdi-camera /></span>
|
||||
Take screenshot
|
||||
</c-button>
|
||||
|
||||
<div v-if="isRecordingSupported" flex justify-center gap-2>
|
||||
<c-button v-if="recordingState === 'stopped'" @click="startRecording">
|
||||
<span mr-2> <icon-mdi-video /></span>
|
||||
Start recording
|
||||
</c-button>
|
||||
|
||||
<c-button v-if="recordingState === 'recording'" @click="pauseRecording">
|
||||
<span mr-2> <icon-mdi-pause /></span>
|
||||
Pause
|
||||
</c-button>
|
||||
|
||||
<c-button v-if="recordingState === 'paused'" @click="resumeRecording">
|
||||
<span mr-2> <icon-mdi-play /></span>
|
||||
Resume
|
||||
</c-button>
|
||||
|
||||
<c-button v-if="recordingState !== 'stopped'" type="error" @click="stopRecording">
|
||||
<span mr-2> <icon-mdi-record /></span>
|
||||
Stop
|
||||
</c-button>
|
||||
</div>
|
||||
<div v-else italic op-60>Video recording is not supported in your browser</div>
|
||||
</div>
|
||||
</div>
|
||||
</c-card>
|
||||
|
||||
<div grid grid-cols-2 mt-5 gap-2>
|
||||
<c-card v-for="({ type, value, createdAt }, index) in medias" :key="index">
|
||||
<img v-if="type === 'image'" :src="value" max-h-full w-full alt="screenshot" />
|
||||
|
||||
<video v-else :src="value" controls max-h-full w-full />
|
||||
|
||||
<div flex items-center justify-between>
|
||||
<div font-bold>{{ type === 'image' ? 'Screenshot' : 'Video' }}</div>
|
||||
|
||||
<div flex gap-2>
|
||||
<c-button @click="downloadMedia({ type, value, createdAt })">
|
||||
<icon-mdi-download />
|
||||
</c-button>
|
||||
|
||||
<c-button @click="medias = medias.filter((_ignored, i) => i !== index)">
|
||||
<icon-mdi-delete-outline />
|
||||
</c-button>
|
||||
</div>
|
||||
</div>
|
||||
</c-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import _ from 'lodash';
|
||||
|
||||
import { useMediaRecorder } from './useMediaRecorder';
|
||||
|
||||
type Media = { type: 'image' | 'video'; value: string; createdAt: Date };
|
||||
|
||||
const {
|
||||
videoInputs: cameras,
|
||||
audioInputs: microphones,
|
||||
permissionGranted,
|
||||
isSupported,
|
||||
ensurePermissions,
|
||||
} = useDevicesList({
|
||||
requestPermissions: true,
|
||||
constraints: { video: true, audio: true },
|
||||
onUpdated() {
|
||||
refreshCurrentDevices();
|
||||
},
|
||||
});
|
||||
|
||||
const video = ref<HTMLVideoElement>();
|
||||
const medias = ref<Media[]>([]);
|
||||
const currentCamera = ref(cameras.value[0]?.deviceId);
|
||||
const currentMicrophone = ref(microphones.value[0]?.deviceId);
|
||||
const permissionCannotBePrompted = ref(false);
|
||||
|
||||
const {
|
||||
stream,
|
||||
start,
|
||||
enabled: isMediaStreamAvailable,
|
||||
} = useUserMedia({
|
||||
constraints: computed(() => ({
|
||||
video: { deviceId: currentCamera.value },
|
||||
...(currentMicrophone.value ? { audio: { deviceId: currentMicrophone.value } } : {}),
|
||||
})),
|
||||
autoSwitch: true,
|
||||
});
|
||||
|
||||
const {
|
||||
isRecordingSupported,
|
||||
onRecordAvailable,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
pauseRecording,
|
||||
recordingState,
|
||||
resumeRecording,
|
||||
} = useMediaRecorder({
|
||||
stream,
|
||||
});
|
||||
|
||||
onRecordAvailable((value) => {
|
||||
medias.value.unshift({ type: 'video', value, createdAt: new Date() });
|
||||
});
|
||||
|
||||
function refreshCurrentDevices() {
|
||||
console.log('refreshCurrentDevices');
|
||||
|
||||
if (_.isNil(currentCamera) || !cameras.value.find((i) => i.deviceId === currentCamera.value)) {
|
||||
currentCamera.value = cameras.value[0]?.deviceId;
|
||||
}
|
||||
|
||||
if (_.isNil(microphones) || !microphones.value.find((i) => i.deviceId === currentMicrophone.value)) {
|
||||
currentMicrophone.value = microphones.value[0]?.deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
function takeScreenshot() {
|
||||
if (!video.value) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.value.videoWidth;
|
||||
canvas.height = video.value.videoHeight;
|
||||
canvas.getContext('2d')?.drawImage(video.value, 0, 0);
|
||||
const image = canvas.toDataURL('image/png');
|
||||
|
||||
medias.value.unshift({ type: 'image', value: image, createdAt: new Date() });
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (video.value && stream.value) video.value.srcObject = stream.value;
|
||||
});
|
||||
|
||||
async function requestPermissions() {
|
||||
try {
|
||||
await ensurePermissions();
|
||||
} catch (e) {
|
||||
permissionCannotBePrompted.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadMedia({ type, value, createdAt }: Media) {
|
||||
const link = document.createElement('a');
|
||||
link.href = value;
|
||||
link.download = `${type}-${createdAt.getTime()}.${type === 'image' ? 'png' : 'webm'}`;
|
||||
link.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
12
src/tools/camera-recorder/index.ts
Normal file
12
src/tools/camera-recorder/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { Camera } from '@vicons/tabler';
|
||||
import { defineTool } from '../tool';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'Camera recorder',
|
||||
path: '/camera-recorder',
|
||||
description: 'Take a picture or record a video from your webcam or camera.',
|
||||
keywords: ['camera', 'recoder'],
|
||||
component: () => import('./camera-recorder.vue'),
|
||||
icon: Camera,
|
||||
createdAt: new Date('2023-05-15'),
|
||||
});
|
88
src/tools/camera-recorder/useMediaRecorder.ts
Normal file
88
src/tools/camera-recorder/useMediaRecorder.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import { computed, ref, type Ref } from 'vue';
|
||||
|
||||
export { useMediaRecorder };
|
||||
|
||||
function useMediaRecorder({ stream }: { stream: Ref<MediaStream | undefined> }): {
|
||||
isRecordingSupported: Ref<boolean>;
|
||||
recordingState: Ref<'stopped' | 'recording' | 'paused'>;
|
||||
startRecording: () => void;
|
||||
stopRecording: () => void;
|
||||
pauseRecording: () => void;
|
||||
resumeRecording: () => void;
|
||||
onRecordAvailable: (cb: (url: string) => void) => void;
|
||||
} {
|
||||
const isRecordingSupported = computed(() => MediaRecorder.isTypeSupported('video/webm'));
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const recordedChunks = ref<Blob[]>([]);
|
||||
const recordAvailable = createEventHook();
|
||||
const recordingState = ref<'stopped' | 'recording' | 'paused'>('stopped');
|
||||
|
||||
const startRecording = () => {
|
||||
if (!isRecordingSupported.value) return;
|
||||
if (!stream.value) return;
|
||||
if (recordingState.value !== 'stopped') return;
|
||||
|
||||
mediaRecorder.value = new MediaRecorder(stream.value, { mimeType: 'video/webm' });
|
||||
|
||||
mediaRecorder.value.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
recordedChunks.value.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.value.onstop = () => {
|
||||
recordAvailable.trigger(createVideo());
|
||||
};
|
||||
|
||||
if (mediaRecorder.value.state !== 'inactive') return;
|
||||
|
||||
mediaRecorder.value.start();
|
||||
recordingState.value = 'recording';
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (!isRecordingSupported.value) return;
|
||||
if (!mediaRecorder.value) return;
|
||||
if (recordingState.value === 'stopped') return;
|
||||
|
||||
mediaRecorder.value.stop();
|
||||
recordingState.value = 'stopped';
|
||||
};
|
||||
|
||||
const pauseRecording = () => {
|
||||
if (!isRecordingSupported.value) return;
|
||||
if (!mediaRecorder.value) return;
|
||||
if (recordingState.value !== 'recording') return;
|
||||
|
||||
mediaRecorder.value.pause();
|
||||
recordingState.value = 'paused';
|
||||
};
|
||||
|
||||
const resumeRecording = () => {
|
||||
if (!isRecordingSupported.value) return;
|
||||
if (!mediaRecorder.value) return;
|
||||
|
||||
if (recordingState.value !== 'paused') return;
|
||||
|
||||
mediaRecorder.value.resume();
|
||||
recordingState.value = 'recording';
|
||||
};
|
||||
|
||||
const createVideo = () => {
|
||||
const blob = new Blob(recordedChunks.value, { type: 'video/webm' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
recordedChunks.value = [];
|
||||
return url;
|
||||
};
|
||||
|
||||
return {
|
||||
isRecordingSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
pauseRecording,
|
||||
resumeRecording,
|
||||
recordingState,
|
||||
|
||||
onRecordAvailable: recordAvailable.on,
|
||||
};
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { tool as base64FileConverter } from './base64-file-converter';
|
||||
import { tool as base64StringConverter } from './base64-string-converter';
|
||||
import { tool as basicAuthGenerator } from './basic-auth-generator';
|
||||
import { tool as cameraRecorder } from './camera-recorder';
|
||||
import { tool as listConverter } from './list-converter';
|
||||
import { tool as phoneParserAndFormatter } from './phone-parser-and-formatter';
|
||||
import { tool as jsonDiff } from './json-diff';
|
||||
|
@ -99,8 +100,8 @@ export const toolsByCategory: ToolCategory[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
name: 'Images',
|
||||
components: [qrCodeGenerator, svgPlaceholderGenerator],
|
||||
name: 'Images and videos',
|
||||
components: [qrCodeGenerator, svgPlaceholderGenerator, cameraRecorder],
|
||||
},
|
||||
{
|
||||
name: 'Development',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue