Add Live Photo playback for paired MOV/MP4/M4V videos and embedded Google and Samsung JPEG clips, with hover/tap controls, viewer replay, and original-video range and cache responses. Show top-level albums and direct subfolders with breadcrumb navigation and nested album search. Add browser regression fixtures and update album navigation checks. Bump Rust and frontend versions and their lockfiles to 0.2.7. Validated: frontend and Rust builds, 121 Rust tests (2 network tests not run), 198 layout cases, and Chromium live-photo and album navigation checks.
68 lines
3.0 KiB
JavaScript
68 lines
3.0 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFile } from "node:fs/promises";
|
|
import { basename } from "node:path";
|
|
import { chromium, webkit } from "playwright-core";
|
|
|
|
export const engine = process.argv.includes("--webkit") || process.env.PIXHELF_TEST_ENGINE === "webkit" ? webkit : chromium;
|
|
export const isChromium = engine === chromium;
|
|
|
|
export function launchBrowser() {
|
|
return engine.launch(isChromium ? {
|
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? chromium.executablePath(),
|
|
headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
} : { headless: true });
|
|
}
|
|
|
|
export async function loadSample() {
|
|
const source = process.env.PIXHELF_LIVE_SAMPLE;
|
|
const [poster, pairedClip] = await Promise.all([
|
|
readFile(source ? `${source}.jpg` : new URL("fixtures/live-photo.jpg", import.meta.url)),
|
|
readFile(source ? `${source}.mov` : new URL("fixtures/live-photo.mp4", import.meta.url))
|
|
.catch(error => { if (source && error.code === "ENOENT") return null; throw error; }),
|
|
]);
|
|
return {
|
|
name: source ? basename(source) : "fixture", poster,
|
|
clip: pairedClip ?? samsungClip(poster),
|
|
contentType: source && pairedClip ? "video/quicktime" : "video/mp4",
|
|
};
|
|
}
|
|
|
|
function samsungClip(jpeg) {
|
|
assert.equal(jpeg.subarray(-4).toString(), "SEFT", "sample needs a paired MOV or embedded Samsung clip");
|
|
const index = jpeg.length - 8 - jpeg.readUInt32LE(jpeg.length - 8);
|
|
assert.equal(jpeg.subarray(index, index + 4).toString(), "SEFH");
|
|
const count = jpeg.readUInt32LE(index + 8);
|
|
for (let i = 0; i < count; i++) {
|
|
const entry = index + 12 + i * 12;
|
|
if (jpeg.readUInt16LE(entry + 2) !== 0x0a30) continue;
|
|
let start = index - jpeg.readUInt32LE(entry + 4) + 24;
|
|
let length = jpeg.readUInt32LE(entry + 8) - 24;
|
|
if (jpeg.subarray(start, start + 4).toString() === "mpv2") {
|
|
[start, length] = [jpeg.readUInt32BE(start + 4), jpeg.readUInt32BE(start + 8)];
|
|
}
|
|
assert.ok(start > 0 && length > 0 && start + length <= index);
|
|
return jpeg.subarray(start, start + length);
|
|
}
|
|
throw new Error("No Samsung motion clip found");
|
|
}
|
|
|
|
export function serveMotion(route, { clip, contentType }) {
|
|
const range = route.request().headers().range?.match(/^bytes=(\d+)-(\d*)$/);
|
|
const start = range ? Number(range[1]) : 0;
|
|
const end = range?.[2] ? Math.min(Number(range[2]), clip.length - 1) : clip.length - 1;
|
|
return route.fulfill({ status: range ? 206 : 200, body: clip.subarray(start, end + 1), headers: {
|
|
"content-type": contentType, "accept-ranges": "bytes",
|
|
"cache-control": "private, max-age=31536000, immutable", "etag": '"original-motion-v2-fixture"',
|
|
...(range ? { "content-range": `bytes ${start}-${end}/${clip.length}` } : {}),
|
|
} });
|
|
}
|
|
|
|
export function imageDimensions(page, poster) {
|
|
return page.evaluate(async base64 => {
|
|
const image = new Image();
|
|
image.src = `data:image/jpeg;base64,${base64}`;
|
|
await image.decode();
|
|
return { width: image.naturalWidth, height: image.naturalHeight };
|
|
}, poster.toString("base64"));
|
|
}
|