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 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDuration(recordingDuration) }}
+
+
+
+
+
+
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": "上方向",