Start work on potential browser-based display like mcopy has, not a priority.

This commit is contained in:
mattmcw 2026-04-12 14:35:09 -04:00
parent 8547e905da
commit ad97a94c0e
8 changed files with 718 additions and 0 deletions

369
browser/display.ts Normal file
View File

@ -0,0 +1,369 @@
/**
* FullscreenDisplay
*
* Takes over the screen of an iOS device running Safari.
* - Requests fullscreen / uses iOS standalone-mode tricks
* - Creates a canvas sized to the true physical pixel resolution
* (CSS pixels × devicePixelRatio)
* - Default state is solid black
* - Acquires a Screen Wake Lock so the display never sleeps while active;
* automatically re-acquires the lock if the page is briefly hidden then
* shown again (iOS releases wake locks on page hide)
* - display(image, ms) renders an HTMLImageElement for exactly `ms`
* milliseconds, then reverts to black, using requestAnimationFrame
* timestamps for sub-frame accuracy
*/
export class FullscreenDisplay {
// ─── Public readonly state ───────────────────────────────────────────────
/** The backing <canvas> element. */
public readonly canvas: HTMLCanvasElement;
/** Physical pixel width (CSS width × devicePixelRatio). */
public readonly physicalWidth: number;
/** Physical pixel height (CSS height × devicePixelRatio). */
public readonly physicalHeight: number;
/** Device pixel ratio detected at construction time. */
public readonly devicePixelRatio: number;
// ─── Private state ───────────────────────────────────────────────────────
private readonly ctx: CanvasRenderingContext2D;
/** Overlay <div> that acts as the fullscreen root. */
private readonly overlay: HTMLDivElement;
/** rAF handle so we can cancel an in-flight display call. */
private rafHandle: number | null = null;
/** Resolves the Promise returned by the active display() call. */
private displayResolve: (() => void) | null = null;
/**
* Active Screen Wake Lock sentinel.
* Typed loosely because lib.dom.d.ts only gained WakeLockSentinel
* in TypeScript 4.4+; the cast below keeps older configs happy.
*/
private wakeLock: WakeLockSentinel | null = null;
// ─── Construction ─────────────────────────────────────────────────────────
constructor() {
// ── 1. Determine true screen density ──────────────────────────────────
//
// window.devicePixelRatio is the authoritative source on iOS Safari.
// Common values: 2× (most iPhones / standard iPads), 3× (Pro iPhones).
this.devicePixelRatio = window.devicePixelRatio ?? 1;
// ── 2. Build fullscreen overlay ────────────────────────────────────────
this.overlay = document.createElement("div");
const overlayStyle = this.overlay.style;
overlayStyle.position = "fixed";
overlayStyle.inset = "0"; // top/right/bottom/left: 0
overlayStyle.width = "100%";
overlayStyle.height = "100%";
overlayStyle.margin = "0";
overlayStyle.padding = "0";
overlayStyle.backgroundColor = "#000";
overlayStyle.zIndex = "2147483647"; // max z-index
// Prevent any rubber-band / scroll bleed-through on iOS
overlayStyle.overflow = "hidden";
overlayStyle.touchAction = "none";
// GPU-composited layer avoids repaint on canvas updates
overlayStyle.transform = "translateZ(0)";
overlayStyle.webkitTransform = "translateZ(0)";
document.body.appendChild(this.overlay);
// ── 3. Size the canvas to physical pixels ──────────────────────────────
//
// On iOS, window.innerWidth/Height gives the CSS pixel viewport after
// the browser chrome has been accounted for. Multiply by DPR to get
// the true raster resolution.
const cssWidth = window.innerWidth;
const cssHeight = window.innerHeight;
this.physicalWidth = Math.round(cssWidth * this.devicePixelRatio);
this.physicalHeight = Math.round(cssHeight * this.devicePixelRatio);
this.canvas = document.createElement("canvas");
this.canvas.width = this.physicalWidth;
this.canvas.height = this.physicalHeight;
const canvasStyle = this.canvas.style;
canvasStyle.display = "block";
canvasStyle.width = `${cssWidth}px`; // CSS size = viewport
canvasStyle.height = `${cssHeight}px`;
canvasStyle.backgroundColor = "#000";
this.overlay.appendChild(this.canvas);
// ── 4. Grab 2-D context with best quality hints ────────────────────────
const ctx = this.canvas.getContext("2d", {
alpha: false, // opaque → faster compositing
desynchronized: false, // keep in sync with display
});
if (ctx === null) {
throw new Error("FullscreenDisplay: could not acquire 2D canvas context.");
}
this.ctx = ctx;
// Scale once so every draw call works in physical pixels automatically.
// (We do NOT use CSS transform scaling so image quality is always 1:1.)
this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
// ── 5. Paint initial black frame ───────────────────────────────────────
this.paintBlack();
// ── 6. Attempt fullscreen (Web Fullscreen API) ─────────────────────────
//
// iOS 16.4+ supports Element.requestFullscreen() on Safari.
// Older versions ignore it silently; the fixed overlay still covers
// the screen, so we degrade gracefully.
this.requestNativeFullscreen();
// ── 7. Listen for resize (orientation change, split-view, etc.) ────────
window.addEventListener("resize", this.handleResize);
// ── 8. Acquire Screen Wake Lock ────────────────────────────────────────
//
// Prevents iOS from dimming or auto-locking the screen.
// Supported in Safari 16.4+; silently no-ops on older versions.
// The lock is automatically released by the browser whenever the page
// is hidden (e.g. user switches apps), so we re-acquire it on
// visibilitychange.
this.acquireWakeLock();
document.addEventListener("visibilitychange", this.handleVisibilityChange);
}
// ─── Public API ───────────────────────────────────────────────────────────
/**
* Display `image` for exactly `durationMs` milliseconds, then go back to
* black. Returns a Promise that resolves when the black frame is shown.
*
* If called while a previous display() is still running, the previous one
* is cancelled immediately (image removed, black shown) before the new
* one starts.
*
* @param image A fully-loaded HTMLImageElement.
* @param durationMs How long (in milliseconds) to show the image.
*/
public display(image: HTMLImageElement, durationMs: number): Promise<void> {
// Cancel any in-flight display.
this.cancelCurrentDisplay();
return new Promise<void>((resolve) => {
this.displayResolve = resolve;
// We capture the rAF timestamp on the very first paint frame to use
// as the precise start time, avoiding any lag between Promise
// construction and actual screen output.
let startTime: number | null = null;
const frame = (timestamp: DOMHighResTimeStamp): void => {
// ── First frame: record start, draw image ────────────────────────
if (startTime === null) {
startTime = timestamp;
this.paintImage(image);
}
const elapsed = timestamp - startTime;
if (elapsed < durationMs) {
// Still within the window schedule next check.
this.rafHandle = requestAnimationFrame(frame);
} else {
// Duration expired go back to black and resolve.
this.paintBlack();
this.rafHandle = null;
this.displayResolve = null;
resolve();
}
};
this.rafHandle = requestAnimationFrame(frame);
});
}
/**
* Manually cancel any active display() and return to black immediately.
*/
public cancelCurrentDisplay(): void {
if (this.rafHandle !== null) {
cancelAnimationFrame(this.rafHandle);
this.rafHandle = null;
}
if (this.displayResolve !== null) {
this.paintBlack();
const resolve = this.displayResolve;
this.displayResolve = null;
resolve();
}
}
/**
* Tear down the overlay and release all resources.
* Cancels any running display() first.
*/
public destroy(): void {
this.cancelCurrentDisplay();
window.removeEventListener("resize", this.handleResize);
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
this.releaseWakeLock();
if (document.fullscreenElement === this.overlay) {
document.exitFullscreen().catch(() => {/* ignore */});
}
this.overlay.remove();
}
// ─── Private helpers ──────────────────────────────────────────────────────
/** Fill the entire canvas with solid black. */
private paintBlack(): void {
// Reset transform before raw pixel fill to avoid double-scaling.
this.ctx.save();
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.fillStyle = "#000000";
this.ctx.fillRect(0, 0, this.physicalWidth, this.physicalHeight);
this.ctx.restore();
}
/**
* Draw `image` scaled to fill the canvas while preserving aspect ratio
* (object-fit: contain semantics, centred, letterboxed with black).
*/
private paintImage(image: HTMLImageElement): void {
const cssWidth = window.innerWidth;
const cssHeight = window.innerHeight;
const imgW = image.naturalWidth || image.width;
const imgH = image.naturalHeight || image.height;
if (imgW === 0 || imgH === 0) {
// Malformed image just show black.
this.paintBlack();
return;
}
const scale = Math.min(cssWidth / imgW, cssHeight / imgH);
const drawW = imgW * scale;
const drawH = imgH * scale;
const drawX = (cssWidth - drawW) / 2;
const drawY = (cssHeight - drawH) / 2;
// Background black (letterbox / pillarbox areas).
this.paintBlack();
// The canvas context was scaled by DPR at construction, so all
// drawImage coordinates here are in CSS pixels which is exactly
// what we want.
this.ctx.drawImage(image, drawX, drawY, drawW, drawH);
}
/** Attempt the Web Fullscreen API; silently no-op if unavailable. */
private requestNativeFullscreen(): void {
const el = this.overlay as HTMLDivElement & {
webkitRequestFullscreen?: () => Promise<void>;
};
if (typeof el.requestFullscreen === "function") {
el.requestFullscreen({ navigationUI: "hide" }).catch(() => {/* ok */});
} else if (typeof el.webkitRequestFullscreen === "function") {
// Older Safari / WebKit prefix
el.webkitRequestFullscreen();
}
}
// ─── Wake Lock helpers ────────────────────────────────────────────────────
/**
* Request a 'screen' wake lock via the Screen Wake Lock API.
* Fails silently if the API is unavailable or the request is rejected
* (e.g. Low Power Mode on iOS).
*/
private acquireWakeLock(): void {
if (!("wakeLock" in navigator)) return;
(navigator.wakeLock as WakeLock)
.request("screen")
.then((sentinel: WakeLockSentinel) => {
this.wakeLock = sentinel;
// If the sentinel is released externally (browser-initiated),
// clear our reference so we know to re-acquire on next
// visibilitychange.
sentinel.addEventListener("release", () => {
if (this.wakeLock === sentinel) {
this.wakeLock = null;
}
});
})
.catch(() => {
// Permission denied, Low Power Mode, or API unavailable no-op.
});
}
/** Release the active wake lock, if any. */
private releaseWakeLock(): void {
if (this.wakeLock !== null) {
this.wakeLock.release().catch(() => {/* ignore */});
this.wakeLock = null;
}
}
/**
* Re-acquire the wake lock when the page returns to the foreground.
* iOS releases all wake locks the moment the page is hidden, so this
* is required to keep the screen alive after the user briefly switches
* away and comes back.
*
* Arrow function so `this` is correctly bound as an event listener.
*/
private readonly handleVisibilityChange = (): void => {
if (document.visibilityState === "visible") {
this.acquireWakeLock();
}
};
// ─── Resize helper ────────────────────────────────────────────────────────
/**
* Resizes the canvas backing store and repaints black.
*
* Arrow function so `this` is always bound correctly as an event listener.
*/
private readonly handleResize = (): void => {
const cssWidth = window.innerWidth;
const cssHeight = window.innerHeight;
const dpr = window.devicePixelRatio ?? 1;
const newPhysW = Math.round(cssWidth * dpr);
const newPhysH = Math.round(cssHeight * dpr);
// Mutating readonly at runtime use type assertion.
(this as { physicalWidth: number }).physicalWidth = newPhysW;
(this as { physicalHeight: number }).physicalHeight = newPhysH;
this.canvas.width = newPhysW;
this.canvas.height = newPhysH;
this.canvas.style.width = `${cssWidth}px`;
this.canvas.style.height = `${cssHeight}px`;
// Re-apply the DPR scale after canvas resize (resizing resets transform).
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.scale(dpr, dpr);
this.paintBlack();
};
}

