diff --git a/.lovable/plan.md b/.lovable/plan.md index 7ad5ab7..b311a67 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,46 +1,44 @@ -## Contexto +# Plano: Mini-menu de Captura (Foto + Vídeo) no Modo AR -O grid vermelho/azul que aparece perto de paredes/objetos é o **Space Sense / Guardian** do Quest 3 — renderizado pelo runtime da Meta, não pelo app. O app não consegue desligá-lo por código. Porém, **a sensibilidade desse sistema aumenta** quando a WebXR session pede features como `mesh-detection`, `depth-sensing`, `anchors`, etc. Hoje o app usa `createXRStore({...})` sem lista explícita de features, então a lib `@react-three/xr` solicita o conjunto default — que inclui várias features que não usamos. +## Objetivo +Adicionar dentro do HUD flutuante do modo AR um pequeno menu de captura com: +- **📸 Foto** — screenshot do canvas (já existe lógica, expor no AR) +- **● Gravar / ⏸ Pausar / ▶ Retomar / ■ Parar** — gravação de vídeo do canvas WebXR -## O que vou fazer (Onda 2, sem quebrar nada) +## O que será implementado -**Auditoria do `createXRStore` em `src/pages/XRSession.tsx`** para pedir só o que realmente usamos: +### 1. Novo hook `useCanvasRecorder` (`src/hooks/useCanvasRecorder.ts`) +- Usa `canvas.captureStream(30)` + `MediaRecorder` (codec `video/webm;codecs=vp9` com fallback p/ vp8). +- Estados expostos: `status` (`idle | recording | paused`), `elapsedMs`. +- Métodos: `start()`, `pause()`, `resume()`, `stop()`. +- Ao parar: monta `Blob` webm, gera `URL.createObjectURL`, dispara download automático (`tracksteel-AR-.webm`) e também salva uma referência opcional para galeria futura. +- Captura screenshot via `canvas.toDataURL('image/png')` reutilizando o store (`addScreenshot`) — mesma lógica já presente em `XRHud.tsx`. -- **Manter (usado):** - - `hit-test` — usado por `XRHitTestPlacement` - - `plane-detection` — usado por `XRHitTestPlacement` (snap) e `XRPlaneOverlay` - - `local-floor` — reference space padrão - - `hand-tracking` — usado -- **Remover do request (não usado):** - - `mesh-detection` — não há leitor de meshes no código - - `anchors` — não criamos âncoras persistentes - - `depth-sensing` — não usado - - `light-estimation` — não usado +### 2. Nova aba "Captura" no HUD 3D (`src/components/three/XRHudInWorld.tsx`) +- Adicionar tab `capture` (ícone 🎥) na barra de tabs (junto com Cena, Ferram., Inspeção, Compart., WebXR). +- Componente `CaptureTab` com botões 3D (`XR3DButton`): + - **📸 Foto** — chama screenshot, mostra toast "Foto capturada". + - **● REC** (vermelho quando gravando) — inicia/para gravação. + - **⏸ Pause / ▶ Resume** — alterna pausa (desabilitado quando idle). + - **■ Stop** — finaliza e baixa o arquivo. + - Texto de status: `● 00:12` em vermelho enquanto grava, amarelo quando pausado. +- Indicador visual flutuante (pequeno círculo vermelho pulsante) visível mesmo com painel fechado quando estiver gravando — anexado ao `headLockRef`. -Configuração proposta (passada para `createXRStore`): +### 3. Integração +- O hook recebe o `` via `document.querySelector('canvas')` (mesmo padrão usado em `XRHud.tsx`/`handleScreenshot`). +- Nenhuma mudança no store global é necessária — o estado de gravação vive no hook, instanciado no nível do `XRSession.tsx` e passado por props ao `XRHudInWorld`, OU mantido dentro do próprio `CaptureTab` via singleton simples. -```ts -const store = createXRStore({ - hand: { left: true, right: true }, - controller: { left: true, right: true }, - sessionInit: { - requiredFeatures: ['local-floor'], - optionalFeatures: ['hit-test', 'plane-detection', 'hand-tracking'], - }, -}); -``` +## Detalhes técnicos +- `MediaRecorder` com `canvas.captureStream(30)` funciona dentro de sessão WebXR no Quest 3 (Chromium-based browser do Quest suporta). +- Áudio do microfone **não** será incluído (pode ser adicionado depois se solicitado). +- Vídeo gerado é `.webm`; o usuário pode converter para `.mp4` fora do app se quiser. +- O download é feito ao parar a gravação — o arquivo aparece no armazenamento do Quest (pasta Downloads do browser). -Tudo continua como `optionalFeatures`, então se o Quest não suportar algum, a sessão ainda inicia. Nenhuma ferramenta atual (hit-test, snap em planos, mãos, grip, joystick, medição, screenshot, inspeção, compartilhamento) depende das features removidas. +## Arquivos afetados +- `src/hooks/useCanvasRecorder.ts` (novo) +- `src/components/three/XRHudInWorld.tsx` (nova tab + CaptureTab + indicador REC) -## O que NÃO faço - -- Não mexo em `useModelStore`, `XRHud`, `XRHudInWorld`, `XRHitTestPlacement`, `XRPlaneOverlay` — comportamento das ferramentas fica igual. -- Não adiciono botão de "desligar Guardian" — impossível por código. - -## Resultado esperado - -Reduz a chance de o Space Sense ativar o grid de segurança perto de objetos, porque a sessão XR não pede mais permissões além das necessárias. Se o grid continuar aparecendo, é configuração do headset (Settings → Segurança → Space Sense) e o usuário precisa desligar lá — posso adicionar depois um painel "Ajuda" com o passo-a-passo se quiser. - -## Arquivo alterado - -- `src/pages/XRSession.tsx` (apenas o objeto passado para `createXRStore`) \ No newline at end of file +## Fora do escopo +- Galeria de vídeos dentro do app (apenas download direto por enquanto). +- Áudio/microfone na gravação. +- Streaming/upload para nuvem. diff --git a/src/components/three/XRHudInWorld.tsx b/src/components/three/XRHudInWorld.tsx index 7b5edce..f0e7276 100644 --- a/src/components/three/XRHudInWorld.tsx +++ b/src/components/three/XRHudInWorld.tsx @@ -6,8 +6,12 @@ import { Text } from '@react-three/drei'; import { XR3DButton, XR3DPanel } from './XRPanel3D'; import { useModelStore, SCALE_PRESETS } from '@/stores/useModelStore'; import { getBroadcastSource } from '@/lib/webrtc/broadcastSource'; +import { + useCanvasRecorder, startRecording, pauseRecording, resumeRecording, + stopRecording, captureScreenshot, formatElapsed, +} from '@/hooks/useCanvasRecorder'; -type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'webxr'; +type Tab = 'scene' | 'tools' | 'inspection' | 'share' | 'capture' | 'webxr'; interface XRHudInWorldProps { freeMove: boolean; @@ -171,6 +175,7 @@ export function XRHudInWorld(props: XRHudInWorldProps) { setShowGrid(!showGrid)} fontSize={0.0085} /> + {open && ( @@ -205,6 +210,7 @@ function FloatingPanel({ { id: 'scene', label: 'Cena', icon: '🧩' }, { id: 'tools', label: 'Ferram.', icon: '🛠' }, { id: 'inspection', label: 'Inspeção', icon: '✓' }, + { id: 'capture', label: 'Captura', icon: '🎥' }, { id: 'share', label: 'Compart.', icon: '📡' }, { id: 'webxr', label: 'WebXR', icon: '⚙' }, ]; @@ -234,9 +240,9 @@ function FloatingPanel({ {tabs.map((t, i) => ( setTab(t.id)} fontSize={0.0075} /> + onClick={() => setTab(t.id)} fontSize={0.0072} /> ))} @@ -244,6 +250,7 @@ function FloatingPanel({ {tab === 'scene' && } {tab === 'tools' && } {tab === 'inspection' && } + {tab === 'capture' && } {tab === 'share' && } {tab === 'webxr' && } @@ -576,3 +583,88 @@ function WebXRTab() { ); } + +function CaptureTab() { + const rec = useCanvasRecorder(); + const addScreenshot = useModelStore((s) => s.addScreenshot); + + const onPhoto = () => { + const dataUrl = captureScreenshot(); + if (dataUrl) addScreenshot(dataUrl); + }; + + const statusColor = + rec.status === 'recording' ? '#dc2626' : + rec.status === 'paused' ? '#f59e0b' : '#94a3b8'; + const statusLabel = + rec.status === 'recording' ? `● REC ${formatElapsed(rec.elapsedMs)}` : + rec.status === 'paused' ? `❚❚ PAUSADO ${formatElapsed(rec.elapsedMs)}` : + '○ Pronto para gravar'; + + return ( + + + Capture fotos ou grave a sessão AR (vídeo .webm baixado ao parar). + + + + {statusLabel} + + + {/* Foto */} + + + {/* Iniciar / Parar gravação */} + {rec.status === 'idle' ? ( + + ) : ( + + )} + + {/* Pause / Resume */} + {rec.status === 'recording' && ( + + )} + {rec.status === 'paused' && ( + + )} + + + Foto → .png | Vídeo → .webm | Arquivos vão para a pasta Downloads do navegador. + + + ); +} + +function RecIndicator() { + const rec = useCanvasRecorder(); + if (rec.status === 'idle') return null; + const color = rec.status === 'recording' ? '#dc2626' : '#f59e0b'; + const label = rec.status === 'recording' + ? `● REC ${formatElapsed(rec.elapsedMs)}` + : `❚❚ ${formatElapsed(rec.elapsedMs)}`; + return ( + + + + + + + {label} + + + ); +} diff --git a/src/hooks/useCanvasRecorder.ts b/src/hooks/useCanvasRecorder.ts new file mode 100644 index 0000000..8a76ccc --- /dev/null +++ b/src/hooks/useCanvasRecorder.ts @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { toast } from 'sonner'; + +export type RecorderStatus = 'idle' | 'recording' | 'paused'; + +// Module-level singleton so the recording state can be shared between +// the CaptureTab (inside the 3D HUD) and a floating indicator. +type State = { status: RecorderStatus; elapsedMs: number }; +let state: State = { status: 'idle', elapsedMs: 0 }; +const listeners = new Set<() => void>(); +function setState(next: Partial) { + state = { ...state, ...next }; + listeners.forEach((l) => l()); +} +function subscribe(l: () => void) { listeners.add(l); return () => { listeners.delete(l); }; } +function getSnapshot() { return state; } + +let recorder: MediaRecorder | null = null; +let chunks: Blob[] = []; +let startedAt = 0; +let accumulatedMs = 0; +let pausedAt = 0; +let tickTimer: number | null = null; + +function pickMimeType(): string | undefined { + const candidates = [ + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=vp9', + 'video/webm;codecs=vp8', + 'video/webm', + 'video/mp4', + ]; + for (const t of candidates) { + if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(t)) return t; + } + return undefined; +} + +function startTick() { + if (tickTimer != null) return; + tickTimer = window.setInterval(() => { + if (state.status === 'recording') { + setState({ elapsedMs: accumulatedMs + (performance.now() - startedAt) }); + } + }, 250); +} +function stopTick() { + if (tickTimer != null) { clearInterval(tickTimer); tickTimer = null; } +} + +function getCanvas(): HTMLCanvasElement | null { + return document.querySelector('canvas'); +} + +export function startRecording() { + if (state.status !== 'idle') return; + const canvas = getCanvas(); + if (!canvas) { toast.error('Canvas não encontrado'); return; } + const stream = (canvas as any).captureStream?.(30) as MediaStream | undefined; + if (!stream) { toast.error('captureStream não suportado neste browser'); return; } + const mimeType = pickMimeType(); + try { + recorder = new MediaRecorder(stream, mimeType ? { mimeType, videoBitsPerSecond: 8_000_000 } : undefined); + } catch (e) { + console.error(e); + toast.error('MediaRecorder falhou ao iniciar'); + return; + } + chunks = []; + recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.push(e.data); }; + recorder.onstop = () => { + const type = recorder?.mimeType || 'video/webm'; + const blob = new Blob(chunks, { type }); + const ext = type.includes('mp4') ? 'mp4' : 'webm'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.${ext}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 5000); + toast.success(`Vídeo salvo (${(blob.size / 1_000_000).toFixed(1)} MB)`); + recorder = null; + chunks = []; + accumulatedMs = 0; + stopTick(); + setState({ status: 'idle', elapsedMs: 0 }); + }; + recorder.start(1000); + startedAt = performance.now(); + accumulatedMs = 0; + setState({ status: 'recording', elapsedMs: 0 }); + startTick(); + toast.success('Gravação iniciada'); +} + +export function pauseRecording() { + if (!recorder || state.status !== 'recording') return; + recorder.pause(); + accumulatedMs += performance.now() - startedAt; + pausedAt = performance.now(); + setState({ status: 'paused', elapsedMs: accumulatedMs }); +} + +export function resumeRecording() { + if (!recorder || state.status !== 'paused') return; + recorder.resume(); + startedAt = performance.now(); + setState({ status: 'recording' }); +} + +export function stopRecording() { + if (!recorder) return; + try { recorder.stop(); } catch (e) { console.error(e); } +} + +export function captureScreenshot(): string | null { + const canvas = getCanvas(); + if (!canvas) { toast.error('Canvas não encontrado'); return null; } + try { + const dataUrl = canvas.toDataURL('image/png'); + // Trigger download + const a = document.createElement('a'); + a.href = dataUrl; + a.download = `tracksteel-AR-${new Date().toISOString().replace(/[:.]/g, '-')}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + toast.success('Foto capturada'); + return dataUrl; + } catch (e) { + console.error(e); + toast.error('Falha ao capturar foto'); + return null; + } +} + +export function useCanvasRecorder() { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function formatElapsed(ms: number): string { + const s = Math.floor(ms / 1000); + const m = Math.floor(s / 60); + const ss = (s % 60).toString().padStart(2, '0'); + const mm = m.toString().padStart(2, '0'); + return `${mm}:${ss}`; +}