fix(v2): preview rotation + crop aspect for 13.3" hardware
CI / test (push) Has been cancelled

Two related bugs that surfaced on the first 13.3" device's first photo:

1) Web-UI portrait preview was 90° sideways. DeviceApiController::
   renderBinToPng rotated whenever the device was Portrait — correct
   for V1 (landscape-native, Portrait => renderer rotated, so preview
   un-rotates) but wrong for V2 (portrait-native — the renderer
   doesn't rotate, so the preview shouldn't either). Now mirrors the
   render-pipeline check: rotate only when `orientation !==
   model->nativeOrientation()`. Two new functional tests pin the V2
   portrait and V2 landscape PNG dimensions to guard against
   regressions.

2) Cropped photo letterboxed on the 13.3" panel. CropEditor /
   StickerCanvas / FrameCard had V1 dimensions hardcoded (1600×960
   = 5:3 aspect). V2 is 4:3 (1200×1600 portrait / 1600×1200
   landscape), so a "full crop" came out the wrong shape and the
   server's white-canvas composite added bars. New `panelDims(model,
   orientation)` helper in @/types is the single source of truth on
   the frontend; matches DeviceModel::width/height on the server.
   Threaded `model` through Device serializer → Device type →
   UploadView → CropEditor / StickerCanvas, and HomeView → FrameCard.
   FrameCard tests updated to cover all four model × orientation
   placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:02:39 -04:00
parent b286a1f241
commit 081ca83613
11 changed files with 198 additions and 28 deletions
+9 -6
View File
@@ -57,7 +57,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import type { CropParams } from '@/types'
import { panelDims, type CropParams, type DeviceModel } from '@/types'
type Orientation = 'landscape' | 'portrait'
@@ -65,6 +65,10 @@ const props = defineProps<{
src: string
/** Frame's current orientation used as the toggle's initial value and for the mismatch chip. */
orientation: Orientation
/** Frame's hardware model drives crop output dimensions. V1 (7.3", 5:3) and
* V2 (13.3", 4:3) have different aspect ratios. Defaults to 'v1' so existing
* callers that don't pass a model keep the original behaviour. */
model?: DeviceModel
deviceName?: string
initialParams?: CropParams | null
/** When editing an existing image, defaults the toggle to the saved choice instead of `orientation`. */
@@ -85,11 +89,10 @@ const ORIENT_OPTS: Array<{ value: Orientation; label: string }> = [
// the crop frame and the eventual output blob.
const cropOrientation = ref<Orientation>(props.initialOrientation ?? props.orientation)
const outputDims = computed(() =>
cropOrientation.value === 'landscape'
? { w: 1600, h: 960 }
: { w: 960, h: 1600 }
)
const outputDims = computed(() => {
const { width, height } = panelDims(props.model ?? 'v1', cropOrientation.value)
return { w: width, h: height }
})
const aspect = computed(() => outputDims.value.w / outputDims.value.h)
// Visible only when the user's choice doesn't match the frame's setting.
+9 -5
View File
@@ -70,6 +70,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import { panelDims, type DeviceModel } from '@/types'
const props = defineProps<{
deviceId: number
@@ -77,6 +78,9 @@ const props = defineProps<{
size: 'large' | 'compact'
status: 'ok' | 'offline' | 'sync-fail'
orientation: 'landscape' | 'portrait'
/** Frame's hardware model — drives the empty-preview placeholder aspect
* ratio. V1 is 5:3, V2 is 4:3 (or 3:4 portrait). Defaults to 'v1'. */
model?: DeviceModel
thumbnailUrl?: string
photoCount?: number
lastSync?: string | null
@@ -91,11 +95,11 @@ defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
// roughly the same shape so the layout doesn't jump.
const previewStyle = computed(() => ({}))
const emptyAspectStyle = computed(() =>
props.size === 'large'
? { aspectRatio: props.orientation === 'portrait' ? '3 / 5' : '5 / 3' }
: {}
)
const emptyAspectStyle = computed(() => {
if (props.size !== 'large') return {}
const { width, height } = panelDims(props.model ?? 'v1', props.orientation)
return { aspectRatio: `${width} / ${height}` }
})
const statusText = computed(() => {
switch (props.status) {
+7 -4
View File
@@ -74,11 +74,14 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import Konva from 'konva'
import BaseButton from '@/components/BaseButton.vue'
import StickerTray from '@/components/StickerTray.vue'
import type { StickerLayer } from '@/types'
import { panelDims, type StickerLayer, type DeviceModel } from '@/types'
const props = defineProps<{
croppedUrl: string
orientation: 'landscape' | 'portrait'
/** Frame's hardware model drives stage aspect ratio + final output dims.
* Defaults to 'v1' so existing callers keep their behaviour. */
model?: DeviceModel
stickers: StickerLayer[]
}>()
@@ -101,7 +104,8 @@ const selectedId = ref<string | null>(null)
const stageW = ref(375)
const stageH = ref(225)
const ASPECT = props.orientation === 'landscape' ? (1600 / 960) : (960 / 1600)
const { width: OUT_W, height: OUT_H } = panelDims(props.model ?? 'v1', props.orientation)
const ASPECT = OUT_W / OUT_H
function sizeStage() {
if (!containerRef.value) return
@@ -384,8 +388,7 @@ async function done() {
const stage: Konva.Stage = stageRef.value?.getNode()
if (!stage) return
const outputW = props.orientation === 'landscape' ? 1600 : 960
const pixelRatio = outputW / stageW.value
const pixelRatio = OUT_W / stageW.value
// Use explicit mimeType so the blob is a real JPEG, not the default PNG
const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
+22 -6
View File
@@ -146,19 +146,35 @@ describe('FrameCard', () => {
expect(wrapper.emitted('edit')![0]).toEqual([1])
})
it('reserves a 5:3 aspect placeholder when no thumbnail is present (landscape)', () => {
it('reserves a V1 landscape (1600 × 960) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*960/)
})
it('reserves a 3:5 aspect placeholder when no thumbnail is present (portrait)', () => {
it('reserves a V1 portrait (960 × 1600) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*960\s*\/\s*1600/)
})
it('reserves a V2 portrait (1200 × 1600) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v2' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1200\s*\/\s*1600/)
})
it('reserves a V2 landscape (1600 × 1200) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v2' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*1200/)
})
})
+27
View File
@@ -6,10 +6,37 @@ export interface User {
timezone: string
}
export type DeviceModel = 'v1' | 'v2'
/** Panel dimensions a device renders to for a given user orientation. */
export interface PanelDims {
/** Pre-rotation source width — matches the renderer's thumbnail target. */
width: number
/** Pre-rotation source height. */
height: number
}
/**
* Crop / render dimensions for a device's photo at the user's chosen
* orientation. Mirrors `DeviceModel::width(Orientation)` / `height(...)` on
* the server. Keep these tables in sync.
*
* - V1: native landscape 800×480 upscaled to 1600×960 for better dither
* - V2: native portrait 1200×1600
*/
export function panelDims(model: DeviceModel, orientation: 'landscape' | 'portrait'): PanelDims {
if (model === 'v1') {
return orientation === 'landscape' ? { width: 1600, height: 960 } : { width: 960, height: 1600 }
}
// v2 — 13.3" Spectra-6, portrait-native
return orientation === 'landscape' ? { width: 1600, height: 1200 } : { width: 1200, height: 1600 }
}
export interface Device {
id: number
mac: string
name: string
model: DeviceModel
orientation: 'landscape' | 'portrait'
rotationIntervalMinutes: number
/** Wake times as minutes-since-midnight (0-1439). Empty = use rotationIntervalMinutes. */
+2
View File
@@ -30,6 +30,7 @@
size="large"
:status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation"
:model="devicesStore.devices[0].model"
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
:lastSync="lastSyncLabel(devicesStore.devices[0])"
:nextSync="nextSyncLabel(devicesStore.devices[0])"
@@ -59,6 +60,7 @@
size="large"
:status="deviceStatus(device)"
:orientation="device.orientation"
:model="device.model"
:thumbnailUrl="previewUrl(device)"
:lastSync="lastSyncLabel(device)"
:nextSync="nextSyncLabel(device)"
+6 -1
View File
@@ -30,6 +30,7 @@
v-if="step === 'crop' && uploadStore.originalUrl"
:src="uploadStore.originalUrl"
:orientation="contextOrientation"
:model="contextModel"
:device-name="contextDeviceName"
:initial-params="uploadStore.cropParams"
:initial-orientation="uploadStore.cropOrientation"
@@ -42,6 +43,7 @@
v-else-if="step === 'stickers' && uploadStore.croppedUrl"
:cropped-url="uploadStore.croppedUrl"
:orientation="effectiveOrientation"
:model="contextModel"
:stickers="uploadStore.stickers"
class="upload-view__stage"
@add-sticker="uploadStore.addSticker"
@@ -78,7 +80,7 @@ import { useUploadStore } from '@/stores/upload'
import { useDevicesStore } from '@/stores/devices'
import { useImagesStore } from '@/stores/images'
import { useToastStore } from '@/stores/toast'
import type { CropParams } from '@/types'
import type { CropParams, DeviceModel } from '@/types'
import CropEditor from '@/components/CropEditor.vue'
import StickerCanvas from '@/components/StickerCanvas.vue'
import DevicePicker from '@/components/DevicePicker.vue'
@@ -120,6 +122,9 @@ const contextDevice = computed(() =>
: devicesStore.devices[0]
)
const contextModel = computed<DeviceModel>(() =>
contextDevice.value?.model ?? 'v1'
)
const contextOrientation = computed<'landscape' | 'portrait'>(() =>
contextDevice.value?.orientation ?? 'landscape'
)