Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
c4dde2420This file is pinned to the selected release. Download the original to open it in your design tool.
import {
type CameraFrame,
type CameraImageType,
copyRgb565Frame,
type MosaicBlock,
sampleRgb565Mosaic,
toPiuColorString,
} from 'camera-preview-utils'
import Bitmap from 'commodetto/Bitmap'
import type { MainContent } from 'common-view'
import config from 'mc/config'
import { Container, Label, type Port as PiuPort, Skin } from 'piu/MC'
import RuntimeBitmapPort from 'runtime-bitmap-port'
import { ActionButton } from 'ui-controls'
import { uiStyles } from 'ui-theme'
export const CAMERA_PREVIEW_WIDTH = 200
export const CAMERA_PREVIEW_HEIGHT = 120
export type CameraPreviewRenderMode = 'bitmap' | 'mosaic'
export type CameraPreviewOptions = {
onRender?: (mode: CameraPreviewRenderMode) => void
onDismiss?: () => void
/** Caption drawn at the bottom of the full-area dialog. */
caption?: string
}
export type CameraPreviewFrame = {
width: number
height: number
imageType: CameraImageType
buffer: ArrayBuffer
blocks: MosaicBlock[]
}
// The preview sits centered on a full-area dialog background (60,60 centers 200x120 on a 320x240 screen).
const PREVIEW_LEFT = 60
const PREVIEW_TOP = 60
const PREVIEW_BLOCK_SIZE = 48
const PREVIEW_BACKGROUND = '#101010'
const DIALOG_BACKGROUND = '#000000'
const DEFAULT_CAPTION = 'カメラ'
// Match the Poco pixel byte order of the target display (RGB565BE on CoreS3) so the raw camera
// buffer blits directly without a per-pixel conversion.
const PREVIEW_BITMAP_FORMAT =
(Bitmap as unknown as Record<string, number>)[String((config as { format?: string }).format ?? '')] ?? Bitmap.RGB565LE
const PREVIEW_BITMAP_BYTE_ORDER = PREVIEW_BITMAP_FORMAT === Bitmap.RGB565BE ? 'be' : 'le'
type BitmapPort = PiuPort & {
drawBitmap?: (bitmap: Bitmap, x: number, y: number, sx?: number, sy?: number, sw?: number, sh?: number) => void
clearBitmap?: () => void
}
export function prepareCameraPreviewFrame(frame: CameraFrame): CameraPreviewFrame {
return {
width: CAMERA_PREVIEW_WIDTH,
height: CAMERA_PREVIEW_HEIGHT,
imageType: frame.imageType,
// Raw frame for the fast bitmap blit path...
buffer: copyRgb565Frame(frame, {
width: CAMERA_PREVIEW_WIDTH,
height: CAMERA_PREVIEW_HEIGHT,
byteOrder: PREVIEW_BITMAP_BYTE_ORDER,
}),
// ...plus a downsampled mosaic as a fallback when bitmap drawing is unavailable.
blocks: sampleRgb565Mosaic(frame, {
width: CAMERA_PREVIEW_WIDTH,
height: CAMERA_PREVIEW_HEIGHT,
blockSize: PREVIEW_BLOCK_SIZE,
}),
}
}
function createPreviewBitmap(preview: CameraPreviewFrame): Bitmap | null {
if (preview.imageType !== 'rgb565le' && preview.imageType !== 'rgb565be') return null
if (preview.buffer.byteLength < preview.width * preview.height * 2) return null
return new Bitmap(preview.width, preview.height, PREVIEW_BITMAP_FORMAT, preview.buffer, 0)
}
function drawPreviewBitmap(port: BitmapPort, preview: CameraPreviewFrame, bitmap: Bitmap | null): boolean {
if (!port.drawBitmap || (preview.imageType !== 'rgb565le' && preview.imageType !== 'rgb565be')) return false
if (preview.buffer.byteLength < preview.width * preview.height * 2) return false
if (!bitmap) return false
port.drawBitmap(bitmap, 0, 0, 0, 0, preview.width, preview.height)
return true
}
/**
* Build the camera preview as a full-area, Face-independent main component (MainContent).
* On device it blits the raw camera frame via RuntimeBitmapPort (real image), falling back to a
* mosaic when bitmap drawing is unavailable. Mounted via `ui.setMain(...)`, dismissed via `ui.showFace()`.
*/
export function createCameraPreviewDialog(
preview: CameraPreviewFrame,
options: CameraPreviewOptions = {},
): MainContent {
const previewPort = new RuntimeBitmapPort(
{ preview, options },
{
left: PREVIEW_LEFT,
top: PREVIEW_TOP,
width: CAMERA_PREVIEW_WIDTH,
height: CAMERA_PREVIEW_HEIGHT,
active: true,
Behavior: class extends Behavior {
preview: CameraPreviewFrame | null = null
bitmap: Bitmap | null = null
options: CameraPreviewOptions | null = null
didReportRenderMode = false
onCreate(_port: PiuPort, data: { preview: CameraPreviewFrame; options: CameraPreviewOptions }) {
this.preview = data.preview
this.bitmap = createPreviewBitmap(data.preview)
this.options = data.options
}
onDisplaying(port: PiuPort) {
port.invalidate()
}
onTouchEnded(_port: PiuPort) {
this.options?.onDismiss?.()
}
onUndisplaying(port: PiuPort) {
;(port as BitmapPort).clearBitmap?.()
this.preview = null
this.bitmap = null
this.options = null
}
onDraw(port: PiuPort) {
port.fillColor(PREVIEW_BACKGROUND, 0, 0, CAMERA_PREVIEW_WIDTH, CAMERA_PREVIEW_HEIGHT)
const preview = this.preview
if (!preview) return
if (drawPreviewBitmap(port as BitmapPort, preview, this.bitmap)) {
this.reportRenderMode('bitmap')
return
}
for (const block of preview.blocks) {
port.fillColor(toPiuColorString(block.color), block.x, block.y, block.width, block.height)
}
this.reportRenderMode('mosaic')
}
reportRenderMode(mode: CameraPreviewRenderMode) {
if (this.didReportRenderMode) return
this.didReportRenderMode = true
this.options?.onRender?.(mode)
}
},
},
)
const caption = new Label(null, {
left: 0,
right: 0,
bottom: 8,
height: 20,
style: uiStyles().compact,
string: options.caption ?? DEFAULT_CAPTION,
})
return new Container(
{ options },
{
left: 0,
right: 0,
top: 0,
bottom: 0,
active: true,
backgroundTouch: true,
skin: new Skin({ fill: DIALOG_BACKGROUND }),
contents: [
previewPort,
caption,
new ActionButton({ icon: 'close', onTap: options.onDismiss }, { right: 0, top: 44 }),
],
Behavior: class extends Behavior {
options: CameraPreviewOptions | null = null
onCreate(_container: unknown, data: { options: CameraPreviewOptions }) {
this.options = data.options
}
// Tapping anywhere on the dialog (outside the preview port) dismisses it.
onTouchEnded() {
this.options?.onDismiss?.()
}
// MainContentBehavior hooks (Face-independent lifecycle).
onShow(container: { first?: { invalidate?: () => void } }) {
container.first?.invalidate?.()
}
onHide() {
this.options = null
}
onDispose() {
this.options = null
}
},
},
) as MainContent
}