19
notes/index.html Normal file
View File

@ -0,0 +1,19 @@
<html>
<body>
<button id="goFullscreen">Go Fullscreen</button>
<script>
document.getElementById('goFullscreen').addEventListener('click', () => {
// 2. Request Fullscreen
document.documentElement.requestFullscreen().then(() => {
// 3. Get Resolution after entering fullscreen
const width = window.screen.width;
const height = window.screen.height;
console.log(`Resolution: ${width}x${height}`); // [1, 10]
}).catch((err) => {
console.error(`Error: ${err.message}`);
});
});
</script>
</body>
</html>

117
notes/tests_to_perform.md Normal file
View File

@ -0,0 +1,117 @@
# Tests To Perform
This is a brain dump of all things that should be tested with this system and make it the highest quality it can be for the price.
### Stocks
Print and reversal stocks to project.
* 3302 - 2 - Orthochromatic
* 3383 - 3 - Balance with RGB exposures
* 3378E - 6-25
* 7294 - 100 (?)
* 7266 - 200
Negative stocks to be used as a kind of internegative.
* 7203 - 50
* 7207 - 250
* 7213 - 200 - Tungsten
* 7219 - 500 - Tungsten
* 7222 - 200
### Factors
These are the different factors that can be controlled for in individual tests.
Combining multiple tests that do not impact or influence one another
* Lens Distance x Image Size
* Exposure
* Density
* Contrast
* Color balance
* Screens (finishes/resolution/technologies)
* Saturation control
* Resolution (screen)
* Resolution (lens)
* Focus
* Glare
* Speed
#### Exposure
Track exposure data by printing full-frames of colors or values and then have a corresponding set of video analysis tests in place.
Factors to track in QR code/datamatrix as a single-line csv.
Should be bookended with a single code containing the constants throughout.
* Identifier ("FO" for filmout)
* Const Identifier ("CONST")
* Test Name
* Test ID
* Frame number (this one)
* Screen
* Brightness of screen (nits)
* Image Resolution
* Filmstock
* ISO (as rated for test)
* F/stop
* Lens
* Distance
* Process (D-19/D-96/C-41/ECN-2/ECP-2/D-19BWR/D-96BWR/E-6)
* Lab
```
FO,CONST,INITIALDENSITY,3378ED19@6,00003,MacBookPro,1000,1440x1080,3378E,12,1000,5.6,50,35000,D-19@6,MONO
```
Factors to track per-frame.
* Identifier ("FO")
* Frame number (of sample data frame)
* Exposure
* Filename
* Notes
```
FO,00005,1000,example.png,Measure_density
```
Output to track:
* Brightness (Calculated)
* Brightness (Perceptual)
* Min/Max (Avg,R,G,B)
* Density (Grayscale)
* Histogram (R,G,B)
* Histogram (Total)
* Histogram (Perceptual)
### Processing Results
Scan to full frame ("Overscan") at 6.5K in .dpx.
Once a test is processed, export frames to a ProRes 444 HQ proxy.
Scans are flat, log and frame-to-frame accurate.
Trim the fat.
Run the tests again on the proxy to determine if there would be any differences in the results.
Move to only ProRes 444 if no difference.
Measure density with X-Rite portable unit.
Log using a tool, preferably
Use scripts to determine frame dimensions within image.
### ETC
Not sure where to log this, but each stock needs a "best guess" setting for producing a readable QR code or datamatrix between each test frame.
Doing this properly will allow for valuable test data be printed between each test frame as a QR code or datamatrix and parsed to process the frame.
Need to create a process for reading the data as part of the data analysis.
Threshold the image in 5 different ways and calculate

