first commit

This commit is contained in:
2026-07-18 01:34:52 +05:30
commit d1308bf149
113 changed files with 20593 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
import type { Annotation, EditState } from '../types';
import { editFilterCss } from './edits';
export interface BakeResult {
blob: Blob;
width: number;
height: number;
annotations: Annotation[];
}
/** True if the edit stack contains geometry that must be flattened into pixels. */
export function hasGeometry(edits?: EditState): boolean {
if (!edits) return false;
const c = edits.crop;
const cropped = !!c && (c.x > 0.001 || c.y > 0.001 || c.width < 0.999 || c.height < 0.999);
return cropped || !!edits.rotation || !!edits.straighten || !!edits.flipH || !!edits.flipV;
}
/**
* Flatten the visual edit stack (crop → flips → filter → rotation/straighten) of
* a (CORS-clean) image into a new JPEG Blob. Annotations are re-mapped into the
* cropped space (when un-rotated) so they stay aligned.
*/
export async function bakeEdits(
img: HTMLImageElement,
edits: EditState,
): Promise<BakeResult> {
const natW = img.naturalWidth || img.width;
const natH = img.naturalHeight || img.height;
const c = edits.crop ?? { x: 0, y: 0, width: 1, height: 1 };
const sx = Math.round(c.x * natW);
const sy = Math.round(c.y * natH);
const sw = Math.max(1, Math.round(c.width * natW));
const sh = Math.max(1, Math.round(c.height * natH));
// Canvas A: cropped region with flips + colour filter baked in.
const a = document.createElement('canvas');
a.width = sw;
a.height = sh;
const actx = a.getContext('2d');
if (!actx) throw new Error('Canvas unavailable');
const filter = editFilterCss(edits);
if (filter) actx.filter = filter;
const fH = edits.flipH ? -1 : 1;
const fV = edits.flipV ? -1 : 1;
actx.save();
actx.translate(fH < 0 ? sw : 0, fV < 0 ? sh : 0);
actx.scale(fH, fV);
actx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
actx.restore();
// Optional vignette.
const vig = edits.adjustments?.vignette ?? 0;
if (vig > 0) {
actx.filter = 'none';
const g = actx.createRadialGradient(sw / 2, sh / 2, Math.min(sw, sh) * 0.35, sw / 2, sh / 2, Math.max(sw, sh) * 0.75);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, `rgba(0,0,0,${Math.min(0.85, vig * 0.85).toFixed(2)})`);
actx.fillStyle = g;
actx.fillRect(0, 0, sw, sh);
}
// Rotation (90/180/270 + fine straighten).
const deg = ((edits.rotation ?? 0) + (edits.straighten ?? 0)) % 360;
let out = a;
if (deg !== 0) {
const rad = (deg * Math.PI) / 180;
const cos = Math.abs(Math.cos(rad));
const sin = Math.abs(Math.sin(rad));
const ow = Math.round(sw * cos + sh * sin);
const oh = Math.round(sw * sin + sh * cos);
const o = document.createElement('canvas');
o.width = ow;
o.height = oh;
const octx = o.getContext('2d');
if (!octx) throw new Error('Canvas unavailable');
octx.translate(ow / 2, oh / 2);
octx.rotate(rad);
octx.drawImage(a, -sw / 2, -sh / 2);
out = o;
}
const blob = await new Promise<Blob>((resolve, reject) =>
out.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.92),
);
// Re-map annotations into the cropped coordinate space (skip when rotated).
let annotations: Annotation[] = [];
if (edits.annotations?.length && deg === 0) {
const remap = (x: number, y: number) => ({
x: (x - c.x) / c.width,
y: (y - c.y) / c.height,
});
annotations = edits.annotations.map((an) => {
const p1 = remap(an.x1, an.y1);
const p2 = remap(an.x2, an.y2);
return {
...an,
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y,
points: an.points?.map((pt) => remap(pt.x, pt.y)),
};
});
}
return { blob, width: out.width, height: out.height, annotations };
}
+78
View File
@@ -0,0 +1,78 @@
import type { MediaSource } from '../types';
/**
* Heuristic media-source classification — no AI model required.
*
* Uses filename patterns, dimensions, mime type and the presence of camera EXIF
* data to guess whether a file is a screenshot, a download, a social-app save,
* a scanned document, or a real camera capture. This mirrors how Photos
* auto-populates its "Screenshots" / "Recents" smart albums.
*/
export interface ClassifyInput {
name: string;
width: number;
height: number;
mime: string;
hasCameraExif?: boolean;
/** Common device screen sizes help confirm screenshots. */
knownScreenSizes?: Array<[number, number]>;
}
const SCREENSHOT_NAME = /(screen[\s_-]?shot|screenshot|screen recording|capture)/i;
const SOCIAL_NAME = /(whatsapp|img-\d{8}-wa\d+|telegram|instagram|insta|fb_img|facebook|messenger|signal|snapchat)/i;
const DOWNLOAD_NAME = /(download|untitled|unsplash|pexels|pixabay|getty|shutterstock|wallpaper)/i;
const SCAN_NAME = /(scan|scanned|cam[\s_-]?scanner|doc[\s_-]?scan|adobe[\s_-]?scan)/i;
const AI_NAME = /(midjourney|dall[\s_-]?e|stable[\s_-]?diffusion|sdxl|ai[\s_-]?generated|upscayl|gigapixel|remaster)/i;
const CAMERA_NAME = /(img_\d+|dsc[_-]?\d+|dscf\d+|p\d{7}|gopr\d+|dji_\d+|_mg_\d+|pxl_\d+)/i;
const DEFAULT_SCREEN_SIZES: Array<[number, number]> = [
[1170, 2532], [1284, 2778], [1080, 2400], [1080, 1920], [1440, 3200],
[2532, 1170], [2778, 1284], [2400, 1080], [1920, 1080],
[2560, 1440], [3840, 2160], [1280, 800], [2880, 1800], [1366, 768],
];
export function classifyMediaSource(input: ClassifyInput): MediaSource {
const { name, width, height, mime, hasCameraExif } = input;
const lower = name.toLowerCase();
if (mime.startsWith('video/')) {
if (SCREENSHOT_NAME.test(lower)) return 'screenshot'; // screen recording
if (SOCIAL_NAME.test(lower)) return 'social';
return hasCameraExif ? 'camera' : 'imported';
}
if (AI_NAME.test(lower)) return 'ai';
if (SCAN_NAME.test(lower)) return 'scanned';
if (SCREENSHOT_NAME.test(lower)) return 'screenshot';
if (SOCIAL_NAME.test(lower)) return 'social';
// An image at an exact device resolution with no camera EXIF -> almost certainly a screenshot.
const sizes = input.knownScreenSizes ?? DEFAULT_SCREEN_SIZES;
const matchesScreen = sizes.some(([w, h]) => w === width && h === height);
if (!hasCameraExif && matchesScreen) {
return 'screenshot';
}
if (CAMERA_NAME.test(lower) || hasCameraExif) return 'camera';
if (DOWNLOAD_NAME.test(lower)) return 'download';
// PNGs without camera data are usually web downloads or graphics, not captures.
if (mime === 'image/png' && !hasCameraExif) return 'download';
return 'imported';
}
/** Human-friendly label for a media source. */
export function sourceLabel(source: MediaSource): string {
switch (source) {
case 'camera': return 'Camera';
case 'screenshot': return 'Screenshot';
case 'download': return 'Downloaded';
case 'social': return 'Social';
case 'scanned': return 'Scanned';
case 'ai': return 'AI Generated';
case 'imported': return 'Imported';
default: return 'Unknown';
}
}
+105
View File
@@ -0,0 +1,105 @@
import type { MediaId, MediaItem } from '../types';
/**
* Face clustering — groups detected faces across the library into people.
*
* Faces carry a 128-D embedding (from the provider's face-recognition model).
* Two faces of the same person sit close in that space; different people sit far
* apart. We do a simple, deterministic online clustering: process faces largest
* first (big, frontal faces make better seeds), and assign each to the nearest
* existing centroid within `threshold`, else start a new cluster.
*
* Kept dependency-free and pure so it runs anywhere and is trivially testable.
*/
/** Max Euclidean distance between L2-ish descriptors for "same person".
* face-api's recommended cut-off is 0.6; we use a slightly tighter 0.55 to
* favour precision (fewer wrong merges) over recall. */
const DEFAULT_THRESHOLD = 0.55;
export interface FaceCluster {
/** Unique media ids in this cluster, ordered by first appearance. */
mediaIds: MediaId[];
/** Item whose face is the most prominent (largest box) — used as the cover. */
coverId: MediaId;
/** Mean descriptor of the cluster. */
centroid: number[];
/** Number of individual faces (not items) merged into this cluster. */
faceCount: number;
}
function euclidean(a: number[], b: number[]): number {
let sum = 0;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
const d = a[i]! - b[i]!;
sum += d * d;
}
return Math.sqrt(sum);
}
export function clusterFaces(media: MediaItem[], threshold = DEFAULT_THRESHOLD): FaceCluster[] {
// Flatten every embedded face; bigger faces first for stabler seed centroids.
const faces: { itemId: MediaId; emb: number[]; area: number }[] = [];
for (const m of media) {
if (m.deletedAt) continue;
for (const f of m.faces ?? []) {
if (f.embedding && f.embedding.length > 0) {
faces.push({ itemId: m.id, emb: f.embedding, area: f.box.width * f.box.height });
}
}
}
faces.sort((a, b) => b.area - a.area);
interface Acc {
sum: number[];
n: number;
centroid: number[];
members: { itemId: MediaId; area: number }[];
}
const clusters: Acc[] = [];
for (const f of faces) {
let best = -1;
let bestD = Infinity;
for (let i = 0; i < clusters.length; i++) {
const d = euclidean(clusters[i]!.centroid, f.emb);
if (d < bestD) {
bestD = d;
best = i;
}
}
if (best >= 0 && bestD <= threshold) {
const c = clusters[best]!;
for (let k = 0; k < f.emb.length; k++) c.sum[k] = (c.sum[k] ?? 0) + f.emb[k]!;
c.n += 1;
c.centroid = c.sum.map((s) => s / c.n);
c.members.push({ itemId: f.itemId, area: f.area });
} else {
clusters.push({
sum: [...f.emb],
n: 1,
centroid: [...f.emb],
members: [{ itemId: f.itemId, area: f.area }],
});
}
}
return clusters.map((c) => {
const seen = new Set<MediaId>();
const mediaIds: MediaId[] = [];
let coverId = c.members[0]!.itemId;
let coverArea = -1;
for (const m of c.members) {
if (!seen.has(m.itemId)) {
seen.add(m.itemId);
mediaIds.push(m.itemId);
}
if (m.area > coverArea) {
coverArea = m.area;
coverId = m.itemId;
}
}
return { mediaIds, coverId, centroid: c.centroid, faceCount: c.n };
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Lightweight password hashing for the client-side album lock.
*
* NOTE: this is a convenience/privacy UX feature, not server-grade auth. The
* hash is computed with the Web Crypto API (SHA-256 over a fixed app salt + the
* password) and stored locally. It keeps casual onlookers out of the Recently
* Deleted view on a shared device; it is NOT a substitute for real authz.
*/
const APP_SALT = 'apg::recently-deleted::v1';
function toHex(buf: ArrayBuffer): string {
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** Returns `sha256:<hex>` for the given password, or '' if Web Crypto is absent. */
export async function hashPassword(password: string): Promise<string> {
const subtle = globalThis.crypto?.subtle;
if (!subtle) return '';
const data = new TextEncoder().encode(`${APP_SALT}:${password}`);
const digest = await subtle.digest('SHA-256', data);
return `sha256:${toHex(digest)}`;
}
/** Constant-time-ish comparison of a candidate password against a stored hash. */
export async function verifyPassword(password: string, storedHash: string | null): Promise<boolean> {
if (!storedHash) return false;
const candidate = await hashPassword(password);
if (candidate.length !== storedHash.length) return false;
let diff = 0;
for (let i = 0; i < candidate.length; i++) diff |= candidate.charCodeAt(i) ^ storedHash.charCodeAt(i);
return diff === 0;
}
+52
View File
@@ -0,0 +1,52 @@
import type { MediaItem } from '../types';
/** Trigger a browser download of a single media item by URL. */
export function downloadMedia(item: MediaItem): void {
if (typeof document === 'undefined') return;
const a = document.createElement('a');
a.href = item.src;
a.download = sanitizeFilename(item.name);
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
/** Export a metadata-only JSON sidecar for the current selection. */
export function exportMetadata(items: MediaItem[]): void {
if (typeof document === 'undefined') return;
const payload = items.map((i) => ({
name: i.name,
takenAt: new Date(i.takenAt).toISOString(),
source: i.source,
tags: i.tags,
objects: i.objectLabels,
location: i.location,
favorite: i.favorite,
width: i.width,
height: i.height,
}));
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'photos-metadata.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* Sanitize a filename to prevent path traversal / illegal characters when it is
* used as a download target. Security-relevant: never trust an imported name.
*/
export function sanitizeFilename(name: string): string {
const cleaned = name
// Strip path separators and characters illegal on common filesystems.
.replace(/[/\\?%*:|"<>]/g, '_')
// Collapse parent-directory sequences (path traversal guard).
.replace(/\.\.+/g, '_')
.trim();
return cleaned.slice(0, 200) || 'photo';
}
+56
View File
@@ -0,0 +1,56 @@
import { FILTER_PRESETS, ZERO_ADJUSTMENTS } from '../constants';
import type { EditAdjustments, EditState } from '../types';
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
function resolveAdjustments(edits: EditState): EditAdjustments {
const preset = edits.filter ? FILTER_PRESETS[edits.filter]?.adjustments ?? {} : {};
return { ...ZERO_ADJUSTMENTS, ...preset, ...edits.adjustments };
}
/** Build a CSS `filter` string approximating the adjustment stack. */
export function editFilterCss(edits?: EditState): string | undefined {
if (!edits) return undefined;
const a = resolveAdjustments(edits);
const brightness = clamp(
1 + a.exposure * 0.4 + a.brightness * 0.4 + a.shadows * 0.12 - a.highlights * 0.06,
0.2,
2.2,
);
const contrast = clamp(
1 + a.contrast * 0.5 + a.definition * 0.2 + a.sharpness * 0.15 + a.blackPoint * 0.25,
0.2,
2.6,
);
const saturate = clamp(1 + a.saturation * 0.85 + a.vibrance * 0.35, 0, 3);
const sepia = clamp(Math.max(0, a.warmth) * 0.5, 0, 1);
const hue = (a.warmth < 0 ? a.warmth * 14 : 0) + a.tint * 14;
const parts = [
`brightness(${brightness.toFixed(3)})`,
`contrast(${contrast.toFixed(3)})`,
];
// Heavily desaturated presets read as true monochrome.
if (saturate <= 0.2) parts.push('grayscale(1)');
else parts.push(`saturate(${saturate.toFixed(3)})`);
if (sepia > 0.01) parts.push(`sepia(${sepia.toFixed(3)})`);
if (Math.abs(hue) > 0.5) parts.push(`hue-rotate(${hue.toFixed(1)}deg)`);
return parts.join(' ');
}
/** Build a CSS `transform` for rotation + straighten + flips. */
export function editTransformCss(edits?: EditState): string | undefined {
if (!edits) return undefined;
const straighten = edits.straighten ?? 0;
const rot = (edits.rotation ?? 0) + straighten;
// Scale up slightly when straightening so the rotated image still covers the frame.
const cover = straighten ? 1 + Math.min(0.5, Math.abs(straighten) / 90) : 1;
const sx = (edits.flipH ? -1 : 1) * cover;
const sy = (edits.flipV ? -1 : 1) * cover;
if (!rot && sx === 1 && sy === 1) return undefined;
return `rotate(${rot}deg) scale(${sx}, ${sy})`;
}
export { ZERO_ADJUSTMENTS };
+76
View File
@@ -0,0 +1,76 @@
/** Formatting helpers for dates, sizes and durations. */
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
export function formatBytes(bytes?: number): string {
if (!bytes || bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
export function formatDuration(seconds?: number): string {
if (!seconds || seconds < 0) return '';
const s = Math.floor(seconds % 60);
const m = Math.floor((seconds / 60) % 60);
const h = Math.floor(seconds / 3600);
const pad = (n: number) => n.toString().padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export function formatDate(ms: number): string {
const d = new Date(ms);
return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
export function formatDay(ms: number): string {
const d = new Date(ms);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
if (sameDay(d, today)) return 'Today';
if (sameDay(d, yesterday)) return 'Yesterday';
const weekday = d.toLocaleDateString(undefined, { weekday: 'long' });
return `${weekday}, ${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
export function formatMonth(ms: number): string {
const d = new Date(ms);
return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
export function formatYear(ms: number): string {
return new Date(ms).getFullYear().toString();
}
export function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
export function relativeTime(ms: number): string {
const diff = Date.now() - ms;
const day = 24 * 60 * 60 * 1000;
if (diff < 0) return 'in the future';
if (diff < day) return 'today';
const days = Math.floor(diff / day);
if (days === 1) return 'yesterday';
if (days < 30) return `${days} days ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} month${months > 1 ? 's' : ''} ago`;
const years = Math.floor(days / 365);
return `${years} year${years > 1 ? 's' : ''} ago`;
}
/** Days remaining before a trashed item is permanently removed. */
export function daysUntilPermanentDelete(deletedAt: number, retentionMs: number): number {
const remaining = deletedAt + retentionMs - Date.now();
return Math.max(0, Math.ceil(remaining / (24 * 60 * 60 * 1000)));
}
+63
View File
@@ -0,0 +1,63 @@
import type { MediaItem } from '../types';
import { formatDay, formatMonth, formatYear } from './format';
export interface MediaSection<T = MediaItem> {
key: string;
title: string;
subtitle?: string;
items: T[];
}
type Granularity = 'day' | 'month' | 'year';
function bucketKey(ms: number, granularity: Granularity): string {
const d = new Date(ms);
switch (granularity) {
case 'day': return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
case 'month': return `${d.getFullYear()}-${d.getMonth()}`;
case 'year': return `${d.getFullYear()}`;
}
}
function bucketTitle(ms: number, granularity: Granularity): string {
switch (granularity) {
case 'day': return formatDay(ms);
case 'month': return formatMonth(ms);
case 'year': return formatYear(ms);
}
}
/**
* Group media into reverse-chronological sections by day/month/year.
* Used by the Library's Years / Months / All Photos views.
*/
export function groupByTime(
items: MediaItem[],
granularity: Granularity,
): MediaSection[] {
const sorted = [...items].sort((a, b) => b.takenAt - a.takenAt);
const map = new Map<string, MediaSection>();
for (const item of sorted) {
const key = bucketKey(item.takenAt, granularity);
let section = map.get(key);
if (!section) {
section = { key, title: bucketTitle(item.takenAt, granularity), items: [] };
map.set(key, section);
}
section.items.push(item);
}
return [...map.values()];
}
/** Find probable duplicates by (name, bytes, dimensions) signature. */
export function findDuplicateGroups(items: MediaItem[]): MediaItem[][] {
const map = new Map<string, MediaItem[]>();
for (const item of items) {
if (item.deletedAt) continue;
const sig = `${item.name.toLowerCase()}|${item.bytes ?? 0}|${item.width}x${item.height}`;
const arr = map.get(sig) ?? [];
arr.push(item);
map.set(sig, arr);
}
return [...map.values()].filter((g) => g.length > 1);
}
+253
View File
@@ -0,0 +1,253 @@
import { nanoid } from 'nanoid';
import type { MediaItem, MediaKind } from '../types';
import { classifyMediaSource } from './classify';
export type MediaInput = Partial<MediaItem> & { src: string };
/** Normalize loose input into a fully-formed MediaItem with sensible defaults. */
export function createMediaItem(input: MediaInput): MediaItem {
const now = Date.now();
const kind: MediaKind = input.kind ?? (input.mime?.startsWith('video/') ? 'video' : 'image');
const name = input.name ?? 'Untitled';
const width = input.width ?? 1600;
const height = input.height ?? 1067;
const mime = input.mime ?? (kind === 'video' ? 'video/mp4' : 'image/jpeg');
const source =
input.source ??
classifyMediaSource({
name,
width,
height,
mime,
hasCameraExif: Boolean(input.exif && Object.keys(input.exif).length > 0),
});
return {
id: input.id ?? nanoid(10),
kind,
src: input.src,
thumbnail: input.thumbnail,
poster: input.poster,
name,
width,
height,
mime,
bytes: input.bytes,
takenAt: input.takenAt ?? now,
importedAt: input.importedAt ?? now,
editedAt: input.editedAt,
deletedAt: input.deletedAt,
duration: input.duration,
favorite: input.favorite ?? false,
hidden: input.hidden ?? false,
source,
albumIds: input.albumIds ?? [],
tags: input.tags ?? [],
objectLabels: input.objectLabels ?? [],
personIds: input.personIds ?? [],
location: input.location,
exif: input.exif,
caption: input.caption,
objects: input.objects,
faces: input.faces,
analyzedAt: input.analyzedAt,
colorPalette: input.colorPalette,
blurScore: input.blurScore,
qualityScore: input.qualityScore,
embedding: input.embedding,
isLivePhoto: input.isLivePhoto,
isRaw: input.isRaw,
isPanorama: input.isPanorama,
edits: input.edits,
// Preserve edit history + comments through the single normalization path so
// versions/comments survive a reload (localStorage adapter re-normalizes on load).
versions: input.versions,
comments: input.comments,
};
}
/**
* Validate a media URL. Allows http(s), data:image/video, blob, and same-origin
* relative paths; rejects dangerous schemes (javascript:, vbscript:, file:, …).
* Security: untrusted `src`/`thumbnail`/`poster` must never carry an active scheme.
*/
export function safeMediaUrl(value: unknown): string | undefined {
const s = typeof value === 'string' ? value.trim() : '';
if (!s) return undefined;
if (/^(https?:|blob:)/i.test(s)) return s;
if (/^data:(image|video)\//i.test(s)) return s;
// No scheme at all → treat as a relative same-origin path.
if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) return s;
return undefined; // unknown / disallowed scheme
}
const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
const asStringArray = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
const asFiniteNumber = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
/**
* Coerce an untrusted object (from props or persisted storage) into a safe
* MediaItem. Returns null if it has no usable source. This is the single
* normalization path so persisted and prop-supplied data are validated alike.
*/
export function normalizeMediaItem(raw: unknown): MediaItem | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const src = safeMediaUrl(r.src);
if (!src) return null;
let location: MediaItem['location'];
if (r.location && typeof r.location === 'object') {
const loc = r.location as Record<string, unknown>;
const lat = asFiniteNumber(loc.lat);
const lng = asFiniteNumber(loc.lng);
if (lat !== undefined && lng !== undefined) {
location = { lat, lng, place: asString(loc.place), city: asString(loc.city), country: asString(loc.country) };
}
}
return createMediaItem({
...(r as MediaInput),
src,
thumbnail: safeMediaUrl(r.thumbnail),
poster: safeMediaUrl(r.poster),
name: asString(r.name),
caption: asString(r.caption),
tags: asStringArray(r.tags),
objectLabels: asStringArray(r.objectLabels),
albumIds: asStringArray(r.albumIds),
personIds: asStringArray(r.personIds),
location,
});
}
/** Accepted upload types — guards against importing arbitrary/executable files. */
const ACCEPTED = /^(image\/(jpeg|png|gif|webp|heic|heif|avif|bmp|tiff)|video\/(mp4|quicktime|webm|x-matroska|3gpp))$/;
export function isAcceptedMediaFile(file: File): boolean {
return ACCEPTED.test(file.type) || /\.(jpe?g|png|gif|webp|heic|heif|avif|bmp|tiff?|mp4|mov|webm|mkv|3gp)$/i.test(file.name);
}
/** Read a Blob/File into a base64 data: URL. Durable across reloads (unlike blob: URLs). */
export function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
/** Build a MediaItem from a browser File (reads real dimensions / duration). */
export async function mediaFromFile(file: File): Promise<MediaItem | null> {
if (!isAcceptedMediaFile(file)) return null;
const isVideo = file.type.startsWith('video/') || /\.(mp4|mov|webm|mkv|3gp)$/i.test(file.name);
const url = URL.createObjectURL(file);
try {
if (isVideo) {
const meta = await readVideoMeta(url);
return createMediaItem({
src: url,
name: file.name,
mime: file.type || 'video/mp4',
bytes: file.size,
kind: 'video',
takenAt: file.lastModified || Date.now(),
width: meta.width,
height: meta.height,
duration: meta.duration,
});
}
const dims = await readImageMeta(url);
const meta = await readExif(file);
return createMediaItem({
src: url,
name: file.name,
mime: file.type || 'image/jpeg',
bytes: file.size,
kind: 'image',
takenAt: meta.takenAt ?? file.lastModified ?? Date.now(),
width: dims.width,
height: dims.height,
isPanorama: dims.width / Math.max(1, dims.height) > 2.2,
location: meta.location,
exif: meta.exif,
tags: meta.tags,
});
} catch {
URL.revokeObjectURL(url);
return null;
}
}
interface ExifMeta {
takenAt?: number;
location?: MediaItem['location'];
exif?: Record<string, string | number>;
/** Existing keywords/tags from IPTC/XMP. */
tags?: string[];
}
/** Parse EXIF + IPTC/XMP (date, GPS, camera, keywords) from an image File. Lazy-loads `exifr`. */
async function readExif(file: File): Promise<ExifMeta> {
try {
const exifr = (await import('exifr')).default;
// Parse GPS + IPTC + XMP so existing keywords/tags and location are imported.
const data = await exifr.parse(file, { gps: true, iptc: true, xmp: true });
if (!data) return {};
const out: ExifMeta = {};
const dt = data.DateTimeOriginal ?? data.CreateDate;
if (dt) {
const t = new Date(dt).getTime();
if (Number.isFinite(t)) out.takenAt = t;
}
if (typeof data.latitude === 'number' && typeof data.longitude === 'number') {
out.location = { lat: data.latitude, lng: data.longitude };
}
const exif: Record<string, string | number> = {};
for (const k of ['Make', 'Model', 'LensModel', 'ISO', 'FNumber', 'FocalLength'] as const) {
const v = data[k];
if (typeof v === 'string' || typeof v === 'number') exif[k] = v;
}
if (Object.keys(exif).length) out.exif = exif;
// Import existing keywords/tags from IPTC (Keywords) and XMP (dc:subject).
const raw: unknown[] = [];
const push = (v: unknown) => {
if (Array.isArray(v)) raw.push(...v);
else if (typeof v === 'string') raw.push(...v.split(/[;,]/));
};
push(data.Keywords);
push(data.subject);
const tags = [...new Set(raw.map((s) => String(s).trim().toLowerCase()).filter(Boolean))];
if (tags.length) out.tags = tags;
return out;
} catch {
return {};
}
}
function readImageMeta(url: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = reject;
img.src = url;
});
}
function readVideoMeta(url: string): Promise<{ width: number; height: number; duration: number }> {
return new Promise((resolve, reject) => {
const v = document.createElement('video');
v.preload = 'metadata';
v.onloadedmetadata = () =>
resolve({ width: v.videoWidth, height: v.videoHeight, duration: v.duration });
v.onerror = reject;
v.src = url;
});
}
+169
View File
@@ -0,0 +1,169 @@
import type { Album, MediaItem, SmartRule, SmartRuleSet } from '../types';
/** Evaluate a single smart rule against a media item. */
function evalRule(item: MediaItem, rule: SmartRule): boolean {
const { field, op, value } = rule;
const get = (): unknown => {
switch (field) {
case 'source': return item.source;
case 'kind': return item.kind;
case 'favorite': return item.favorite;
case 'mime': return item.mime;
case 'name': return item.name;
case 'takenAt': return item.takenAt;
case 'hasLocation': return Boolean(item.location);
case 'hasText': return Boolean(item.ocrText && item.ocrText.trim().length > 0);
case 'isRaw': return Boolean(item.isRaw);
case 'isLivePhoto': return Boolean(item.isLivePhoto);
case 'isPanorama': return Boolean(item.isPanorama);
case 'tag': return item.tags;
case 'object': return item.objectLabels;
case 'person': return item.personIds;
default: return undefined;
}
};
const actual = get();
switch (op) {
case 'eq': return actual === value;
case 'neq': return actual !== value;
case 'isTrue': return actual === true;
case 'isFalse': return actual === false || actual === undefined;
case 'gt': return typeof actual === 'number' && typeof value === 'number' && actual > value;
case 'lt': return typeof actual === 'number' && typeof value === 'number' && actual < value;
case 'contains':
if (Array.isArray(actual)) {
return actual.some(
(v) => typeof v === 'string' && typeof value === 'string' && v.toLowerCase().includes(value.toLowerCase()),
);
}
if (typeof actual === 'string' && typeof value === 'string') {
return actual.toLowerCase().includes(value.toLowerCase());
}
return false;
default: return false;
}
}
/** Does an item satisfy a rule set (AND/OR over its rules)? */
export function matchesRuleSet(item: MediaItem, ruleSet: SmartRuleSet): boolean {
if (item.deletedAt) return false; // trashed items never appear in smart albums
if (ruleSet.rules.length === 0) return true;
return ruleSet.match === 'all'
? ruleSet.rules.every((r) => evalRule(item, r))
: ruleSet.rules.some((r) => evalRule(item, r));
}
/** Resolve the live member ids of a smart album from the full library. */
export function resolveSmartAlbum(album: Album, items: MediaItem[]): string[] {
if (!album.ruleSet) return [];
return items.filter((i) => matchesRuleSet(i, album.ruleSet!)).map((i) => i.id);
}
/**
* The system smart albums Photos ships with. These are recomputed from the
* library whenever it changes, so they always reflect current content.
*/
export function defaultSystemAlbums(now: number): Album[] {
const base = (
id: string,
name: string,
icon: string,
ruleSet: SmartRuleSet,
pinned = true,
): Album => ({
id,
name,
kind: 'smart',
mediaIds: [],
ruleSet,
createdAt: now,
pinned,
system: true,
icon,
});
return [
base('sys:favourites', 'Favourites', 'heart', {
match: 'all',
rules: [{ field: 'favorite', op: 'isTrue' }],
}),
base('sys:videos', 'Videos', 'video', {
match: 'all',
rules: [{ field: 'kind', op: 'eq', value: 'video' }],
}),
base('sys:screenshots', 'Screenshots', 'screenshot', {
match: 'all',
rules: [{ field: 'source', op: 'eq', value: 'screenshot' }],
}),
base('sys:documents', 'Documents', 'document', {
match: 'all',
rules: [{ field: 'hasText', op: 'isTrue' }],
}),
base('sys:selfies', 'Selfies', 'person', {
match: 'any',
rules: [{ field: 'tag', op: 'contains', value: 'selfie' }],
}, false),
base('sys:recently-saved', 'Recently Saved', 'download', {
match: 'any',
rules: [
{ field: 'source', op: 'eq', value: 'download' },
{ field: 'source', op: 'eq', value: 'social' },
],
}),
base('sys:raw', 'RAW', 'raw', {
match: 'all',
rules: [{ field: 'isRaw', op: 'isTrue' }],
}, false),
base('sys:live', 'Live Photos', 'live', {
match: 'all',
rules: [{ field: 'isLivePhoto', op: 'isTrue' }],
}, false),
base('sys:panoramas', 'Panoramas', 'pano', {
match: 'all',
rules: [{ field: 'isPanorama', op: 'isTrue' }],
}, false),
];
}
/** Title-case a detected object label for display ("dining table" → "Dining Table"). */
function titleCase(s: string): string {
return s.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Auto-generate one live smart album per DETECTED OBJECT label — e.g. every photo
* containing a "chair" is grouped into a "Chair" album, "table" into "Table", etc.
* Membership is resolved live (via resolveSmartAlbum) as detection tags more photos;
* these are system albums (never persisted — regenerated on load).
*
* @param minCount only surface a label once it appears on at least this many photos.
*/
export function objectSmartAlbums(media: MediaItem[], now: number, minCount = 1): Album[] {
const counts = new Map<string, number>();
for (const m of media) {
if (m.deletedAt || m.hidden) continue;
// De-dupe labels within one item so a single photo counts once per label.
for (const label of new Set(m.objectLabels)) {
const key = label.trim().toLowerCase();
if (!key) continue;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
return [...counts.entries()]
.filter(([, n]) => n >= minCount)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([label]) => ({
id: `sys:obj:${label}`,
name: titleCase(label),
kind: 'smart' as const,
mediaIds: [],
ruleSet: { match: 'all', rules: [{ field: 'object', op: 'contains', value: label }] } as SmartRuleSet,
createdAt: now,
pinned: false,
system: true,
icon: 'tag',
}));
}
+28
View File
@@ -0,0 +1,28 @@
/** Hard cap on stored OCR text — prevents a pathological image from bloating
* the store / backend row (DoS) and keeps search tokenization cheap. */
export const OCR_TEXT_MAX_CHARS = 10_000;
// C0 control characters + DEL. Built from a string of \u escapes so the source
// stays pure ASCII. Replaced with spaces (then collapsed) so a newline between
// two words doesn't fuse them into a single search token.
const CONTROL_CHARS = new RegExp('[\\u0000-\\u001F\\u007F]', 'g');
/**
* Clean OCR output before it is stored or searched.
*
* OCR text is attacker-influencable (it comes from pixels inside an imported
* image), so we treat it as untrusted input at the write boundary: strip
* control characters, collapse whitespace into single spaces, and length-cap.
* No HTML stripping is required because the value is only ever rendered through
* React/textContent (never innerHTML) — but if a future search-highlight feature
* is added it MUST build nodes with createElement + textContent, never innerHTML.
*/
export function sanitizeOcrText(input: string): string {
const cleaned = input.replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
if (cleaned.length <= OCR_TEXT_MAX_CHARS) return cleaned;
// Truncate on a word boundary so a word straddling the cap isn't split into an
// unsearchable fragment (search does substring matching on whole tokens).
const cut = cleaned.slice(0, OCR_TEXT_MAX_CHARS);
const lastSpace = cut.lastIndexOf(' ');
return lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
}
+37
View File
@@ -0,0 +1,37 @@
import type { EditState } from '../types';
/**
* Human-readable audit log of what an edit changed — shown per version in the
* photo/video details and the Versions browser.
*/
export function summarizeEdits(edits?: EditState): string[] {
if (!edits) return ['Edited'];
const c: string[] = [];
if (edits.crop) c.push('Cropped');
if (edits.rotation) c.push(`Rotated ${edits.rotation}°`);
if (typeof edits.straighten === 'number' && Math.round(edits.straighten) !== 0)
c.push(`Straightened ${Math.round(edits.straighten)}°`);
if (edits.flipH) c.push('Flipped horizontally');
if (edits.flipV) c.push('Flipped vertically');
if (edits.filter) c.push(`Filter: ${edits.filter}`);
const adj = Object.entries(edits.adjustments ?? {}).filter(([, v]) => v).map(([k]) => k);
if (adj.length) c.push(`Adjusted ${adj.slice(0, 3).join(', ')}${adj.length > 3 ? '…' : ''}`);
const n = edits.annotations?.length ?? 0;
if (n) c.push(`${n} annotation${n === 1 ? '' : 's'}`);
// ---- video ----
const segCount = edits.segments?.length ?? 0;
if (segCount > 1) c.push(`Split into ${segCount} segments`);
else if (edits.trim || segCount === 1) c.push('Trimmed');
if (edits.segments?.some((s) => (s.speed ?? 1) !== 1)) c.push('Speed change');
const ovCount = (edits.overlays?.length ?? 0) + (edits.overlay ? 1 : 0);
if (ovCount) {
c.push(`${ovCount} overlay${ovCount === 1 ? '' : 's'}`);
if (edits.overlays?.some((o) => o.keyframes && o.keyframes.length > 1)) c.push('Keyframe animation');
if (edits.overlays?.some((o) => o.watermark)) c.push('Watermark');
if (edits.overlays?.some((o) => o.kind === 'text')) c.push('Text');
}
if (edits.audio?.muted) c.push('Muted original audio');
if (edits.audio?.musicSrc) c.push('Added music');
if (edits.audio?.fadeIn || edits.audio?.fadeOut) c.push('Audio fade');
return c.length ? c : ['Edited'];
}
+337
View File
@@ -0,0 +1,337 @@
import type { EditState } from '../types';
import { editFilterCss } from './edits';
import {
normalizeSegments,
outputDuration,
resolveOverlays,
sampleOverlay,
videoOutputSize,
} from './videoTimeline';
/**
* Bake a video's edits into a NEW clip — entirely in the browser, no server and
* no ffmpeg/SharedArrayBuffer (which would break the strict CSP).
*
* Timeline engine: ONE MediaRecorder runs continuously while an offscreen <video>
* is driven across the ordered keep-segments (seek → set playbackRate → play → draw
* until the segment's end → next). Because the recorder never stops between segments,
* they concatenate automatically. Each frame is drawn to a <canvas> applying crop +
* 90° rotation + flips + the CSS filter, then image/text overlays resolved at the
* current OUTPUT time (keyframe-interpolated), then legacy annotations. Audio (original
* ± music, with master fade in/out) is mixed through WebAudio into the recorded stream.
*
* Real-time: exporting N output-seconds takes ~N seconds. Progress is reported 0..1.
*/
export interface VideoBakeResult {
blob: Blob;
durationSec: number;
mime: string;
width: number;
height: number;
/** JPEG data URL poster grabbed from the chosen frame (edits.posterTime). */
poster?: string;
}
function pickMime(): string {
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
'video/mp4',
];
const MR = typeof MediaRecorder !== 'undefined' ? MediaRecorder : null;
for (const m of candidates) {
if (MR && MR.isTypeSupported(m)) return m;
}
return 'video/webm';
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
/** Rasterize a live annotations <svg> at the export resolution (vector → bitmap). */
function rasterizeSvg(svg: SVGSVGElement, w: number, h: number): Promise<HTMLImageElement> {
const clone = svg.cloneNode(true) as SVGSVGElement;
const vbW = svg.clientWidth || w;
const vbH = svg.clientHeight || h;
clone.setAttribute('viewBox', `0 0 ${vbW} ${vbH}`);
clone.setAttribute('width', String(w));
clone.setAttribute('height', String(h));
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const xml = new XMLSerializer().serializeToString(clone);
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
return loadImage(url);
}
const seekTo = (video: HTMLVideoElement, t: number) =>
new Promise<void>((res) => {
const done = () => {
video.removeEventListener('seeked', done);
res();
};
video.addEventListener('seeked', done);
// Nudge so a seek to the current time still fires 'seeked'.
video.currentTime = Math.max(0, t);
// Fallback in case 'seeked' never fires (some codecs at exact boundaries).
setTimeout(done, 600);
});
/** Map an output-timeline time to the corresponding SOURCE time (for poster grab). */
function outputToSourceTime(
segs: { start: number; end: number; speed?: number }[],
outT: number,
): number {
let acc = 0;
for (const s of segs) {
const d = (s.end - s.start) / (s.speed || 1);
if (outT <= acc + d) return s.start + (outT - acc) * (s.speed || 1);
acc += d;
}
const last = segs[segs.length - 1];
return last ? last.end : outT;
}
export interface VideoBakeOptions {
/** Live annotations SVG from the preview, rasterized over every frame. */
annotationsSvg?: SVGSVGElement | null;
onProgress?: (fraction: number) => void;
signal?: AbortSignal;
maxDim?: number;
}
export async function bakeVideo(
src: string,
edits: EditState,
opts: VideoBakeOptions = {},
): Promise<VideoBakeResult> {
const maxDim = edits.export?.maxDim ?? opts.maxDim ?? 1280;
const fps = edits.export?.fps ?? 30;
// 1) Offscreen source video (separate from the on-screen preview).
const video = document.createElement('video');
video.src = src;
video.crossOrigin = 'anonymous';
video.playsInline = true;
video.muted = true; // reliable autoplay; audio is tapped via WebAudio below
await new Promise<void>((res, rej) => {
video.onloadedmetadata = () => res();
video.onerror = () => rej(new Error('Could not load video for export.'));
});
const srcW = video.videoWidth || 1280;
const srcH = video.videoHeight || 720;
const totalDur = video.duration || 0;
// 2) Geometry (crop + 90° rotation + flips) → output canvas size.
const geo = videoOutputSize(srcW, srcH, edits, maxDim);
const { W, H, contentW, contentH, cropX, cropY, cropW, cropH, rot } = geo;
const flipH = !!edits.flipH;
const flipV = !!edits.flipV;
// 3) Timeline: ordered keep-segments + total output duration.
const segs = normalizeSegments(edits, totalDur);
const outDur = Math.max(0.1, outputDuration(segs));
// 4) Overlays: preload images; resolve legacy single overlay too.
const overlays = resolveOverlays(edits);
const imgMap = new Map<string, HTMLImageElement>();
await Promise.all(
overlays
.filter((o) => o.kind === 'image' && o.src)
.map(async (o) => {
const img = await loadImage(o.src!).catch(() => null);
if (img) imgMap.set(o.id, img);
}),
);
const annoImg = opts.annotationsSvg
? await rasterizeSvg(opts.annotationsSvg, W, H).catch(() => null)
: null;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d')!;
const filter = editFilterCss(edits) || 'none';
// 5) Audio graph: original (volume/mute) + music → master gain (fades) → stream.
const AC: typeof AudioContext =
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext ??
AudioContext;
const ac = new AC();
const dest = ac.createMediaStreamDestination();
const master = ac.createGain();
master.connect(dest);
let music: HTMLAudioElement | null = null;
try {
const vNode = ac.createMediaElementSource(video);
const vGain = ac.createGain();
vGain.gain.value = edits.audio?.muted ? 0 : (edits.audio?.originalVolume ?? 1);
vNode.connect(vGain).connect(master);
} catch {
/* element may have no audio track */
}
if (edits.audio?.musicSrc) {
music = new Audio(edits.audio.musicSrc);
music.crossOrigin = 'anonymous';
music.loop = true;
try {
const mNode = ac.createMediaElementSource(music);
const gain = ac.createGain();
gain.gain.value = edits.audio.musicVolume ?? 0.8;
mNode.connect(gain).connect(master);
} catch {
/* ignore */
}
}
// 6) Recorder over canvas video + mixed audio.
const stream = canvas.captureStream(fps);
const audioTrack = dest.stream.getAudioTracks()[0];
if (audioTrack) stream.addTrack(audioTrack);
const mime = pickMime();
const recorderOpts: MediaRecorderOptions = { mimeType: mime };
if (edits.export?.bitrate) recorderOpts.videoBitsPerSecond = edits.export.bitrate;
const recorder = new MediaRecorder(stream, recorderOpts);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
const stopped = new Promise<void>((res) => {
recorder.onstop = () => res();
});
// Per-frame compositor. `outClock` is the elapsed OUTPUT time (drives keyframes).
const drawFrame = (outClock: number, withOverlays = true) => {
ctx.clearRect(0, 0, W, H);
ctx.save();
ctx.translate(W / 2, H / 2);
if (rot) ctx.rotate((rot * Math.PI) / 180);
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
ctx.filter = filter;
ctx.drawImage(video, cropX, cropY, cropW, cropH, -contentW / 2, -contentH / 2, contentW, contentH);
ctx.filter = 'none';
ctx.restore();
if (withOverlays) {
for (const o of overlays) {
const s = sampleOverlay(o, outClock);
if (!s.visible || s.opacity <= 0) continue;
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, s.opacity));
if (o.kind === 'image') {
const img = imgMap.get(o.id);
if (img) {
const ow = W * s.scale;
const oh = ow * (img.height / Math.max(1, img.width));
ctx.translate(s.x * W + ow / 2, s.y * H + oh / 2);
if (s.rotation) ctx.rotate((s.rotation * Math.PI) / 180);
ctx.drawImage(img, -ow / 2, -oh / 2, ow, oh);
}
} else {
const fsize = Math.max(8, (o.fontSize ?? 0.08) * H);
ctx.font = `${o.bold ? '700 ' : ''}${fsize}px system-ui, -apple-system, sans-serif`;
ctx.textBaseline = 'top';
ctx.translate(s.x * W, s.y * H);
if (s.rotation) ctx.rotate((s.rotation * Math.PI) / 180);
ctx.lineWidth = Math.max(2, fsize * 0.12);
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.fillStyle = o.color ?? '#ffffff';
ctx.strokeText(o.text ?? '', 0, 0);
ctx.fillText(o.text ?? '', 0, 0);
}
ctx.restore();
}
}
if (annoImg) ctx.drawImage(annoImg, 0, 0, W, H);
};
// 7) Prime first segment, start recorder + audio + fades.
await seekTo(video, segs[0]!.start);
drawFrame(0);
recorder.start();
await ac.resume().catch(() => {});
const t0 = ac.currentTime;
const fadeIn = edits.audio?.fadeIn ?? 0;
const fadeOut = edits.audio?.fadeOut ?? 0;
master.gain.setValueAtTime(fadeIn > 0 ? 0.0001 : 1, t0);
if (fadeIn > 0) master.gain.linearRampToValueAtTime(1, t0 + Math.min(fadeIn, outDur));
if (fadeOut > 0) {
const fs = Math.max(t0 + fadeIn, t0 + outDur - fadeOut);
master.gain.setValueAtTime(1, fs);
master.gain.linearRampToValueAtTime(0.0001, t0 + outDur);
}
let aborted = false;
const onAbort = () => {
aborted = true;
};
opts.signal?.addEventListener('abort', onAbort);
// 8) Drive the offscreen video across every segment (continuous recording).
let baseOut = 0; // output seconds completed by prior segments
for (const seg of segs) {
if (aborted) break;
await seekTo(video, seg.start);
video.playbackRate = seg.speed || 1;
if (music && seg === segs[0]) await music.play().catch(() => {});
await video.play().catch(() => {});
const segOut = (seg.end - seg.start) / (seg.speed || 1);
// Guard against a stalled decoder (autoplay blocked, boundary glitch).
const deadline = performance.now() + (segOut + 4) * 1000;
await new Promise<void>((res) => {
const draw = () => {
if (
aborted ||
video.currentTime >= seg.end ||
video.ended ||
performance.now() > deadline
) {
res();
return;
}
const outClock = baseOut + (video.currentTime - seg.start) / (seg.speed || 1);
drawFrame(outClock);
opts.onProgress?.(Math.min(0.99, outClock / outDur));
requestAnimationFrame(draw);
};
requestAnimationFrame(draw);
});
video.pause();
baseOut += segOut;
}
music?.pause();
if (recorder.state !== 'inactive') recorder.stop();
await stopped;
opts.signal?.removeEventListener('abort', onAbort);
// 9) Poster frame (before closing the audio context / disposing the video).
let poster: string | undefined;
try {
const posterOut = Math.max(0, Math.min(outDur, edits.posterTime ?? 0));
await seekTo(video, outputToSourceTime(segs, posterOut));
drawFrame(posterOut);
const pscale = Math.min(1, 640 / Math.max(W, H));
const pc = document.createElement('canvas');
pc.width = Math.max(2, Math.round(W * pscale));
pc.height = Math.max(2, Math.round(H * pscale));
pc.getContext('2d')!.drawImage(canvas, 0, 0, pc.width, pc.height);
poster = pc.toDataURL('image/jpeg', 0.8);
} catch {
/* poster is best-effort */
}
await ac.close().catch(() => {});
opts.onProgress?.(1);
return { blob: new Blob(chunks, { type: mime }), durationSec: outDur, mime, width: W, height: H, poster };
}
+165
View File
@@ -0,0 +1,165 @@
import type { EditState, VideoOverlay, VideoSegment } from '../types';
/**
* Pure helpers shared by the video bake engine (lib/videoBake.ts) and the editor's
* live preview (VideoEditor.tsx), so the on-screen preview matches the export exactly.
*/
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** Resolve the ordered keep-segments (falls back to legacy trim, then the whole clip). */
export function normalizeSegments(edits: EditState, totalDur: number): VideoSegment[] {
const raw: VideoSegment[] =
edits.segments && edits.segments.length
? edits.segments
: edits.trim
? [{ id: 'legacy', start: edits.trim.start, end: edits.trim.end }]
: [{ id: 'full', start: 0, end: totalDur }];
const out = raw
.map((s) => ({
...s,
start: clamp(s.start, 0, totalDur),
end: clamp(s.end, 0, totalDur),
speed: s.speed && s.speed > 0 ? s.speed : 1,
}))
.filter((s) => s.end - s.start > 0.05);
return out.length ? out : [{ id: 'full', start: 0, end: totalDur, speed: 1 }];
}
/** Total duration of the exported clip (sum of segment durations ÷ their speed). */
export function outputDuration(segs: VideoSegment[]): number {
return segs.reduce((n, s) => n + (s.end - s.start) / (s.speed || 1), 0);
}
/** Map a SOURCE time to the OUTPUT-timeline time (for previewing keyframed overlays). */
export function sourceToOutputTime(segs: VideoSegment[], srcT: number): number {
let acc = 0;
for (const s of segs) {
const dur = (s.end - s.start) / (s.speed || 1);
if (srcT >= s.start && srcT <= s.end) return acc + (srcT - s.start) / (s.speed || 1);
if (srcT < s.start) return acc; // in a gap before this segment
acc += dur;
}
return acc;
}
export interface VideoOutputSize {
W: number;
H: number;
/** Content dims BEFORE the 90°/270° swap (how the frame is drawn pre-rotation). */
contentW: number;
contentH: number;
cropX: number;
cropY: number;
cropW: number;
cropH: number;
rot: number;
}
/** Compute the exported frame size from crop + 90° rotation, capped to `maxDim`. */
export function videoOutputSize(
srcW: number,
srcH: number,
edits: EditState,
maxDim: number,
): VideoOutputSize {
const crop = edits.crop;
const cropW = crop ? Math.max(1, Math.round(crop.width * srcW)) : srcW;
const cropH = crop ? Math.max(1, Math.round(crop.height * srcH)) : srcH;
const rot = ((((edits.rotation ?? 0) % 360) + 360) % 360);
const swap = rot === 90 || rot === 270;
const scale = Math.min(1, maxDim / Math.max(cropW, cropH));
const contentW = Math.max(2, Math.round(cropW * scale));
const contentH = Math.max(2, Math.round(cropH * scale));
return {
W: swap ? contentH : contentW,
H: swap ? contentW : contentH,
contentW,
contentH,
cropX: crop ? crop.x * srcW : 0,
cropY: crop ? crop.y * srcH : 0,
cropW,
cropH,
rot,
};
}
/** Merge the overlays list with any legacy single `overlay` (as a watermark). */
export function resolveOverlays(edits: EditState): VideoOverlay[] {
const list = [...(edits.overlays ?? [])];
if (edits.overlay?.src && !list.some((o) => o.src === edits.overlay!.src)) {
list.push({
id: 'legacy-overlay',
kind: 'image',
src: edits.overlay.src,
x: edits.overlay.x,
y: edits.overlay.y,
scale: edits.overlay.scale,
opacity: 1,
watermark: true,
});
}
return list;
}
export interface SampledOverlay {
x: number;
y: number;
scale: number;
rotation: number;
opacity: number;
visible: boolean;
}
/** Resolve an overlay's transform at output-time `t` by interpolating its keyframes. */
export function sampleOverlay(o: VideoOverlay, t: number): SampledOverlay {
const base = {
x: o.x,
y: o.y,
scale: o.scale,
rotation: o.rotation ?? 0,
opacity: o.opacity ?? 1,
};
const inT = o.in ?? -Infinity;
const outT = o.out ?? Infinity;
const visible = t >= inT && t <= outT;
const kfs = o.keyframes;
if (!kfs || kfs.length === 0) return { ...base, visible };
const sorted = [...kfs].sort((a, b) => a.t - b.t);
let prev: (typeof sorted)[number] | null = null;
let next: (typeof sorted)[number] | null = null;
for (const k of sorted) {
if (k.t <= t) prev = k;
if (k.t >= t && !next) next = k;
}
const pick = (k: (typeof sorted)[number], key: 'x' | 'y' | 'scale' | 'rotation' | 'opacity') =>
k[key] ?? base[key];
if (prev && next && prev !== next) {
const span = next.t - prev.t || 1;
const f = (t - prev.t) / span;
const lerp = (key: 'x' | 'y' | 'scale' | 'rotation' | 'opacity') => {
const a = pick(prev!, key);
const b = pick(next!, key);
return a + (b - a) * f;
};
return {
x: lerp('x'),
y: lerp('y'),
scale: lerp('scale'),
rotation: lerp('rotation'),
opacity: lerp('opacity'),
visible,
};
}
const k = prev ?? next!;
return {
x: pick(k, 'x'),
y: pick(k, 'y'),
scale: pick(k, 'scale'),
rotation: pick(k, 'rotation'),
opacity: pick(k, 'opacity'),
visible,
};
}