feat(frame): add OLD FILM LANDSCAPE and OLD FILM PORTRAIT from old_film.png

This commit is contained in:
2026-10-02 19:46:06 +07:00
parent 256fe6c2e2
commit 84b8e64652
8 changed files with 68 additions and 4 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+3 -1
View File
@@ -4,7 +4,9 @@ export type FrameId =
| 'classic-white' | 'classic-white'
| 'cinematic' | 'cinematic'
| 'wallframe' // WALL FRAME PORTRAIT — the artwork hung 90° CW | '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 // Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT
// (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing // (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing
+4 -1
View File
@@ -13,7 +13,10 @@ const KEY = 'RecipesCam::recipe-share::v1';
const ALGORITHM = 'xor16-v1'; const ALGORITHM = 'xor16-v1';
const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none']; 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 => { const hash = (s: string): number => {
let h = 0x811c9dc5; let h = 0x811c9dc5;
+2
View File
@@ -111,6 +111,8 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [
{ id: 'cinematic', label: 'frame.cinematic' }, { id: 'cinematic', label: 'frame.cinematic' },
{ id: 'wallframe', label: 'frame.wall' }, { id: 'wallframe', label: 'frame.wall' },
{ id: 'wallframe-landscape', label: 'frame.wallLand' }, { 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 — // The recipe a saved photo's base is rendered through: the neutral stock —
+9 -2
View File
@@ -34,13 +34,20 @@ export function probeGpu(): boolean {
export async function loadEngine(): Promise<{ assets: RenderAssets; gpu: boolean }> { export async function loadEngine(): Promise<{ assets: RenderAssets; gpu: boolean }> {
await initSkia({ locateFile: (file) => `/wasm/${file}` }); await initSkia({ locateFile: (file) => `/wasm/${file}` });
if (!assets) { 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/Cousine-Regular.ttf'),
buf('/assets/NotoEmoji-GPS.ttf'), buf('/assets/NotoEmoji-GPS.ttf'),
buf('/assets/wallframe.png'), buf('/assets/wallframe.png'),
buf('/assets/old_film.png'),
Promise.all(MARK_FACES.map(async (face) => ({ family: face.family, bytes: await buf(face.file) }))), 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() }; return { assets, gpu: probeGpu() };
} }
@@ -76,6 +76,7 @@ export interface RenderAssets {
textFont: ArrayBuffer; // assets/Cousine-Regular.ttf textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
wallframe: Uint8Array; // wallframe.png wallframe: Uint8Array; // wallframe.png
oldFilm: Uint8Array; // old_film.png
// The FONT option's faces beside DEFAULT (see assets.ts MARK_FACES). // The FONT option's faces beside DEFAULT (see assets.ts MARK_FACES).
markFonts: { family: string; bytes: ArrayBuffer }[]; markFonts: { family: string; bytes: ArrayBuffer }[];
} }
@@ -690,6 +691,7 @@ function replaceThrough(
let faces: { typeface: any; iconTypeface: any } | null = null; let faces: { typeface: any; iconTypeface: any } | null = null;
let wallframeImage: any = null; let wallframeImage: any = null;
let oldFilmImage: any = null;
function loadFaces(assets: RenderAssets) { function loadFaces(assets: RenderAssets) {
if (faces) return faces; if (faces) return faces;
@@ -762,6 +764,19 @@ function loadWallframe(assets: RenderAssets) {
return wallframeImage; 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<RenderResult | null> { export async function renderPhoto(input: RenderInput): Promise<RenderResult | null> {
const { recipe, frameId, options } = input; const { recipe, frameId, options } = input;
const owned: SkDisposable[] = []; const owned: SkDisposable[] = [];
@@ -1444,6 +1459,37 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
stampCanvas = cardCanvas; stampCanvas = cardCanvas;
stampW = outW; stampW = outW;
stampH = outH; stampH = outH;
} else if (isOldFilm(frameId)) {
// The sheet is stretched over the whole frame and drawn on top of the
// photo, so the paper eats the outer percent of the picture instead of
// cropping it — the inverse of the walls above, whose mat window crops.
// The stretch is deliberately non-uniform: a cover-scale of a 3:2 sheet
// onto a 4:3 frame would cut the torn edge off two sides, and the deckle
// is the whole point of the frame.
const film = loadOldFilm(input.assets);
if (!film) {
console.error('Failed to decode old_film.png');
return null;
}
const portrait = frameId === 'old-film-portrait';
const filmPaint = own(Skia.Paint());
canvas.save();
if (portrait) {
// The wall-frame portrait's own turn: origin to the top-right corner,
// then 90° CW, so the sheet's width runs down the frame and the drawn
// rect is the frame's height by its width.
canvas.translate(width, 0);
canvas.rotate(90, 0, 0);
}
canvas.drawImageRectOptions(
film,
Skia.XYWHRect(0, 0, film.width(), film.height()),
portrait ? Skia.XYWHRect(0, 0, height, width) : Skia.XYWHRect(0, 0, width, height),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
filmPaint
);
canvas.restore();
} else { } else {
drawFrameOnCanvas(canvas, width, height, frameId); drawFrameOnCanvas(canvas, width, height, frameId);
} }
+2
View File
@@ -93,6 +93,8 @@ export const en: Dict = {
'frame.cinematic': 'CINEMATIC', 'frame.cinematic': 'CINEMATIC',
'frame.wall': 'WALL FRAME PORTRAIT', 'frame.wall': 'WALL FRAME PORTRAIT',
'frame.wallLand': 'WALL FRAME LANDSCAPE', 'frame.wallLand': 'WALL FRAME LANDSCAPE',
'frame.oldFilm': 'OLD FILM LANDSCAPE',
'frame.oldFilmPort': 'OLD FILM PORTRAIT',
'crop.none': 'NO CROP', 'crop.none': 'NO CROP',
'crop.1:1': '1:1', 'crop.1:1': '1:1',
+2
View File
@@ -105,6 +105,8 @@ export const vi = {
'frame.cinematic': 'ĐIỆN ẢNH', 'frame.cinematic': 'ĐIỆN ẢNH',
'frame.wall': 'WALL FRAME PORTRAIT', 'frame.wall': 'WALL FRAME PORTRAIT',
'frame.wallLand': 'WALL FRAME LANDSCAPE', 'frame.wallLand': 'WALL FRAME LANDSCAPE',
'frame.oldFilm': 'OLD FILM LANDSCAPE',
'frame.oldFilmPort': 'OLD FILM PORTRAIT',
'crop.none': 'KHÔNG CẮT', 'crop.none': 'KHÔNG CẮT',
'crop.1:1': '1:1', 'crop.1:1': '1:1',