feat(library & studio): add EXIF info bar, portrait frame panning, and multi-photo selection with Panorama/HDR context menu
This commit is contained in:
@@ -173,6 +173,7 @@ export async function readCapturedAt(bytes: Uint8Array): Promise<number | null>
|
||||
// screenshot, a scan or a Fuji RAF answers: `exifr` reads the formats it knows
|
||||
// and nothing about a file it does not know is invented here.
|
||||
export interface ShotSpecs {
|
||||
camera: string | null;
|
||||
iso: number | null;
|
||||
// A focal length in millimetres, an f-number, and an exposure in seconds.
|
||||
focal: number | null;
|
||||
@@ -187,14 +188,23 @@ export interface ShotSpecs {
|
||||
export async function readSpecs(bytes: Uint8Array): Promise<ShotSpecs | null> {
|
||||
try {
|
||||
const tags = (await exifr.parse(bytes, {
|
||||
pick: ['ISO', 'ISOSpeedRatings', 'FocalLength', 'FNumber', 'ExposureTime', 'ExifImageWidth', 'ExifImageHeight'],
|
||||
pick: ['Make', 'Model', 'ISO', 'ISOSpeedRatings', 'FocalLength', 'FNumber', 'ExposureTime', 'ExifImageWidth', 'ExifImageHeight'],
|
||||
})) as Record<string, unknown> | undefined;
|
||||
if (!tags) return null;
|
||||
const num = (v: unknown): number | null => {
|
||||
const n = Number(Array.isArray(v) ? v[0] : v);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
const make = tags.Make ? String(tags.Make).trim() : '';
|
||||
const model = tags.Model ? String(tags.Model).trim() : '';
|
||||
let camera: string | null = null;
|
||||
if (model) {
|
||||
camera = make && !model.toLowerCase().startsWith(make.toLowerCase()) ? `${make} ${model}` : model;
|
||||
} else if (make) {
|
||||
camera = make;
|
||||
}
|
||||
return {
|
||||
camera,
|
||||
iso: num(tags.ISO ?? tags.ISOSpeedRatings),
|
||||
focal: num(tags.FocalLength),
|
||||
aperture: num(tags.FNumber),
|
||||
@@ -216,16 +226,18 @@ const round = (n: number) => String(Math.round(n * 10) / 10);
|
||||
// fraction a camera prints (`1/320s`), and everything the file does not have is
|
||||
// left out rather than stood in for — an empty line is what a file with no EXIF
|
||||
// gets, and the caller prints nothing.
|
||||
export function specsLine(specs: ShotSpecs | null): string {
|
||||
if (!specs) return '';
|
||||
export function specsLine(specs: ShotSpecs | null, place?: string | null, dims?: { w: number; h: number } | null): string {
|
||||
if (!specs && !place && !dims) return '';
|
||||
const parts = [
|
||||
specs.iso ? `ISO ${Math.round(specs.iso)}` : '',
|
||||
specs.focal ? `${round(specs.focal)}mm` : '',
|
||||
specs.aperture ? `f/${round(specs.aperture)}` : '',
|
||||
specs.exposure ? (specs.exposure < 1 ? `1/${Math.round(1 / specs.exposure)}s` : `${round(specs.exposure)}s`) : '',
|
||||
specs.width && specs.height ? `${specs.width}×${specs.height}` : '',
|
||||
specs?.camera ? specs.camera : '',
|
||||
specs?.aperture ? `f/${round(specs.aperture)}` : '',
|
||||
specs?.exposure ? (specs.exposure < 1 ? `1/${Math.round(1 / specs.exposure)}s` : `${round(specs.exposure)}s`) : '',
|
||||
specs?.iso ? `ISO ${Math.round(specs.iso)}` : '',
|
||||
specs?.focal ? `${round(specs.focal)}mm` : '',
|
||||
dims ? `${dims.w} × ${dims.h}` : (specs?.width && specs?.height ? `${specs.width} × ${specs.height}` : ''),
|
||||
place ? place : '',
|
||||
];
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
// EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect.
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface StudioState {
|
||||
recipe?: Recipe;
|
||||
simId?: string | null;
|
||||
frameId?: FrameId;
|
||||
framePanX?: number;
|
||||
crop?: CropRatio;
|
||||
cropRect?: CropRect;
|
||||
// false = the frame is on screen but not accepted yet (still draggable).
|
||||
|
||||
Reference in New Issue
Block a user