mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - add camera zoom levels
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user