Skip to content

Commit d39236a

Browse files
author
app4dog-ci
committed
feat: pre-kinematic
1 parent bb4f361 commit d39236a

5 files changed

Lines changed: 416 additions & 17 deletions

File tree

src/components/CameraDebugPanel.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ onUnmounted(() => {
158158
async function startJs() {
159159
status.value = 'Starting camera (JS)…'
160160
console.log('🎬 Starting camera service...')
161-
await cameraService.start({ width: 640, height: 480, rear: true })
161+
await cameraService.start({ width: 640, height: 480, rear: true, zoom: 0.25 })
162162
163163
// Also start HTML canvas preview for development
164164
try {
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
<template>
2+
<div class="training-mode-overlay">
3+
<div class="camera-surface">
4+
<canvas ref="cameraCanvas" class="camera-canvas"></canvas>
5+
</div>
6+
7+
<div class="overlay">
8+
<div class="overlay-top">
9+
<div class="timer-display">{{ formattedElapsed }}</div>
10+
</div>
11+
<div class="overlay-bottom">
12+
<q-btn color="primary" label="Exit" class="exit-btn" @click="onExitClick" />
13+
</div>
14+
</div>
15+
</div>
16+
</template>
17+
18+
<script setup lang="ts">
19+
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
20+
import { useQuasar } from 'quasar'
21+
import { cameraService, type CameraFrame } from '../services/CameraService'
22+
23+
const emit = defineEmits<{
24+
(event: 'exit', reason?: 'timeout' | 'manual' | 'error'): void
25+
}>()
26+
27+
const $q = useQuasar()
28+
29+
const cameraCanvas = ref<HTMLCanvasElement | null>(null)
30+
const elapsedSeconds = ref(0)
31+
32+
const formattedElapsed = computed(() => {
33+
const minutes = Math.floor(elapsedSeconds.value / 60)
34+
const seconds = elapsedSeconds.value % 60
35+
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
36+
})
37+
38+
const TRAINING_DURATION_SECONDS = 15
39+
40+
let intervalId: number | null = null
41+
let trainingStartedAt = 0
42+
let frameCleanup: (() => void) | null = null
43+
let ctx: CanvasRenderingContext2D | null = null
44+
let rawCanvas: HTMLCanvasElement | null = null
45+
let rawCtx: CanvasRenderingContext2D | null = null
46+
let rawImageData: ImageData | null = null
47+
let pendingFrame: CameraFrame | null = null
48+
let drawHandle = 0
49+
let cameraActive = false
50+
let dispatchedStartEvent = false
51+
let exiting = false
52+
let viewportWidth = 0
53+
let viewportHeight = 0
54+
let resizeHandler: (() => void) | null = null
55+
56+
function startTimer() {
57+
trainingStartedAt = Date.now()
58+
elapsedSeconds.value = 0
59+
intervalId = window.setInterval(() => {
60+
const diff = Math.floor((Date.now() - trainingStartedAt) / 1000)
61+
elapsedSeconds.value = diff
62+
if (diff >= TRAINING_DURATION_SECONDS) {
63+
void exitTraining('timeout')
64+
}
65+
}, 1000)
66+
}
67+
68+
function stopTimer() {
69+
if (intervalId !== null) {
70+
window.clearInterval(intervalId)
71+
intervalId = null
72+
}
73+
}
74+
75+
function handleFrame(frame: CameraFrame) {
76+
pendingFrame = frame
77+
if (drawHandle === 0) {
78+
drawHandle = window.requestAnimationFrame(drawFrame)
79+
}
80+
}
81+
82+
function drawFrame() {
83+
drawHandle = 0
84+
const frame = pendingFrame
85+
pendingFrame = null
86+
if (!frame) return
87+
88+
const canvas = cameraCanvas.value
89+
if (!canvas) return
90+
91+
if (!ctx) {
92+
ctx = canvas.getContext('2d', { willReadFrequently: true })
93+
if (!ctx) return
94+
ctx.imageSmoothingEnabled = true
95+
}
96+
97+
if (!rawCanvas) {
98+
rawCanvas = document.createElement('canvas')
99+
rawCtx = rawCanvas.getContext('2d', { willReadFrequently: true })
100+
}
101+
if (!rawCtx || !rawCanvas) return
102+
103+
if (rawCanvas.width !== frame.width || rawCanvas.height !== frame.height) {
104+
rawCanvas.width = frame.width
105+
rawCanvas.height = frame.height
106+
rawImageData = null
107+
}
108+
109+
if (!rawImageData || rawImageData.width !== frame.width || rawImageData.height !== frame.height) {
110+
rawImageData = rawCtx.createImageData(frame.width, frame.height)
111+
}
112+
113+
const dest = rawImageData.data
114+
const src = frame.data
115+
for (let srcIdx = 0, destIdx = 0; srcIdx < src.length; srcIdx += 3, destIdx += 4) {
116+
dest[destIdx] = src[srcIdx] ?? 0
117+
dest[destIdx + 1] = src[srcIdx + 1] ?? 0
118+
dest[destIdx + 2] = src[srcIdx + 2] ?? 0
119+
dest[destIdx + 3] = 255
120+
}
121+
122+
rawCtx.putImageData(rawImageData, 0, 0)
123+
124+
if (viewportWidth === 0 || viewportHeight === 0) {
125+
updateCanvasSize()
126+
}
127+
128+
const dpr = window.devicePixelRatio || 1
129+
const displayWidth = viewportWidth
130+
const displayHeight = viewportHeight
131+
ctx.save()
132+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
133+
ctx.clearRect(0, 0, displayWidth, displayHeight)
134+
135+
const scale = Math.max(displayWidth / rawCanvas.width, displayHeight / rawCanvas.height)
136+
const drawWidth = rawCanvas.width * scale
137+
const drawHeight = rawCanvas.height * scale
138+
const offsetX = (displayWidth - drawWidth) / 2
139+
const offsetY = (displayHeight - drawHeight) / 2
140+
141+
ctx.drawImage(rawCanvas, offsetX, offsetY, drawWidth, drawHeight)
142+
ctx.restore()
143+
}
144+
145+
function dispatchCameraStart(width: number, height: number) {
146+
if (typeof window === 'undefined') return
147+
const eventPayload = {
148+
type: 'CameraStart',
149+
request_id: `training-${Date.now()}`,
150+
width,
151+
height
152+
}
153+
window.dispatchEvent(new CustomEvent('bevy-to-js-event', { detail: JSON.stringify(eventPayload) }))
154+
dispatchedStartEvent = true
155+
}
156+
157+
function dispatchCameraStop() {
158+
if (!dispatchedStartEvent || typeof window === 'undefined') return
159+
const eventPayload = {
160+
type: 'CameraStop',
161+
request_id: `training-${Date.now()}`
162+
}
163+
window.dispatchEvent(new CustomEvent('bevy-to-js-event', { detail: JSON.stringify(eventPayload) }))
164+
dispatchedStartEvent = false
165+
}
166+
167+
function dispatchCameraPreviewDisable() {
168+
if (typeof window === 'undefined') return
169+
const wasm = window.__A4D_WASM__
170+
const payload = {
171+
type: 'CameraPreviewToggle',
172+
request_id: `training-preview-${Date.now()}`,
173+
enabled: false,
174+
scale: 0,
175+
anchor: 'TopRight',
176+
margin: 0,
177+
mirror_x: false,
178+
}
179+
try {
180+
wasm?.send_js_to_bevy_event?.(JSON.stringify(payload))
181+
} catch (error) {
182+
console.warn('Failed to disable camera preview', error)
183+
}
184+
try {
185+
window.dispatchEvent(new CustomEvent('bevy-to-js-event', { detail: JSON.stringify(payload) }))
186+
} catch (err) {
187+
console.warn('Failed to dispatch preview toggle event', err)
188+
}
189+
}
190+
191+
function updateCanvasSize() {
192+
const canvas = cameraCanvas.value
193+
if (!canvas || typeof window === 'undefined') return
194+
viewportWidth = window.innerWidth
195+
viewportHeight = window.innerHeight
196+
const dpr = window.devicePixelRatio || 1
197+
canvas.style.width = `${viewportWidth}px`
198+
canvas.style.height = `${viewportHeight}px`
199+
canvas.width = Math.round(viewportWidth * dpr)
200+
canvas.height = Math.round(viewportHeight * dpr)
201+
if (ctx) {
202+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
203+
ctx.imageSmoothingEnabled = true
204+
}
205+
}
206+
207+
async function cleanupCamera() {
208+
if (drawHandle) {
209+
window.cancelAnimationFrame(drawHandle)
210+
drawHandle = 0
211+
}
212+
pendingFrame = null
213+
ctx = null
214+
rawImageData = null
215+
rawCanvas = null
216+
rawCtx = null
217+
218+
if (frameCleanup) {
219+
frameCleanup()
220+
frameCleanup = null
221+
}
222+
223+
if (cameraActive) {
224+
try {
225+
await cameraService.stop()
226+
} catch (error) {
227+
console.warn('Failed to stop camera service:', error)
228+
}
229+
cameraActive = false
230+
}
231+
232+
dispatchCameraStop()
233+
dispatchCameraPreviewDisable()
234+
}
235+
236+
async function exitTraining(reason: 'timeout' | 'manual' | 'error' = 'manual') {
237+
if (exiting) return
238+
exiting = true
239+
stopTimer()
240+
await cleanupCamera()
241+
emit('exit', reason)
242+
}
243+
244+
function onExitClick() {
245+
void exitTraining('manual')
246+
}
247+
248+
onMounted(async () => {
249+
const canvas = cameraCanvas.value
250+
if (!canvas) {
251+
console.error('Training mode canvas unavailable')
252+
emit('exit', 'error')
253+
return
254+
}
255+
256+
updateCanvasSize()
257+
if (typeof window !== 'undefined') {
258+
resizeHandler = () => updateCanvasSize()
259+
window.addEventListener('resize', resizeHandler)
260+
}
261+
262+
frameCleanup = cameraService.addFrameListener(handleFrame)
263+
264+
try {
265+
await cameraService.start({ width: 640, height: 480 })
266+
cameraActive = true
267+
dispatchCameraStart(640, 480)
268+
} catch (error) {
269+
console.error('Failed to start training mode camera:', error)
270+
$q.notify({ type: 'negative', message: 'Unable to start camera for training mode', position: 'top' })
271+
await cleanupCamera()
272+
emit('exit', 'error')
273+
return
274+
}
275+
276+
startTimer()
277+
})
278+
279+
onBeforeUnmount(async () => {
280+
stopTimer()
281+
await cleanupCamera()
282+
if (resizeHandler && typeof window !== 'undefined') {
283+
window.removeEventListener('resize', resizeHandler)
284+
resizeHandler = null
285+
}
286+
})
287+
</script>
288+
289+
<style scoped>
290+
.training-mode-overlay {
291+
position: fixed;
292+
inset: 0;
293+
background-color: #000;
294+
z-index: 2000;
295+
overflow: hidden;
296+
}
297+
298+
.camera-surface {
299+
position: absolute;
300+
inset: 0;
301+
display: flex;
302+
justify-content: center;
303+
align-items: center;
304+
}
305+
306+
.camera-canvas {
307+
width: 100%;
308+
height: 100%;
309+
display: block;
310+
background: #000;
311+
}
312+
313+
.overlay {
314+
position: absolute;
315+
inset: 0;
316+
display: flex;
317+
flex-direction: column;
318+
justify-content: space-between;
319+
pointer-events: none;
320+
}
321+
322+
.overlay-top {
323+
display: flex;
324+
justify-content: center;
325+
padding: 24px;
326+
}
327+
328+
.timer-display {
329+
font-size: 1.5rem;
330+
font-weight: 600;
331+
color: #fff;
332+
background: rgba(0, 0, 0, 0.4);
333+
padding: 8px 16px;
334+
border-radius: 999px;
335+
}
336+
337+
.overlay-bottom {
338+
display: flex;
339+
justify-content: flex-end;
340+
padding: 24px;
341+
}
342+
343+
.exit-btn {
344+
pointer-events: auto;
345+
}
346+
</style>

src/composables/useBevyEventBridge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export class BevyEventBridge {
8282
const { width = 640, height = 480 } = event
8383
try {
8484
const { cameraService } = await import('../services/CameraService')
85-
await cameraService.start({ width, height, rear: true })
85+
await cameraService.start({ width, height, zoom: 0.25 })
8686
console.log('📷 Camera started', { width, height })
8787
} catch (error) {
8888
console.error('❌ Failed to start camera:', error)

0 commit comments

Comments
 (0)