ADD - add camera zoom levels

This commit is contained in:
Eichenholz
2026-08-06 15:11:42 +02:00
parent 46dac9cce4
commit 369817a4b8
7 changed files with 181 additions and 14 deletions
+18 -1
View File
@@ -17,6 +17,7 @@ const portraitAspect = 3 / 4
const landscapeAspect = 16 / 9
let bitrateBps = 1_500_000
let landscape = false
let zoom = 1
let gameView: GameView | null = null
let renderFrameId: number | undefined
let lastRenderAt = 0
@@ -57,6 +58,7 @@ function ensureGameView(): GameView {
dimensions.height,
window.innerWidth,
window.innerHeight,
zoom,
)
return gameView
}
@@ -209,7 +211,7 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
canvas.height = height
const view = createGameView(canvas, { preserveDrawingBuffer: true })
try {
view.resize(width, height, window.innerWidth, window.innerHeight)
view.resize(width, height, window.innerWidth, window.innerHeight, zoom)
await renderFrames(view, 3)
const output = document.createElement('canvas')
output.width = width
@@ -323,6 +325,21 @@ function onMessage(event: MessageEvent): void {
dimensions.height,
window.innerWidth,
window.innerHeight,
zoom,
)
}
} else if (message.type === 'camera:zoom') {
const nextZoom = Number(message.data?.zoom)
if (![0.5, 1, 2, 3].includes(nextZoom)) return
zoom = nextZoom
if (gameView && !gameView.isLost()) {
const dimensions = captureDimensions()
gameView.resize(
dimensions.width,
dimensions.height,
window.innerWidth,
window.innerHeight,
zoom,
)
}
} else if (message.type === 'media:uploadReady') {
+1
View File
@@ -199,6 +199,7 @@ const defaultLocales: LocaleTree = {
startRecording: 'Start recording',
stopRecording: 'Stop recording',
saved: 'Saved to Gallery.',
zoom: 'Set camera zoom to {zoom}',
errors: {
cancelled: 'Capture cancelled.',
capture_failed: 'Unable to capture the game view.',
+37 -5
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { coverTextureCoordinates } from '@/utils/gameView'
import { gameViewGeometry } from '@/utils/gameView'
describe('coverTextureCoordinates', () => {
describe('gameViewGeometry', () => {
it('center-crops a widescreen game view for 3:4 portrait output', () => {
expect(Array.from(coverTextureCoordinates(1920, 1080, 540, 720))).toEqual([
const geometry = gameViewGeometry(1920, 1080, 540, 720)
expect(Array.from(geometry.textureCoordinates)).toEqual([
expect.closeTo(0.29, 2),
0,
expect.closeTo(0.71, 2),
@@ -17,14 +18,45 @@ describe('coverTextureCoordinates', () => {
})
it('keeps the full game view for 16:9 landscape output', () => {
expect(Array.from(coverTextureCoordinates(1920, 1080, 720, 405))).toEqual([
const geometry = gameViewGeometry(1920, 1080, 720, 405)
expect(Array.from(geometry.textureCoordinates)).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([
const geometry = gameViewGeometry(1600, 900, 800, 450)
expect(Array.from(geometry.textureCoordinates)).toEqual([
0, 0, 1, 0, 0, 1, 1, 1,
])
})
it('uses the tablet camera zoom levels around the crop center', () => {
const wideGeometry = gameViewGeometry(1920, 1080, 540, 720, 0.5)
expect(Array.from(wideGeometry.textureCoordinates)).toEqual([
expect.closeTo(0.08, 2),
0,
expect.closeTo(0.92, 2),
0,
expect.closeTo(0.08, 2),
1,
expect.closeTo(0.92, 2),
1,
])
expect(Array.from(wideGeometry.positions)).toEqual([
-1, -0.5, 1, -0.5, -1, 0.5, 1, 0.5,
])
const zoomedGeometry = gameViewGeometry(1920, 1080, 540, 720, 2)
expect(Array.from(zoomedGeometry.textureCoordinates)).toEqual([
expect.closeTo(0.39, 2),
0.25,
expect.closeTo(0.61, 2),
0.25,
expect.closeTo(0.39, 2),
0.75,
expect.closeTo(0.61, 2),
0.75,
])
})
})
+57 -5
View File
@@ -26,6 +26,7 @@ export interface GameView {
height: number,
sourceWidth?: number,
sourceHeight?: number,
zoom?: number,
): void
}
@@ -33,12 +34,13 @@ export interface GameViewOptions {
preserveDrawingBuffer?: boolean
}
export function coverTextureCoordinates(
export function gameViewGeometry(
sourceWidth: number,
sourceHeight: number,
targetWidth: number,
targetHeight: number,
): Float32Array {
zoom = 1,
): { positions: Float32Array; textureCoordinates: Float32Array } {
const sourceAspect = sourceWidth / sourceHeight
const targetAspect = targetWidth / targetHeight
let left = 0
@@ -56,7 +58,47 @@ export function coverTextureCoordinates(
bottom = 1 - top
}
return new Float32Array([left, top, right, top, left, bottom, right, bottom])
const baseWidth = right - left
const baseHeight = bottom - top
const normalizedZoom = Math.min(3, Math.max(0.5, zoom))
const centerX = (left + right) / 2
const centerY = (top + bottom) / 2
left = Math.max(0, centerX + (left - centerX) / normalizedZoom)
right = Math.min(1, centerX + (right - centerX) / normalizedZoom)
top = Math.max(0, centerY + (top - centerY) / normalizedZoom)
bottom = Math.min(1, centerY + (bottom - centerY) / normalizedZoom)
const positionScaleX = Math.min(
1,
(normalizedZoom * (right - left)) / baseWidth,
)
const positionScaleY = Math.min(
1,
(normalizedZoom * (bottom - top)) / baseHeight,
)
return {
positions: new Float32Array([
-positionScaleX,
-positionScaleY,
positionScaleX,
-positionScaleY,
-positionScaleX,
positionScaleY,
positionScaleX,
positionScaleY,
]),
textureCoordinates: new Float32Array([
left,
top,
right,
top,
left,
bottom,
right,
bottom,
]),
}
}
function compileShader(
@@ -126,7 +168,7 @@ export function createGameView(
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
gl.DYNAMIC_DRAW,
)
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
@@ -188,14 +230,24 @@ export function createGameView(
height: number,
sourceWidth = window.innerWidth,
sourceHeight = window.innerHeight,
zoom = 1,
) {
if (disposed || lost) return
canvas.width = width
canvas.height = height
const geometry = gameViewGeometry(
sourceWidth,
sourceHeight,
width,
height,
zoom,
)
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
coverTextureCoordinates(sourceWidth, sourceHeight, width, height),
geometry.textureCoordinates,
gl.DYNAMIC_DRAW,
)
gl.viewport(0, 0, width, height)
+65 -1
View File
@@ -32,9 +32,11 @@ type CaptureItem = {
}
const isDevelopment = import.meta.env.DEV
const zoomLevels = [0.5, 1, 2, 3] as const
const phone = usePhoneStore()
const router = useRouter()
const mode = ref<MediaType>('photo')
const selectedZoom = ref<(typeof zoomLevels)[number]>(1)
const flashEnabled = ref(false)
const frontCamera = ref(false)
const shutterActive = ref(false)
@@ -223,6 +225,12 @@ function toggleOrientation(): void {
)
}
function setZoom(zoom: (typeof zoomLevels)[number]): void {
selectedZoom.value = zoom
resizeGameView()
window.postMessage({ data: { zoom }, type: 'camera:zoom' }, '*')
}
function resizeGameView(entry?: ResizeObserverEntry): void {
if (!gameCanvas.value || !gameView) return
const width = entry?.contentRect.width ?? gameCanvas.value.offsetWidth
@@ -233,6 +241,7 @@ function resizeGameView(entry?: ResizeObserverEntry): void {
Math.max(1, Math.round(height * renderScale)),
window.innerWidth,
window.innerHeight,
selectedZoom.value,
)
}
@@ -326,6 +335,10 @@ onMounted(() => {
{ data: { landscape: false }, type: 'camera:orientation' },
'*',
)
window.postMessage(
{ data: { zoom: selectedZoom.value }, type: 'camera:zoom' },
'*',
)
window.addEventListener('keydown', onKeydown)
window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true })
@@ -373,7 +386,12 @@ onBeforeUnmount(() => {
class="camera-game-view"
aria-hidden="true"
></canvas>
<div v-else class="camera-dev-view" aria-hidden="true">
<div
v-else
class="camera-dev-view"
:style="{ transform: `scale(${selectedZoom})` }"
aria-hidden="true"
>
<span class="camera-dev-sun"></span>
<span class="camera-dev-horizon"></span>
</div>
@@ -427,6 +445,21 @@ onBeforeUnmount(() => {
{{ savingVideo ? phone.t('Apps.camera.saving') : elapsed }}
</div>
<div class="camera-zoom-row">
<button
v-for="zoom in zoomLevels"
:key="zoom"
class="camera-zoom-pill"
:class="{ active: selectedZoom === zoom }"
type="button"
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
:aria-pressed="selectedZoom === zoom"
@click="setZoom(zoom)"
>
{{ zoom }}x
</button>
</div>
<footer class="camera-controls">
<div class="camera-capture-row">
<button
@@ -668,6 +701,37 @@ onBeforeUnmount(() => {
border-radius: 50%;
background: #ff3b30;
}
.camera-zoom-row {
position: absolute;
z-index: 4;
bottom: 154px;
left: 50%;
display: flex;
gap: 6px;
transform: translateX(-50%);
}
.camera-zoom-pill {
width: 30px;
height: 26px;
padding: 0;
border: 1px solid rgb(255 255 255 / 15%);
border-radius: 999px;
background: rgb(28 28 28 / 90%);
color: rgb(255 255 255 / 75%);
font-size: 10px;
text-align: center;
transition:
background-color 0.2s ease,
color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.camera-zoom-pill.active {
border-color: rgb(255 255 255 / 60%);
background: rgb(255 255 255 / 92%);
box-shadow: 0 8px 16px rgb(0 0 0 / 30%);
color: #111;
}
.camera-controls {
position: absolute;
z-index: 4;
+1
View File
@@ -67,6 +67,7 @@ Locales["en"] = {
focusHelp = "Space for movement", returnHelp = "Space to return", uploading = "{count} uploading",
saving = "Saving video...", openGallery = "Open Gallery", takePhoto = "Take photo",
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Gallery.",
zoom = "Set camera zoom to {zoom}",
errors = {
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-CrHpIbKG.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-BKoDphER.css">
<script type="module" crossorigin src="./assets/sky-index-DbVinxqT.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-CuZ_-2bz.css">
</head>
<body>
<div id="app"></div>