ENH - refine camera framing and controls

This commit is contained in:
Eichenholz
2026-08-06 12:32:30 +02:00
parent ab09f61b1c
commit 17b40a1d95
9 changed files with 327 additions and 73 deletions
+1
View File
@@ -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"
+23
View File
@@ -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;
+34 -7
View File
@@ -12,8 +12,11 @@ type PendingVideo = { blob: Blob; fileName: string }
const canvasRef = ref<HTMLCanvasElement | null>(null)
const pendingVideos = new Map<string, PendingVideo>()
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<void> {
}
async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
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)
}
+7
View File
@@ -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<string, number>,
@@ -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
},
+30
View File
@@ -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,
])
})
})
+44 -5
View File
@@ -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)
},
}
+184 -58
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { kFab, kPage, kSegmented, kSegmentedButton, kToast } from 'konsta/vue'
import { kFab, kPage, kTabbar, kTabbarLink, kToast } from 'konsta/vue'
import {
Camera as CameraIcon,
Images,
RectangleHorizontal,
RectangleVertical,
RefreshCw,
Video,
Zap,
@@ -13,6 +14,7 @@ import { useRouter } from 'vue-router'
import { usePhoneStore } from '@/stores/phone'
import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
import { createGameView, type GameView } from '@/utils/gameView'
import { formatRecordingDuration, mediaErrorKey } from '@/utils/media'
import { nuiCall } from '@/utils/nui'
@@ -37,25 +39,39 @@ const recordingStartedAt = ref(0)
const elapsed = ref('00:00')
const captures = ref<CaptureItem[]>([])
const latestMedia = ref<PhoneMedia | null>(null)
const gameCanvas = ref<HTMLCanvasElement | null>(null)
const toastOpened = ref(false)
const toastText = ref('')
const videoBitrateKbps = ref(1500)
let shutterTimer: number | undefined
let toastTimer: number | undefined
let recordingTimer: number | undefined
let gameView: GameView | null = null
let renderFrameId: number | undefined
let resizeObserver: ResizeObserver | null = null
const pendingCount = computed(
() =>
captures.value.filter((capture) => capture.status === 'uploading').length,
)
const controlColors = {
bgIos: 'bg-black/40',
textIos: 'text-white',
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
textIos: 'text-black dark:text-white',
}
const flashColors = computed(() => ({
...controlColors,
textIos: flashEnabled.value ? 'text-yellow-300' : 'text-white',
textIos: flashEnabled.value
? 'text-yellow-500 dark:text-yellow-300'
: controlColors.textIos,
}))
const tabbarColors = {
bgIos: 'bg-gradient-to-t from-black via-black/90 to-transparent',
}
const tabColors = {
textActiveIos: 'text-yellow-300',
textIos: 'text-white/55',
}
function correlationId(): string {
return `${Date.now()}-${crypto.randomUUID()}`
@@ -175,6 +191,11 @@ function capture(): void {
void requestPhoto()
}
function setMode(nextMode: MediaType): void {
if (recording.value || savingVideo.value) return
mode.value = nextMode
}
async function toggleFlash(): Promise<void> {
flashEnabled.value = !flashEnabled.value
await nuiCall('camera:setFlash', { enabled: flashEnabled.value })
@@ -185,6 +206,47 @@ async function toggleFacing(): Promise<void> {
await nuiCall('camera:setFacing', { front: frontCamera.value })
}
function toggleOrientation(): void {
if (recording.value || savingVideo.value) return
phone.setCameraLandscape(!phone.cameraLandscape)
window.postMessage(
{
data: { landscape: phone.cameraLandscape },
type: 'camera:orientation',
},
'*',
)
}
function resizeGameView(): void {
if (!gameCanvas.value || !gameView) return
const bounds = gameCanvas.value.getBoundingClientRect()
const renderScale = Math.min(window.devicePixelRatio, 2)
gameView.resize(
Math.max(1, Math.round(bounds.width * renderScale)),
Math.max(1, Math.round(bounds.height * renderScale)),
window.innerWidth,
window.innerHeight,
)
}
function startGameView(): void {
if (isDevelopment || !gameCanvas.value) return
gameView = createGameView(gameCanvas.value)
resizeObserver = new ResizeObserver(resizeGameView)
resizeObserver.observe(gameCanvas.value)
resizeGameView()
const render = () => {
if (!gameView || gameView.isLost()) {
renderFrameId = undefined
return
}
gameView.render()
renderFrameId = window.requestAnimationFrame(render)
}
renderFrameId = window.requestAnimationFrame(render)
}
function updateRecordingTimer(): void {
elapsed.value = formatRecordingDuration(Date.now() - recordingStartedAt.value)
}
@@ -253,6 +315,11 @@ async function loadLatest(): Promise<void> {
}
onMounted(() => {
phone.setCameraLandscape(false)
window.postMessage(
{ data: { landscape: false }, type: 'camera:orientation' },
'*',
)
window.addEventListener('keydown', onKeydown)
window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true })
@@ -264,6 +331,7 @@ onMounted(() => {
},
)
void loadLatest()
startGameView()
})
onBeforeUnmount(() => {
@@ -272,6 +340,14 @@ onBeforeUnmount(() => {
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('message', onMessage)
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
resizeObserver?.disconnect()
gameView?.dispose()
phone.setCameraLandscape(false)
window.postMessage(
{ data: { landscape: false }, type: 'camera:orientation' },
'*',
)
window.postMessage({ type: 'camera:recordCancel' }, '*')
void nuiCall('camera:setFlash', { enabled: false })
void nuiCall('camera:setActive', { active: false })
@@ -279,19 +355,25 @@ onBeforeUnmount(() => {
</script>
<template>
<k-page class="camera-page" :aria-label="phone.t('Apps.camera.name')">
<object
v-if="!isDevelopment"
class="camera-game-view"
type="application/x-cfx-game-view"
aria-hidden="true"
></object>
<div v-else class="camera-dev-view" aria-hidden="true">
<span class="camera-dev-sun"></span>
<span class="camera-dev-horizon"></span>
<k-page
class="camera-page"
:class="{ 'camera-page--landscape': phone.cameraLandscape }"
:aria-label="phone.t('Apps.camera.name')"
>
<div class="camera-viewport">
<canvas
v-if="!isDevelopment"
ref="gameCanvas"
class="camera-game-view"
aria-hidden="true"
></canvas>
<div v-else class="camera-dev-view" aria-hidden="true">
<span class="camera-dev-sun"></span>
<span class="camera-dev-horizon"></span>
</div>
<div class="camera-shade"></div>
<div class="camera-flash" :class="{ active: shutterActive }"></div>
</div>
<div class="camera-shade"></div>
<div class="camera-flash" :class="{ active: shutterActive }"></div>
<header class="camera-topbar">
<k-fab
@@ -333,23 +415,6 @@ onBeforeUnmount(() => {
</div>
<footer class="camera-controls">
<k-segmented class="camera-mode-picker">
<k-segmented-button
:active="mode === 'photo'"
:disabled="recording || savingVideo"
@click="mode = 'photo'"
>
{{ phone.t('Apps.camera.photo') }}
</k-segmented-button>
<k-segmented-button
:active="mode === 'video'"
:disabled="recording || savingVideo"
@click="mode = 'video'"
>
{{ phone.t('Apps.camera.video') }}
</k-segmented-button>
</k-segmented>
<div class="camera-capture-row">
<button
class="camera-latest"
@@ -385,10 +450,56 @@ onBeforeUnmount(() => {
<span></span>
</button>
<span class="camera-row-spacer" aria-hidden="true">
<CameraIcon :size="22" />
</span>
<button
class="camera-orientation"
type="button"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
phone.cameraLandscape
? 'Apps.camera.portrait'
: 'Apps.camera.landscape',
)
"
@click="toggleOrientation"
>
<RectangleVertical v-if="phone.cameraLandscape" :size="22" />
<RectangleHorizontal v-else :size="22" />
</button>
</div>
<k-tabbar labels class="camera-mode-tabbar" :colors="tabbarColors">
<k-tabbar-link
component="button"
:active="mode === 'photo'"
:class="{
'pointer-events-none opacity-50': recording || savingVideo,
}"
:colors="tabColors"
:label="phone.t('Apps.camera.photo')"
:link-props="{
component: 'button',
type: 'button',
'aria-disabled': recording || savingVideo,
}"
@click="setMode('photo')"
/>
<k-tabbar-link
component="button"
:active="mode === 'video'"
:class="{
'pointer-events-none opacity-50': recording || savingVideo,
}"
:colors="tabColors"
:label="phone.t('Apps.camera.video')"
:link-props="{
component: 'button',
type: 'button',
'aria-disabled': recording || savingVideo,
}"
@click="setMode('video')"
/>
</k-tabbar>
</footer>
<k-toast
@@ -408,6 +519,23 @@ onBeforeUnmount(() => {
background: #000;
color: #fff;
}
.camera-viewport {
position: absolute;
top: 50%;
left: 0;
width: 100%;
aspect-ratio: 3 / 4;
overflow: hidden;
transform: translateY(-50%);
}
.camera-page--landscape .camera-viewport {
top: 0;
left: 50%;
width: auto;
height: 100%;
aspect-ratio: 16 / 9;
transform: translateX(-50%);
}
.camera-game-view,
.camera-dev-view,
.camera-shade,
@@ -481,10 +609,11 @@ onBeforeUnmount(() => {
gap: 10px;
}
.camera-control {
width: 42px;
height: 42px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
--color-primary: #8e8e93;
}
.camera-control svg {
width: 21px;
height: 21px;
}
.camera-focus-pill,
.camera-upload-pill {
@@ -528,29 +657,24 @@ onBeforeUnmount(() => {
position: absolute;
z-index: 4;
right: 0;
bottom: 35px;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
gap: 18px;
padding: 0 24px;
}
.camera-mode-picker {
align-self: center;
width: 188px;
padding: 2px;
border-radius: 10px;
background: #0007;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
gap: 0;
padding: 0;
}
.camera-capture-row {
display: grid;
grid-template-columns: 54px 1fr 54px;
align-items: center;
padding: 0 24px 4px;
}
.camera-mode-tabbar {
width: 100%;
}
.camera-latest,
.camera-row-spacer {
.camera-orientation {
width: 50px;
height: 50px;
overflow: hidden;
@@ -561,15 +685,17 @@ onBeforeUnmount(() => {
display: grid;
place-items: center;
}
.camera-orientation {
justify-self: end;
}
.camera-orientation:disabled {
opacity: 0.5;
}
.camera-latest img {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-row-spacer {
justify-self: end;
visibility: hidden;
}
.camera-shutter {
justify-self: center;
width: 76px;
+2 -1
View File
@@ -62,7 +62,8 @@ Locales["en"] = {
},
calculator = { name = "Calculator" },
camera = {
name = "Camera", flash = "Flash", flip = "Flip camera", photo = "Photo", video = "Video",
name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape",
portrait = "Switch to portrait", photo = "Photo", video = "Video",
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.",
+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-BZSEyvMU.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-StMblc29.css">
<script type="module" crossorigin src="./assets/sky-index-BemolEtl.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-D03h3vHf.css">
</head>
<body>
<div id="app"></div>