Added capture tab & REC indicator

X-Lovable-Edit-ID: edt-bb216c0f-61c7-44fc-ad66-d1f17051d2f4
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-19 20:59:06 +00:00
3 changed files with 280 additions and 41 deletions
+36 -38
View File
@@ -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-<timestamp>.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):** ### 2. Nova aba "Captura" no HUD 3D (`src/components/three/XRHudInWorld.tsx`)
- `hit-test` — usado por `XRHitTestPlacement` - Adicionar tab `capture` (ícone 🎥) na barra de tabs (junto com Cena, Ferram., Inspeção, Compart., WebXR).
- `plane-detection` — usado por `XRHitTestPlacement` (snap) e `XRPlaneOverlay` - Componente `CaptureTab` com botões 3D (`XR3DButton`):
- `local-floor` — reference space padrão - **📸 Foto** — chama screenshot, mostra toast "Foto capturada".
- `hand-tracking` — usado - **● REC** (vermelho quando gravando) — inicia/para gravação.
- **Remover do request (não usado):** - **⏸ Pause / ▶ Resume** — alterna pausa (desabilitado quando idle).
- `mesh-detection` — não há leitor de meshes no código - **■ Stop** — finaliza e baixa o arquivo.
- `anchors` — não criamos âncoras persistentes - Texto de status: `● 00:12` em vermelho enquanto grava, amarelo quando pausado.
- `depth-sensing` — não usado - Indicador visual flutuante (pequeno círculo vermelho pulsante) visível mesmo com painel fechado quando estiver gravando — anexado ao `headLockRef`.
- `light-estimation` — não usado
Configuração proposta (passada para `createXRStore`): ### 3. Integração
- O hook recebe o `<canvas>` 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 ## Detalhes técnicos
const store = createXRStore({ - `MediaRecorder` com `canvas.captureStream(30)` funciona dentro de sessão WebXR no Quest 3 (Chromium-based browser do Quest suporta).
hand: { left: true, right: true }, - Áudio do microfone **não** será incluído (pode ser adicionado depois se solicitado).
controller: { left: true, right: true }, - Vídeo gerado é `.webm`; o usuário pode converter para `.mp4` fora do app se quiser.
sessionInit: { - O download é feito ao parar a gravação — o arquivo aparece no armazenamento do Quest (pasta Downloads do browser).
requiredFeatures: ['local-floor'],
optionalFeatures: ['hit-test', 'plane-detection', 'hand-tracking'],
},
});
```
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 ## Fora do escopo
- Galeria de vídeos dentro do app (apenas download direto por enquanto).
- Não mexo em `useModelStore`, `XRHud`, `XRHudInWorld`, `XRHitTestPlacement`, `XRPlaneOverlay` — comportamento das ferramentas fica igual. - Áudio/microfone na gravação.
- Não adiciono botão de "desligar Guardian" — impossível por código. - Streaming/upload para nuvem.
## 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`)
+95 -3
View File
@@ -6,8 +6,12 @@ import { Text } from '@react-three/drei';
import { XR3DButton, XR3DPanel } from './XRPanel3D'; import { XR3DButton, XR3DPanel } from './XRPanel3D';
import { useModelStore, SCALE_PRESETS } from '@/stores/useModelStore'; import { useModelStore, SCALE_PRESETS } from '@/stores/useModelStore';
import { getBroadcastSource } from '@/lib/webrtc/broadcastSource'; 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 { interface XRHudInWorldProps {
freeMove: boolean; freeMove: boolean;
@@ -171,6 +175,7 @@ export function XRHudInWorld(props: XRHudInWorldProps) {
<XR3DButton position={[0, -0.008, 0]} size={[0.07, 0.022]} <XR3DButton position={[0, -0.008, 0]} size={[0.07, 0.022]}
label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid} label={showGrid ? 'Grid ON' : 'Grid OFF'} active={showGrid}
onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} /> onClick={() => setShowGrid(!showGrid)} fontSize={0.0085} />
<RecIndicator />
</group> </group>
{open && ( {open && (
<group ref={panelRef}> <group ref={panelRef}>
@@ -205,6 +210,7 @@ function FloatingPanel({
{ id: 'scene', label: 'Cena', icon: '🧩' }, { id: 'scene', label: 'Cena', icon: '🧩' },
{ id: 'tools', label: 'Ferram.', icon: '🛠' }, { id: 'tools', label: 'Ferram.', icon: '🛠' },
{ id: 'inspection', label: 'Inspeção', icon: '✓' }, { id: 'inspection', label: 'Inspeção', icon: '✓' },
{ id: 'capture', label: 'Captura', icon: '🎥' },
{ id: 'share', label: 'Compart.', icon: '📡' }, { id: 'share', label: 'Compart.', icon: '📡' },
{ id: 'webxr', label: 'WebXR', icon: '⚙' }, { id: 'webxr', label: 'WebXR', icon: '⚙' },
]; ];
@@ -234,9 +240,9 @@ function FloatingPanel({
<group position={[0, H / 2 - 0.045, 0.001]}> <group position={[0, H / 2 - 0.045, 0.001]}>
{tabs.map((t, i) => ( {tabs.map((t, i) => (
<XR3DButton key={t.id} <XR3DButton key={t.id}
position={[-W / 2 + 0.045 + i * 0.092, 0, 0]} size={[0.085, 0.022]} position={[-W / 2 + 0.04 + i * 0.078, 0, 0]} size={[0.072, 0.022]}
label={t.label} icon={t.icon} active={tab === t.id} label={t.label} icon={t.icon} active={tab === t.id}
onClick={() => setTab(t.id)} fontSize={0.0075} /> onClick={() => setTab(t.id)} fontSize={0.0072} />
))} ))}
</group> </group>
@@ -244,6 +250,7 @@ function FloatingPanel({
{tab === 'scene' && <SceneTab />} {tab === 'scene' && <SceneTab />}
{tab === 'tools' && <ToolsTab {...p} />} {tab === 'tools' && <ToolsTab {...p} />}
{tab === 'inspection' && <InspectionTab />} {tab === 'inspection' && <InspectionTab />}
{tab === 'capture' && <CaptureTab />}
{tab === 'share' && <ShareTab {...p} />} {tab === 'share' && <ShareTab {...p} />}
{tab === 'webxr' && <WebXRTab />} {tab === 'webxr' && <WebXRTab />}
</group> </group>
@@ -576,3 +583,88 @@ function WebXRTab() {
</group> </group>
); );
} }
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 (
<group>
<Text position={[-0.24, 0.115, 0]} fontSize={0.0085} color="#94a3b8" anchorX="left" maxWidth={0.48}>
Capture fotos ou grave a sessão AR (vídeo .webm baixado ao parar).
</Text>
<Text position={[0, 0.075, 0]} fontSize={0.014} color={statusColor}
anchorX="center" anchorY="middle">
{statusLabel}
</Text>
{/* Foto */}
<XR3DButton position={[-0.13, 0.02, 0]} size={[0.18, 0.038]}
label="📸 Tirar Foto" color="#3b82f6"
onClick={onPhoto} fontSize={0.011} />
{/* Iniciar / Parar gravação */}
{rec.status === 'idle' ? (
<XR3DButton position={[0.09, 0.02, 0]} size={[0.18, 0.038]}
label="● Iniciar Gravação" color="#dc2626"
onClick={startRecording} fontSize={0.011} />
) : (
<XR3DButton position={[0.09, 0.02, 0]} size={[0.18, 0.038]}
label="■ Parar e Salvar" color="#dc2626" active
onClick={stopRecording} fontSize={0.011} />
)}
{/* Pause / Resume */}
{rec.status === 'recording' && (
<XR3DButton position={[0, -0.045, 0]} size={[0.2, 0.034]}
label="❚❚ Pausar" color="#f59e0b"
onClick={pauseRecording} fontSize={0.011} />
)}
{rec.status === 'paused' && (
<XR3DButton position={[0, -0.045, 0]} size={[0.2, 0.034]}
label="▶ Retomar" color="#22c55e" active
onClick={resumeRecording} fontSize={0.011} />
)}
<Text position={[-0.24, -0.105, 0]} fontSize={0.0075} color="#64748b"
anchorX="left" maxWidth={0.48}>
Foto .png | Vídeo .webm | Arquivos vão para a pasta Downloads do navegador.
</Text>
</group>
);
}
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 (
<group position={[0.09, 0.018, 0]}>
<mesh>
<planeGeometry args={[0.078, 0.022]} />
<meshBasicMaterial color="#0a0a0a" transparent opacity={0.9} />
</mesh>
<Text position={[0, 0, 0.001]} fontSize={0.0085} color={color}
anchorX="center" anchorY="middle">
{label}
</Text>
</group>
);
}
+149
View File
@@ -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 = { ...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}`;
}