diff --git a/docker/frontend/public/assets/old_film.png b/docker/frontend/public/assets/old_film.png new file mode 100644 index 0000000..53198df Binary files /dev/null and b/docker/frontend/public/assets/old_film.png differ diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index f81e926..952be8f 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -4,7 +4,9 @@ export type FrameId = | 'classic-white' | 'cinematic' | 'wallframe' // WALL FRAME PORTRAIT — the artwork hung 90° CW - | 'wallframe-landscape'; // the same artwork upright: WALL FRAME LANDSCAPE + | 'wallframe-landscape' // the same artwork upright: WALL FRAME LANDSCAPE + | 'old-film' // OLD FILM LANDSCAPE — the distressed sheet as it is + | 'old-film-portrait'; // OLD FILM PORTRAIT — the same sheet turned 90° CW // Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT // (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing diff --git a/docker/frontend/shared/utils/recipeShare.ts b/docker/frontend/shared/utils/recipeShare.ts index 471b65a..6855b35 100644 --- a/docker/frontend/shared/utils/recipeShare.ts +++ b/docker/frontend/shared/utils/recipeShare.ts @@ -13,7 +13,10 @@ const KEY = 'RecipesCam::recipe-share::v1'; const ALGORITHM = 'xor16-v1'; const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none']; -const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe', 'wallframe-landscape']; +const FRAMES: FrameId[] = [ + 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe', 'wallframe-landscape', + 'old-film', 'old-film-portrait', +]; const hash = (s: string): number => { let h = 0x811c9dc5; diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 80bef23..2e32a29 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -111,6 +111,8 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [ { id: 'cinematic', label: 'frame.cinematic' }, { id: 'wallframe', label: 'frame.wall' }, { id: 'wallframe-landscape', label: 'frame.wallLand' }, + { id: 'old-film', label: 'frame.oldFilm' }, + { id: 'old-film-portrait', label: 'frame.oldFilmPort' }, ]; // The recipe a saved photo's base is rendered through: the neutral stock — diff --git a/docker/frontend/src/engine/assets.ts b/docker/frontend/src/engine/assets.ts index ce45ac3..483ee78 100644 --- a/docker/frontend/src/engine/assets.ts +++ b/docker/frontend/src/engine/assets.ts @@ -34,13 +34,20 @@ export function probeGpu(): boolean { export async function loadEngine(): Promise<{ assets: RenderAssets; gpu: boolean }> { await initSkia({ locateFile: (file) => `/wasm/${file}` }); if (!assets) { - const [textFont, emojiFont, wallframePng, markFonts] = await Promise.all([ + const [textFont, emojiFont, wallframePng, oldFilmPng, markFonts] = await Promise.all([ buf('/assets/Cousine-Regular.ttf'), buf('/assets/NotoEmoji-GPS.ttf'), buf('/assets/wallframe.png'), + buf('/assets/old_film.png'), Promise.all(MARK_FACES.map(async (face) => ({ family: face.family, bytes: await buf(face.file) }))), ]); - assets = { textFont, emojiFont, wallframe: new Uint8Array(wallframePng), markFonts }; + assets = { + textFont, + emojiFont, + wallframe: new Uint8Array(wallframePng), + oldFilm: new Uint8Array(oldFilmPng), + markFonts, + }; } return { assets, gpu: probeGpu() }; } diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 2c09053..dfbab1b 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -76,6 +76,7 @@ export interface RenderAssets { textFont: ArrayBuffer; // assets/Cousine-Regular.ttf emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf wallframe: Uint8Array; // wallframe.png + oldFilm: Uint8Array; // old_film.png // The FONT option's faces beside DEFAULT (see assets.ts MARK_FACES). markFonts: { family: string; bytes: ArrayBuffer }[]; } @@ -690,6 +691,7 @@ function replaceThrough( let faces: { typeface: any; iconTypeface: any } | null = null; let wallframeImage: any = null; +let oldFilmImage: any = null; function loadFaces(assets: RenderAssets) { if (faces) return faces; @@ -762,6 +764,19 @@ function loadWallframe(assets: RenderAssets) { return wallframeImage; } +// Both films are the same sheet of distressed paper: `old-film` lays it down as +// it is, `old-film-portrait` turns it 90° CW so the torn edge and the dust lie +// the other way round. Unlike the walls the sheet has no mat of its own — its +// middle is a transparent window and its opaque part is a border a couple of +// percent wide — so it is drawn OVER the photo and never crops it. +const isOldFilm = (id: FrameId) => id === 'old-film' || id === 'old-film-portrait'; + +function loadOldFilm(assets: RenderAssets) { + if (oldFilmImage) return oldFilmImage; + oldFilmImage = Skia.Image.MakeImageFromEncoded(assets.oldFilm); + return oldFilmImage; +} + export async function renderPhoto(input: RenderInput): Promise { const { recipe, frameId, options } = input; const owned: SkDisposable[] = []; @@ -1444,6 +1459,37 @@ export async function renderPhoto(input: RenderInput): Promise