View File

@ -0,0 +1,3 @@
#!/bin/bash
system_profiler SPDisplaysDataType

22
static/js/display.d.ts vendored Normal file
View File

@ -0,0 +1,22 @@
export declare class FullscreenDisplay {
readonly canvas: HTMLCanvasElement;
readonly physicalWidth: number;
readonly physicalHeight: number;
readonly devicePixelRatio: number;
private readonly ctx;
private readonly overlay;
private rafHandle;
private displayResolve;
private wakeLock;
constructor();
display(image: HTMLImageElement, durationMs: number): Promise<void>;
cancelCurrentDisplay(): void;
destroy(): void;
private paintBlack;
private paintImage;
private requestNativeFullscreen;
private acquireWakeLock;
private releaseWakeLock;
private readonly handleVisibilityChange;
private readonly handleResize;
}

179
static/js/display.js Normal file
View File

@ -0,0 +1,179 @@
define(["require", "exports"], function (require, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FullscreenDisplay = void 0;
class FullscreenDisplay {
constructor() {
this.rafHandle = null;
this.displayResolve = null;
this.wakeLock = null;
this.handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
this.acquireWakeLock();
}
};
this.handleResize = () => {
const cssWidth = window.innerWidth;
const cssHeight = window.innerHeight;
const dpr = window.devicePixelRatio ?? 1;
const newPhysW = Math.round(cssWidth * dpr);
const newPhysH = Math.round(cssHeight * dpr);
this.physicalWidth = newPhysW;
this.physicalHeight = newPhysH;
this.canvas.width = newPhysW;
this.canvas.height = newPhysH;
this.canvas.style.width = `${cssWidth}px`;
this.canvas.style.height = `${cssHeight}px`;
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.scale(dpr, dpr);
this.paintBlack();
};
this.devicePixelRatio = window.devicePixelRatio ?? 1;
this.overlay = document.createElement("div");
const overlayStyle = this.overlay.style;
overlayStyle.position = "fixed";
overlayStyle.inset = "0";
overlayStyle.width = "100%";
overlayStyle.height = "100%";
overlayStyle.margin = "0";
overlayStyle.padding = "0";
overlayStyle.backgroundColor = "#000";
overlayStyle.zIndex = "2147483647";
overlayStyle.overflow = "hidden";
overlayStyle.touchAction = "none";
overlayStyle.transform = "translateZ(0)";
overlayStyle.webkitTransform = "translateZ(0)";
document.body.appendChild(this.overlay);
const cssWidth = window.innerWidth;
const cssHeight = window.innerHeight;
this.physicalWidth = Math.round(cssWidth * this.devicePixelRatio);
this.physicalHeight = Math.round(cssHeight * this.devicePixelRatio);
this.canvas = document.createElement("canvas");
this.canvas.width = this.physicalWidth;
this.canvas.height = this.physicalHeight;
const canvasStyle = this.canvas.style;
canvasStyle.display = "block";
canvasStyle.width = `${cssWidth}px`;
canvasStyle.height = `${cssHeight}px`;
canvasStyle.backgroundColor = "#000";
this.overlay.appendChild(this.canvas);
const ctx = this.canvas.getContext("2d", {
alpha: false,
desynchronized: false,
});
if (ctx === null) {
throw new Error("FullscreenDisplay: could not acquire 2D canvas context.");
}
this.ctx = ctx;
this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
this.paintBlack();
this.requestNativeFullscreen();
window.addEventListener("resize", this.handleResize);
this.acquireWakeLock();
document.addEventListener("visibilitychange", this.handleVisibilityChange);
}
display(image, durationMs) {
this.cancelCurrentDisplay();
return new Promise((resolve) => {
this.displayResolve = resolve;
let startTime = null;
const frame = (timestamp) => {
if (startTime === null) {
startTime = timestamp;
this.paintImage(image);
}
const elapsed = timestamp - startTime;
if (elapsed < durationMs) {
this.rafHandle = requestAnimationFrame(frame);
}
else {
this.paintBlack();
this.rafHandle = null;
this.displayResolve = null;
resolve();
}
};
this.rafHandle = requestAnimationFrame(frame);
});
}
cancelCurrentDisplay() {
if (this.rafHandle !== null) {
cancelAnimationFrame(this.rafHandle);
this.rafHandle = null;
}
if (this.displayResolve !== null) {
this.paintBlack();
const resolve = this.displayResolve;
this.displayResolve = null;
resolve();
}
}
destroy() {
this.cancelCurrentDisplay();
window.removeEventListener("resize", this.handleResize);
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
this.releaseWakeLock();
if (document.fullscreenElement === this.overlay) {
document.exitFullscreen().catch(() => { });
}
this.overlay.remove();
}
paintBlack() {
this.ctx.save();
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.fillStyle = "#000000";
this.ctx.fillRect(0, 0, this.physicalWidth, this.physicalHeight);
this.ctx.restore();
}
paintImage(image) {
const cssWidth = window.innerWidth;
const cssHeight = window.innerHeight;
const imgW = image.naturalWidth || image.width;
const imgH = image.naturalHeight || image.height;
if (imgW === 0 || imgH === 0) {
this.paintBlack();
return;
}
const scale = Math.min(cssWidth / imgW, cssHeight / imgH);
const drawW = imgW * scale;
const drawH = imgH * scale;
const drawX = (cssWidth - drawW) / 2;
const drawY = (cssHeight - drawH) / 2;
this.paintBlack();
this.ctx.drawImage(image, drawX, drawY, drawW, drawH);
}
requestNativeFullscreen() {
const el = this.overlay;
if (typeof el.requestFullscreen === "function") {
el.requestFullscreen({ navigationUI: "hide" }).catch(() => { });
}
else if (typeof el.webkitRequestFullscreen === "function") {
el.webkitRequestFullscreen();
}
}
acquireWakeLock() {
if (!("wakeLock" in navigator))
return;
navigator.wakeLock
.request("screen")
.then((sentinel) => {
this.wakeLock = sentinel;
sentinel.addEventListener("release", () => {
if (this.wakeLock === sentinel) {
this.wakeLock = null;
}
});
})
.catch(() => {
});
}
releaseWakeLock() {
if (this.wakeLock !== null) {
this.wakeLock.release().catch(() => { });
this.wakeLock = null;
}
}
}
exports.FullscreenDisplay = FullscreenDisplay;
});
//# sourceMappingURL=display.js.map

1
static/js/display.js.map Normal file

File diff suppressed because one or more lines are too long

8
views/screen.handlebars Normal file
View File

@ -0,0 +1,8 @@
<!doctype html>
<html>
<head>
<title>Screen</title>
</head>
<body>
<script src="/static/js/display.js"></script>
</html>