From 77ac4a415c0421084ff4ed8d0a12beb333ca41a8 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sat, 3 May 2025 23:00:07 -0400 Subject: [PATCH] [3d] add recording video support (#3749) Co-authored-by: github-actions --- src/components/load3d/Load3D.vue | 52 +++++ .../load3d/controls/RecordingControls.vue | 176 +++++++++++++++++ src/extensions/core/load3d.ts | 19 +- src/extensions/core/load3d/Load3d.ts | 42 +++- src/extensions/core/load3d/Load3dUtils.ts | 14 +- .../core/load3d/RecordingManager.ts | 183 ++++++++++++++++++ .../core/load3d/ViewHelperManager.ts | 10 + src/extensions/core/load3d/interfaces.ts | 9 + src/locales/en/main.json | 10 +- src/locales/es/main.json | 6 + src/locales/fr/main.json | 6 + src/locales/ja/main.json | 6 + src/locales/ko/main.json | 6 + src/locales/ru/main.json | 6 + src/locales/zh/main.json | 6 + 15 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 src/components/load3d/controls/RecordingControls.vue create mode 100644 src/extensions/core/load3d/RecordingManager.ts diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue index 8a3dbd8f2..00d5f8c5c 100644 --- a/src/components/load3d/Load3D.vue +++ b/src/components/load3d/Load3D.vue @@ -57,6 +57,21 @@ @upload-texture="handleUploadTexture" @export-model="handleExportModel" /> +
+ +
@@ -66,6 +81,7 @@ import { useI18n } from 'vue-i18n' import Load3DControls from '@/components/load3d/Load3DControls.vue' import Load3DScene from '@/components/load3d/Load3DScene.vue' +import RecordingControls from '@/components/load3d/controls/RecordingControls.vue' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import { CameraType, @@ -101,6 +117,10 @@ const upDirection = ref('original') const materialMode = ref('original') const edgeThreshold = ref(85) const load3DSceneRef = ref | null>(null) +const isRecording = ref(false) +const hasRecording = ref(false) +const recordingDuration = ref(0) +const showRecordingControls = ref(!inputSpec.isPreview) const showPreviewButton = computed(() => { return !type.includes('Preview') @@ -118,6 +138,38 @@ const handleMouseLeave = () => { } } +const handleStartRecording = async () => { + if (load3DSceneRef.value?.load3d) { + await load3DSceneRef.value.load3d.startRecording() + isRecording.value = true + } +} + +const handleStopRecording = () => { + if (load3DSceneRef.value?.load3d) { + load3DSceneRef.value.load3d.stopRecording() + isRecording.value = false + hasRecording.value = true + recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration() + } +} + +const handleExportRecording = () => { + if (load3DSceneRef.value?.load3d) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const filename = `${timestamp}-scene-recording.mp4` + load3DSceneRef.value.load3d.exportRecording(filename) + } +} + +const handleClearRecording = () => { + if (load3DSceneRef.value?.load3d) { + load3DSceneRef.value.load3d.clearRecording() + hasRecording.value = false + recordingDuration.value = 0 + } +} + const switchCamera = () => { cameraType.value = cameraType.value === 'perspective' ? 'orthographic' : 'perspective' diff --git a/src/components/load3d/controls/RecordingControls.vue b/src/components/load3d/controls/RecordingControls.vue new file mode 100644 index 000000000..a0b4501f3 --- /dev/null +++ b/src/components/load3d/controls/RecordingControls.vue @@ -0,0 +1,176 @@ + + + diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index 390f01370..5fcd529fe 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -215,6 +215,8 @@ useExtensionService().registerExtension({ sceneWidget.serializeValue = async () => { node.properties['Camera Info'] = load3d.getCameraState() + load3d.stopRecording() + const { scene: imageData, mask: maskData, @@ -234,13 +236,26 @@ useExtensionService().registerExtension({ load3d.handleResize() - return { + const returnVal = { image: `threed/${data.name} [temp]`, mask: `threed/${dataMask.name} [temp]`, normal: `threed/${dataNormal.name} [temp]`, lineart: `threed/${dataLineart.name} [temp]`, - camera_info: node.properties['Camera Info'] + camera_info: node.properties['Camera Info'], + recording: '' } + + const recordingData = load3d.getRecordingData() + + if (recordingData) { + const [recording] = await Promise.all([ + Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4') + ]) + + returnVal['recording'] = `threed/${recording.name} [temp]` + } + + return returnVal } } } diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 0245a78d2..0989c7344 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -12,6 +12,7 @@ import { ModelExporter } from './ModelExporter' import { ModelManager } from './ModelManager' import { NodeStorage } from './NodeStorage' import { PreviewManager } from './PreviewManager' +import { RecordingManager } from './RecordingManager' import { SceneManager } from './SceneManager' import { ViewHelperManager } from './ViewHelperManager' import { @@ -38,6 +39,7 @@ class Load3d { protected previewManager: PreviewManager protected loaderManager: LoaderManager protected modelManager: ModelManager + protected recordingManager: RecordingManager STATUS_MOUSE_ON_NODE: boolean STATUS_MOUSE_ON_SCENE: boolean @@ -118,6 +120,11 @@ class Load3d { this.loaderManager = new LoaderManager(this.modelManager, this.eventManager) + this.recordingManager = new RecordingManager( + this.sceneManager.scene, + this.renderer, + this.eventManager + ) this.sceneManager.init() this.cameraManager.init() this.controlsManager.init() @@ -439,7 +446,39 @@ class Load3d { return this.nodeStorage.loadNodeProperty(name, defaultValue) } - remove(): void { + public async startRecording(): Promise { + this.viewHelperManager.visibleViewHelper(false) + + return this.recordingManager.startRecording() + } + + public stopRecording(): void { + this.viewHelperManager.visibleViewHelper(true) + + this.recordingManager.stopRecording() + } + + public isRecording(): boolean { + return this.recordingManager.hasRecording() + } + + public getRecordingDuration(): number { + return this.recordingManager.getRecordingDuration() + } + + public getRecordingData(): string | null { + return this.recordingManager.getRecordingData() + } + + public exportRecording(filename?: string): void { + this.recordingManager.exportRecording(filename) + } + + public clearRecording(): void { + this.recordingManager.clearRecording() + } + + public remove(): void { if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId) } @@ -452,6 +491,7 @@ class Load3d { this.previewManager.dispose() this.loaderManager.dispose() this.modelManager.dispose() + this.recordingManager.dispose() this.renderer.dispose() this.renderer.domElement.remove() diff --git a/src/extensions/core/load3d/Load3dUtils.ts b/src/extensions/core/load3d/Load3dUtils.ts index 79d486bf9..7f3ef7a60 100644 --- a/src/extensions/core/load3d/Load3dUtils.ts +++ b/src/extensions/core/load3d/Load3dUtils.ts @@ -4,10 +4,16 @@ import { app } from '@/scripts/app' import { useToastStore } from '@/stores/toastStore' class Load3dUtils { - static async uploadTempImage(imageData: string, prefix: string) { + static async uploadTempImage( + imageData: string, + prefix: string, + fileType: string = 'png' + ) { const blob = await fetch(imageData).then((r) => r.blob()) - const name = `${prefix}_${Date.now()}.png` - const file = new File([blob], name) + const name = `${prefix}_${Date.now()}.${fileType}` + const file = new File([blob], name, { + type: fileType === 'mp4' ? 'video/mp4' : 'image/png' + }) const body = new FormData() body.append('image', file) @@ -20,7 +26,7 @@ class Load3dUtils { }) if (resp.status !== 200) { - const err = `Error uploading temp image: ${resp.status} - ${resp.statusText}` + const err = `Error uploading temp file: ${resp.status} - ${resp.statusText}` useToastStore().addAlert(err) throw new Error(err) } diff --git a/src/extensions/core/load3d/RecordingManager.ts b/src/extensions/core/load3d/RecordingManager.ts new file mode 100644 index 000000000..169ddcd54 --- /dev/null +++ b/src/extensions/core/load3d/RecordingManager.ts @@ -0,0 +1,183 @@ +import * as THREE from 'three' + +import { EventManagerInterface } from './interfaces' + +export class RecordingManager { + private mediaRecorder: MediaRecorder | null = null + private recordedChunks: Blob[] = [] + private isRecording: boolean = false + private recordingStream: MediaStream | null = null + private recordingIndicator: THREE.Sprite | null = null + private scene: THREE.Scene + private renderer: THREE.WebGLRenderer + private eventManager: EventManagerInterface + private recordingStartTime: number = 0 + private recordingDuration: number = 0 + private recordingCanvas: HTMLCanvasElement | null = null + + constructor( + scene: THREE.Scene, + renderer: THREE.WebGLRenderer, + eventManager: EventManagerInterface + ) { + this.scene = scene + this.renderer = renderer + this.eventManager = eventManager + this.setupRecordingIndicator() + } + + private setupRecordingIndicator(): void { + const map = new THREE.TextureLoader().load( + 'data:image/svg+xml;base64,' + + btoa(` + + + `) + ) + const material = new THREE.SpriteMaterial({ + map: map, + transparent: true, + depthTest: false, + depthWrite: false + }) + this.recordingIndicator = new THREE.Sprite(material) + this.recordingIndicator.scale.set(0.5, 0.5, 0.5) + this.recordingIndicator.position.set(-0.8, 0.8, 0) + this.recordingIndicator.visible = false + + this.scene.add(this.recordingIndicator) + } + + public async startRecording(): Promise { + if (this.isRecording) { + return + } + + try { + this.recordingCanvas = this.renderer.domElement + + this.recordingStream = this.recordingCanvas.captureStream(30) + + if (!this.recordingStream) { + throw new Error('Failed to capture stream from canvas') + } + + this.mediaRecorder = new MediaRecorder(this.recordingStream, { + mimeType: 'video/webm;codecs=vp9', + videoBitsPerSecond: 5000000 + }) + + this.recordedChunks = [] + + this.mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + this.recordedChunks.push(event.data) + } + } + + this.mediaRecorder.onstop = () => { + this.recordingIndicator!.visible = false + this.isRecording = false + this.recordingStream = null + + this.eventManager.emitEvent('recordingStopped', { + duration: this.recordingDuration, + hasRecording: this.recordedChunks.length > 0 + }) + } + + if (this.recordingIndicator) { + this.recordingIndicator.visible = true + } + + this.mediaRecorder.start(100) + this.isRecording = true + this.recordingStartTime = Date.now() + + this.eventManager.emitEvent('recordingStarted', null) + } catch (error) { + console.error('Error starting recording:', error) + this.eventManager.emitEvent('recordingError', error) + } + } + + public stopRecording(): void { + if (!this.isRecording || !this.mediaRecorder) { + return + } + + this.recordingDuration = (Date.now() - this.recordingStartTime) / 1000 // In seconds + + this.mediaRecorder.stop() + if (this.recordingStream) { + this.recordingStream.getTracks().forEach((track) => track.stop()) + } + } + + public hasRecording(): boolean { + return this.recordedChunks.length > 0 + } + + public getRecordingDuration(): number { + return this.recordingDuration + } + + public getRecordingData(): string | null { + if (this.recordedChunks.length !== 0) { + const blob = new Blob(this.recordedChunks, { type: 'video/webm' }) + + return URL.createObjectURL(blob) + } + + return null + } + + public exportRecording(filename: string = 'scene-recording.mp4'): void { + if (this.recordedChunks.length === 0) { + this.eventManager.emitEvent( + 'recordingError', + new Error('No recording available to export') + ) + return + } + + this.eventManager.emitEvent('exportingRecording', null) + + try { + const blob = new Blob(this.recordedChunks, { type: 'video/webm' }) + + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + document.body.appendChild(a) + a.style.display = 'none' + a.href = url + a.download = filename + a.click() + + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + + this.eventManager.emitEvent('recordingExported', null) + } catch (error) { + console.error('Error exporting recording:', error) + this.eventManager.emitEvent('recordingError', error) + } + } + + public clearRecording(): void { + this.recordedChunks = [] + this.recordingDuration = 0 + this.eventManager.emitEvent('recordingCleared', null) + } + + public dispose(): void { + this.stopRecording() + this.clearRecording() + + if (this.recordingIndicator) { + this.scene.remove(this.recordingIndicator) + ;(this.recordingIndicator.material as THREE.SpriteMaterial).map?.dispose() + ;(this.recordingIndicator.material as THREE.SpriteMaterial).dispose() + } + } +} diff --git a/src/extensions/core/load3d/ViewHelperManager.ts b/src/extensions/core/load3d/ViewHelperManager.ts index f3d2a9db0..eeb8f9ad2 100644 --- a/src/extensions/core/load3d/ViewHelperManager.ts +++ b/src/extensions/core/load3d/ViewHelperManager.ts @@ -89,6 +89,16 @@ export class ViewHelperManager implements ViewHelperManagerInterface { handleResize(): void {} + visibleViewHelper(visible: boolean) { + if (visible) { + this.viewHelper.visible = true + this.viewHelperContainer.style.display = 'block' + } else { + this.viewHelper.visible = false + this.viewHelperContainer.style.display = 'none' + } + } + recreateViewHelper(): void { if (this.viewHelper) { this.viewHelper.dispose() diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 7bbdd5117..fd36efe46 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -177,3 +177,12 @@ export interface LoaderManagerInterface { dispose(): void loadModel(url: string, originalFileName?: string): Promise } + +export interface RecordingManagerInterface extends BaseManager { + startRecording(): Promise + stopRecording(): void + hasRecording(): boolean + getRecordingDuration(): number + exportRecording(filename?: string): void + clearRecording(): void +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 329a178ce..b9c5b4b36 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1057,8 +1057,14 @@ "normal": "Normal", "wireframe": "Wireframe", "original": "Original", - "depth": "Depth" - } + "depth": "Depth", + "lineart": "Lineart" + }, + "startRecording": "Start Recording", + "stopRecording": "Stop Recording", + "exportRecording": "Export Recording", + "clearRecording": "Clear Recording", + "resizeNodeMatchOutput": "Resize Node to match output" }, "toastMessages": { "no3dScene": "No 3D scene to apply texture", diff --git a/src/locales/es/main.json b/src/locales/es/main.json index 558d7e65a..466d81fe0 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -467,9 +467,11 @@ "applyingTexture": "Aplicando textura...", "backgroundColor": "Color de fondo", "camera": "Cámara", + "clearRecording": "Borrar grabación", "edgeThreshold": "Umbral de borde", "export": "Exportar", "exportModel": "Exportar modelo", + "exportRecording": "Exportar grabación", "exportingModel": "Exportando modelo...", "fov": "FOV", "light": "Luz", @@ -478,6 +480,7 @@ "materialMode": "Modo de material", "materialModes": { "depth": "Profundidad", + "lineart": "Dibujo lineal", "normal": "Normal", "original": "Original", "wireframe": "Malla" @@ -485,8 +488,11 @@ "model": "Modelo", "previewOutput": "Vista previa de salida", "removeBackgroundImage": "Eliminar imagen de fondo", + "resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida", "scene": "Escena", "showGrid": "Mostrar cuadrícula", + "startRecording": "Iniciar grabación", + "stopRecording": "Detener grabación", "switchCamera": "Cambiar cámara", "switchingMaterialMode": "Cambiando modo de material...", "upDirection": "Dirección hacia arriba", diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 58b4da66c..17c475057 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -467,9 +467,11 @@ "applyingTexture": "Application de la texture...", "backgroundColor": "Couleur de fond", "camera": "Caméra", + "clearRecording": "Effacer l'enregistrement", "edgeThreshold": "Seuil de Bordure", "export": "Exportation", "exportModel": "Exportation du modèle", + "exportRecording": "Exporter l'enregistrement", "exportingModel": "Exportation du modèle en cours...", "fov": "FOV", "light": "Lumière", @@ -478,6 +480,7 @@ "materialMode": "Mode Matériel", "materialModes": { "depth": "Profondeur", + "lineart": "Dessin au trait", "normal": "Normal", "original": "Original", "wireframe": "Fil de fer" @@ -485,8 +488,11 @@ "model": "Modèle", "previewOutput": "Aperçu de la sortie", "removeBackgroundImage": "Supprimer l'image de fond", + "resizeNodeMatchOutput": "Redimensionner le nœud pour correspondre à la sortie", "scene": "Scène", "showGrid": "Afficher la grille", + "startRecording": "Démarrer l'enregistrement", + "stopRecording": "Arrêter l'enregistrement", "switchCamera": "Changer de caméra", "switchingMaterialMode": "Changement de mode de matériau...", "upDirection": "Direction Haut", diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 7d7e7e64f..7ad30e5e3 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -467,9 +467,11 @@ "applyingTexture": "テクスチャを適用中...", "backgroundColor": "背景色", "camera": "カメラ", + "clearRecording": "録画をクリア", "edgeThreshold": "エッジ閾値", "export": "エクスポート", "exportModel": "モデルをエクスポート", + "exportRecording": "録画をエクスポート", "exportingModel": "モデルをエクスポート中...", "fov": "FOV", "light": "ライト", @@ -478,6 +480,7 @@ "materialMode": "マテリアルモード", "materialModes": { "depth": "深度", + "lineart": "線画", "normal": "ノーマル", "original": "オリジナル", "wireframe": "ワイヤーフレーム" @@ -485,8 +488,11 @@ "model": "モデル", "previewOutput": "出力のプレビュー", "removeBackgroundImage": "背景画像を削除", + "resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ", "scene": "シーン", "showGrid": "グリッドを表示", + "startRecording": "録画開始", + "stopRecording": "録画停止", "switchCamera": "カメラを切り替える", "switchingMaterialMode": "マテリアルモードの切り替え中...", "upDirection": "上方向", diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 28e2ed934..25f127fb7 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -467,9 +467,11 @@ "applyingTexture": "텍스처 적용 중...", "backgroundColor": "배경색", "camera": "카메라", + "clearRecording": "녹화 지우기", "edgeThreshold": "엣지 임계값", "export": "내보내기", "exportModel": "모델 내보내기", + "exportRecording": "녹화 내보내기", "exportingModel": "모델 내보내기 중...", "fov": "FOV", "light": "빛", @@ -478,6 +480,7 @@ "materialMode": "재질 모드", "materialModes": { "depth": "깊이", + "lineart": "라인아트", "normal": "노멀(normal)", "original": "원본", "wireframe": "와이어프레임" @@ -485,8 +488,11 @@ "model": "모델", "previewOutput": "출력 미리보기", "removeBackgroundImage": "배경 이미지 제거", + "resizeNodeMatchOutput": "노드 크기를 출력에 맞추기", "scene": "장면", "showGrid": "그리드 표시", + "startRecording": "녹화 시작", + "stopRecording": "녹화 중지", "switchCamera": "카메라 전환", "switchingMaterialMode": "재질 모드 전환 중...", "upDirection": "위 방향", diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index e566074d9..20f8e89c7 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -467,9 +467,11 @@ "applyingTexture": "Применение текстуры...", "backgroundColor": "Цвет фона", "camera": "Камера", + "clearRecording": "Очистить запись", "edgeThreshold": "Пороговое значение края", "export": "Экспорт", "exportModel": "Экспорт модели", + "exportRecording": "Экспортировать запись", "exportingModel": "Экспорт модели...", "fov": "Угол обзора", "light": "Свет", @@ -478,6 +480,7 @@ "materialMode": "Режим Материала", "materialModes": { "depth": "Глубина", + "lineart": "Лайнарт", "normal": "Нормальный", "original": "Оригинал", "wireframe": "Каркас" @@ -485,8 +488,11 @@ "model": "Модель", "previewOutput": "Предварительный просмотр", "removeBackgroundImage": "Удалить фоновое изображение", + "resizeNodeMatchOutput": "Изменить размер узла под вывод", "scene": "Сцена", "showGrid": "Показать сетку", + "startRecording": "Начать запись", + "stopRecording": "Остановить запись", "switchCamera": "Переключить камеру", "switchingMaterialMode": "Переключение режима материала...", "upDirection": "Направление Вверх", diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 48f754b83..9155f9d5d 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -467,9 +467,11 @@ "applyingTexture": "应用纹理中...", "backgroundColor": "背景颜色", "camera": "摄影机", + "clearRecording": "清除录制", "edgeThreshold": "边缘阈值", "export": "导出", "exportModel": "导出模型", + "exportRecording": "导出录制", "exportingModel": "正在导出模型...", "fov": "视场", "light": "灯光", @@ -478,6 +480,7 @@ "materialMode": "材质模式", "materialModes": { "depth": "深度", + "lineart": "线稿", "normal": "法线", "original": "原始", "wireframe": "线框" @@ -485,8 +488,11 @@ "model": "模型", "previewOutput": "预览输出", "removeBackgroundImage": "移除背景图片", + "resizeNodeMatchOutput": "调整节点以匹配输出", "scene": "场景", "showGrid": "显示网格", + "startRecording": "开始录制", + "stopRecording": "停止录制", "switchCamera": "切换摄影机类型", "switchingMaterialMode": "切换材质模式中...", "upDirection": "上方向",