8c6e7930db
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:
cd docker && cp .env.example .env && docker compose up -d --build
Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.
The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.
Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.
Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
90 lines
3.8 KiB
TypeScript
90 lines
3.8 KiB
TypeScript
import { Skia, type SkImage } from '@shopify/react-native-skia';
|
|
|
|
// Turn a decoded image 90deg clockwise into a fresh SkImage. Used by the FRAME
|
|
// tab so a library photo can go landscape <-> portrait before it enters a frame:
|
|
// every consumer reads width()/height(), so the pixel-dimension swap carries the
|
|
// new orientation through the polaroid window aspect, the cover crop and the
|
|
// watermark areas without a single extra layout branch.
|
|
// ponytail: offscreen surface, same fallback as exportEngine.createSurface; a
|
|
// matrix-based draw would avoid the copy if this ever shows up in a profile.
|
|
export function rotateSkImage90(image: SkImage | null): SkImage | null {
|
|
if (!image) return null;
|
|
const w = image.width();
|
|
const h = image.height();
|
|
if (!(w > 0 && h > 0)) return null;
|
|
// CPU-backed on purpose: a GPU (MakeOffscreen) snapshot keeps its pixels in a
|
|
// context texture that is gone by the time the <Canvas> draws a frame later —
|
|
// the preview then renders the photo completely black. Raster pixels survive
|
|
// the trip to the render thread.
|
|
const surface = Skia.Surface.Make(h, w);
|
|
if (!surface) return image; // keep the unrotated photo on allocation failure
|
|
try {
|
|
const canvas = surface.getCanvas();
|
|
canvas.clear(Skia.Color('transparent'));
|
|
canvas.translate(h, 0);
|
|
canvas.rotate(90, 0, 0);
|
|
canvas.drawImage(image, 0, 0);
|
|
return surface.makeImageSnapshot() ?? image;
|
|
} finally {
|
|
// The raster snapshot refcounts the pixels, so the surface can go now
|
|
// instead of leaving one full-size copy behind per rotation.
|
|
surface.dispose();
|
|
}
|
|
}
|
|
|
|
// Fine rotation about the centre (the FRAME tab's STRAIGHTEN), keeping the
|
|
// original pixel dimensions: the content is scaled up just enough that the
|
|
// rotated rectangle still covers the frame, so no empty corner shows. The
|
|
// scale is the axis-aligned wrap of the counter-rotated frame — slightly
|
|
// conservative, which crops a hair more rather than leaking a corner.
|
|
// ponytail: one offscreen surface per call, same trade as rotateSkImage90.
|
|
export function rotateSkImageBy(image: SkImage | null, degrees: number): SkImage | null {
|
|
if (!image) return null;
|
|
const w = image.width();
|
|
const h = image.height();
|
|
if (!(w > 0 && h > 0)) return null;
|
|
if (!degrees) return image;
|
|
const rad = (degrees * Math.PI) / 180;
|
|
const cos = Math.abs(Math.cos(rad));
|
|
const sin = Math.abs(Math.sin(rad));
|
|
const k = Math.max((w * cos + h * sin) / w, (w * sin + h * cos) / h);
|
|
const surface = Skia.Surface.Make(w, h);
|
|
if (!surface) return image; // keep the unrotated photo on allocation failure
|
|
try {
|
|
const canvas = surface.getCanvas();
|
|
canvas.clear(Skia.Color('transparent'));
|
|
canvas.translate(w / 2, h / 2);
|
|
canvas.rotate(degrees, 0, 0);
|
|
canvas.scale(k, k);
|
|
canvas.drawImage(image, -w / 2, -h / 2);
|
|
return surface.makeImageSnapshot() ?? image;
|
|
} finally {
|
|
surface.dispose(); // see rotateSkImage90
|
|
}
|
|
}
|
|
|
|
// The whole FRAME-tab photo orientation in one call: the quarter turn first
|
|
// (rotateSkImage90 swaps the pixel dimensions, which then propagates through
|
|
// every consumer unchanged), then the fine STRAIGHTEN on top of it.
|
|
export function applyPhotoRotation(
|
|
image: SkImage | null,
|
|
quarter = 0,
|
|
straighten = 0
|
|
): SkImage | null {
|
|
let out = image;
|
|
const turns = Math.round(quarter / 90) % 4;
|
|
for (let i = 0; i < turns; i++) {
|
|
const next = rotateSkImage90(out);
|
|
// A 180/270 turn allocates one full-size copy per turn: the intermediate is
|
|
// dead as soon as the next one exists (never the caller's input image).
|
|
if (next && next !== out && i > 0) out.dispose();
|
|
out = next ?? out;
|
|
}
|
|
if (straighten) {
|
|
const s = rotateSkImageBy(out, straighten);
|
|
if (s && s !== out && out !== image) out.dispose(); // see above
|
|
out = s ?? out;
|
|
}
|
|
return out;
|
|
}
|