diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5765aee..abb13c8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -329,6 +329,7 @@ onBeforeUnmount(() => { class="phone-stage" :class="{ 'phone-stage--dev': isDevelopment, + 'phone-stage--landscape': phone.cameraLandscape, 'phone-stage--peek': notifications.isPeeking, }" :style="phoneResolutionStyle" diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index fe8609f..1786e2d 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -251,6 +251,10 @@ button { height: 844px; zoom: var(--phone-zoom, 1); } +.phone-stage--landscape .phone-resolution-wrapper--primary { + width: 844px; + height: 390px; +} .phone-device-row { display: flex; align-items: flex-end; @@ -282,6 +286,10 @@ button { box-shadow: none; filter: drop-shadow(0 40px 100px #0009); } +.phone-stage--landscape .phone-resolution-wrapper--primary .phone-device { + width: 844px; + height: 390px; +} .phone-device__frame { position: absolute; z-index: 100; @@ -291,6 +299,16 @@ button { object-fit: fill; pointer-events: none; } +.phone-stage--landscape + .phone-resolution-wrapper--primary + .phone-device__frame { + inset: auto; + top: 50%; + left: 50%; + width: 390px; + height: 844px; + transform: translate(-50%, -50%) rotate(90deg); +} .phone-screen { position: relative; container-type: size; @@ -302,6 +320,11 @@ button { background: #08080a; border-radius: 40px; } +.phone-stage--landscape .phone-resolution-wrapper--primary .phone-screen { + width: 98%; + height: 94.5%; + margin: auto; +} .phone-app { position: absolute !important; inset: 0 !important; diff --git a/frontend/src/components/PhoneMediaCapture.vue b/frontend/src/components/PhoneMediaCapture.vue index 60f1b50..af4c611 100644 --- a/frontend/src/components/PhoneMediaCapture.vue +++ b/frontend/src/components/PhoneMediaCapture.vue @@ -12,8 +12,11 @@ type PendingVideo = { blob: Blob; fileName: string } const canvasRef = ref(null) const pendingVideos = new Map() const captureFps = 30 -const maxCaptureHeight = 720 +const maxCaptureEdge = 720 +const portraitAspect = 3 / 4 +const landscapeAspect = 16 / 9 let bitrateBps = 1_500_000 +let landscape = false let gameView: GameView | null = null let renderFrameId: number | undefined let lastRenderAt = 0 @@ -24,6 +27,18 @@ let lastChunkAt = 0 let lastChunkTimecode: number | null = null let flushTimer: number | undefined +function captureDimensions(): { height: number; width: number } { + return landscape + ? { + height: Math.round(maxCaptureEdge / landscapeAspect), + width: maxCaptureEdge, + } + : { + height: maxCaptureEdge, + width: Math.round(maxCaptureEdge * portraitAspect), + } +} + function postRecordState(active: boolean, saving = false): void { window.postMessage( { data: { active, saving }, type: 'camera:recordState' }, @@ -35,11 +50,13 @@ function ensureGameView(): GameView { if (!canvasRef.value) throw new Error('capture_failed') if (gameView && !gameView.isLost()) return gameView gameView?.dispose() - const scale = Math.min(1, maxCaptureHeight / window.innerHeight) + const dimensions = captureDimensions() gameView = createGameView(canvasRef.value) gameView.resize( - Math.round(window.innerWidth * scale), - Math.round(window.innerHeight * scale), + dimensions.width, + dimensions.height, + window.innerWidth, + window.innerHeight, ) return gameView } @@ -186,14 +203,13 @@ async function renderFrames(view: GameView, count: number): Promise { } async function capturePhotoBlob(ready: UploadReady): Promise { - const width = window.innerWidth - const height = window.innerHeight + const { height, width } = captureDimensions() const canvas = document.createElement('canvas') canvas.width = width canvas.height = height const view = createGameView(canvas, { preserveDrawingBuffer: true }) try { - view.resize(width, height) + view.resize(width, height, window.innerWidth, window.innerHeight) await renderFrames(view, 3) const output = document.createElement('canvas') output.width = width @@ -298,6 +314,17 @@ function onMessage(event: MessageEvent): void { void stopRecording(message.data ?? {}) } else if (message.type === 'camera:recordCancel') { cleanupRecording() + } else if (message.type === 'camera:orientation') { + landscape = message.data?.landscape === true + if (gameView && !gameView.isLost()) { + const dimensions = captureDimensions() + gameView.resize( + dimensions.width, + dimensions.height, + window.innerWidth, + window.innerHeight, + ) + } } else if (message.type === 'media:uploadReady') { void uploadReady(message.data as UploadReady) } diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index f4c3fed..93e0ae2 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -186,6 +186,8 @@ const defaultLocales: LocaleTree = { name: 'Camera', flash: 'Flash', flip: 'Flip camera', + landscape: 'Switch to landscape', + portrait: 'Switch to portrait', photo: 'Photo', video: 'Video', focusHelp: 'Space for movement', @@ -585,6 +587,7 @@ function getByPath(source: LocaleTree, path: string): unknown { export const usePhoneStore = defineStore('phone', { state: () => ({ + cameraLandscape: false, currentPage: 1, device: null as PhoneDevice | null, deviceRevisions: {} as Record, @@ -604,6 +607,7 @@ export const usePhoneStore = defineStore('phone', { }, actions: { close(): void { + this.cameraLandscape = false this.isOpen = false }, open(payload: PhoneOpenPayload = {}): void { @@ -645,6 +649,9 @@ export const usePhoneStore = defineStore('phone', { setCurrentPage(page: number): void { this.currentPage = clampPage(page) }, + setCameraLandscape(landscape: boolean): void { + this.cameraLandscape = landscape + }, setLaunchOrigin(origin: AppLaunchOrigin | null): void { this.launchOrigin = origin }, diff --git a/frontend/src/utils/gameView.test.ts b/frontend/src/utils/gameView.test.ts new file mode 100644 index 0000000..6350e04 --- /dev/null +++ b/frontend/src/utils/gameView.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { coverTextureCoordinates } from '@/utils/gameView' + +describe('coverTextureCoordinates', () => { + it('center-crops a widescreen game view for 3:4 portrait output', () => { + expect(Array.from(coverTextureCoordinates(1920, 1080, 540, 720))).toEqual([ + expect.closeTo(0.29, 2), + 0, + expect.closeTo(0.71, 2), + 0, + expect.closeTo(0.29, 2), + 1, + expect.closeTo(0.71, 2), + 1, + ]) + }) + + it('keeps the full game view for 16:9 landscape output', () => { + expect(Array.from(coverTextureCoordinates(1920, 1080, 720, 405))).toEqual([ + 0, 0, 1, 0, 0, 1, 1, 1, + ]) + }) + + it('keeps the full texture when both aspect ratios match', () => { + expect(Array.from(coverTextureCoordinates(1600, 900, 800, 450))).toEqual([ + 0, 0, 1, 0, 0, 1, 1, 1, + ]) + }) +}) diff --git a/frontend/src/utils/gameView.ts b/frontend/src/utils/gameView.ts index acb712b..c217919 100644 --- a/frontend/src/utils/gameView.ts +++ b/frontend/src/utils/gameView.ts @@ -21,13 +21,44 @@ export interface GameView { dispose(): void isLost(): boolean render(): void - resize(width: number, height: number): void + resize( + width: number, + height: number, + sourceWidth?: number, + sourceHeight?: number, + ): void } export interface GameViewOptions { preserveDrawingBuffer?: boolean } +export function coverTextureCoordinates( + sourceWidth: number, + sourceHeight: number, + targetWidth: number, + targetHeight: number, +): Float32Array { + const sourceAspect = sourceWidth / sourceHeight + const targetAspect = targetWidth / targetHeight + let left = 0 + let right = 1 + let top = 0 + let bottom = 1 + + if (sourceAspect > targetAspect) { + const visibleWidth = targetAspect / sourceAspect + left = (1 - visibleWidth) / 2 + right = 1 - left + } else if (sourceAspect < targetAspect) { + const visibleHeight = sourceAspect / targetAspect + top = (1 - visibleHeight) / 2 + bottom = 1 - top + } + + return new Float32Array([left, top, right, top, left, bottom, right, bottom]) +} + function compileShader( gl: WebGLRenderingContext, type: number, @@ -127,9 +158,6 @@ export function createGameView( gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT) - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0) return { @@ -150,10 +178,21 @@ export function createGameView( gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) gl.finish() }, - resize(width: number, height: number) { + resize( + width: number, + height: number, + sourceWidth = window.innerWidth, + sourceHeight = window.innerHeight, + ) { if (disposed || lost) return canvas.width = width canvas.height = height + gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer) + gl.bufferData( + gl.ARRAY_BUFFER, + coverTextureCoordinates(sourceWidth, sourceHeight, width, height), + gl.DYNAMIC_DRAW, + ) gl.viewport(0, 0, width, height) }, } diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index c3c7a62..62672a7 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -1,8 +1,9 @@