feat: 媒体流图片自适应显示(单图/2图/Telegram式相册)

- 单图气泡按真实比例显示:横图限高260px、过高裁上下;竖图完整铺满宽度不裁、无黑边
- 2张同类相册:都竖图左右并排、都横图上下堆叠,按比例不裁
- 3+张相册:Telegram式马赛克拼贴(竖主图占左+其余堆右 / 横主图占顶+其余排底)
- 图片比例优先用后端width/height,缺失时从加载后的naturalWidth/Height读取
- 新增 constants/media.ts 统一尺寸规范;albumLayout 纯算法附单测

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
TerryM
2026-05-29 22:16:55 +08:00
parent a7792c117d
commit 0035457c6d
10 changed files with 431 additions and 58 deletions

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { computeAlbumLayout } from "./albumLayout";
describe("computeAlbumLayout", () => {
it("returns null for fewer than 2 images", () => {
expect(computeAlbumLayout([])).toBeNull();
expect(computeAlbumLayout([1.5])).toBeNull();
});
it("places two portraits side by side (1 row, 2 cols)", () => {
const layout = computeAlbumLayout([0.7, 0.8])!;
expect(layout.gridTemplateRows).toBe("1fr");
expect(layout.tiles).toHaveLength(2);
// Both tiles occupy the single row, adjacent columns.
expect(layout.tiles[0]).toMatchObject({ colStart: 1, colEnd: 2 });
expect(layout.tiles[1]).toMatchObject({ colStart: 2, colEnd: 3 });
// Album width = sum of ratios when height = 1.
expect(layout.aspectRatio).toBeCloseTo(0.7 + 0.8, 4);
});
it("stacks two landscapes vertically (2 rows, 1 col)", () => {
const layout = computeAlbumLayout([1.6, 1.4])!;
expect(layout.gridTemplateColumns).toBe("1fr");
expect(layout.tiles[0]).toMatchObject({ rowStart: 1, rowEnd: 2 });
expect(layout.tiles[1]).toMatchObject({ rowStart: 2, rowEnd: 3 });
expect(layout.aspectRatio).toBeCloseTo(1 / (1 / 1.6 + 1 / 1.4), 4);
});
it("puts a portrait primary on the left with the rest stacked right", () => {
const layout = computeAlbumLayout([0.6, 1.2, 1.3])!;
// Primary spans both right-hand rows in column 1.
expect(layout.tiles[0]).toEqual({
colStart: 1,
colEnd: 2,
rowStart: 1,
rowEnd: 3,
});
expect(layout.tiles[1]).toMatchObject({ colStart: 2, rowStart: 1 });
expect(layout.tiles[2]).toMatchObject({ colStart: 2, rowStart: 2 });
});
it("puts a landscape primary on top with the rest in a row below", () => {
const layout = computeAlbumLayout([1.8, 0.9, 1.1])!;
// Primary spans both bottom columns in row 1.
expect(layout.tiles[0]).toEqual({
colStart: 1,
colEnd: 3,
rowStart: 1,
rowEnd: 2,
});
expect(layout.tiles[1]).toMatchObject({ rowStart: 2, colStart: 1 });
expect(layout.tiles[2]).toMatchObject({ rowStart: 2, colStart: 2 });
});
});

View File

@@ -0,0 +1,131 @@
/**
* Telegram-style adaptive album layout.
*
* Given the aspect ratios (width / height) of the images in an album, computes
* a CSS-grid mosaic where each cell is sized close to its image's real ratio,
* so the collage looks tidy with minimal cropping.
*
* Rules (mirroring the product's request):
* - 2 images, both portrait -> side by side (1 row, 2 cols)
* - 2 images, both landscape -> stacked (2 rows, 1 col)
* - 2 images, mixed -> side by side, proportional
* - 3+ images -> "primary + line": the first image is the big
* one; a portrait primary sits on the LEFT with the rest stacked on the
* right, a landscape primary sits on TOP with the rest in a row below.
*/
export type AlbumTile = {
colStart: number;
colEnd: number;
rowStart: number;
rowEnd: number;
};
export type AlbumLayout = {
gridTemplateColumns: string;
gridTemplateRows: string;
/** width / height of the whole album box. */
aspectRatio: number;
tiles: AlbumTile[];
};
/**
* Keep ratios within a sane range so one extreme image can't make the whole
* mosaic absurdly tall or flat. Beyond this the cell crops (object-cover).
*/
function clampRatio(ratio: number | undefined): number {
if (!ratio || !Number.isFinite(ratio) || ratio <= 0) return 1;
return Math.min(2, Math.max(0.55, ratio));
}
function fr(values: number[]): string {
return values.map((v) => `${Number(v.toFixed(4))}fr`).join(" ");
}
function layoutTwo(ratios: number[]): AlbumLayout {
const [r0, r1] = ratios;
const bothLandscape = r0 >= 1 && r1 >= 1;
if (bothLandscape) {
// Stacked: equal width, heights follow each ratio.
const h0 = 1 / r0;
const h1 = 1 / r1;
return {
gridTemplateColumns: "1fr",
gridTemplateRows: fr([h0, h1]),
aspectRatio: 1 / (h0 + h1),
tiles: [
{ colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 },
{ colStart: 1, colEnd: 2, rowStart: 2, rowEnd: 3 },
],
};
}
// Portrait or mixed: side by side, equal height, widths follow each ratio.
return {
gridTemplateColumns: fr([r0, r1]),
gridTemplateRows: "1fr",
aspectRatio: r0 + r1,
tiles: [
{ colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 },
{ colStart: 2, colEnd: 3, rowStart: 1, rowEnd: 2 },
],
};
}
function layoutPrimaryPlusLine(ratios: number[]): AlbumLayout {
const r0 = ratios[0];
const secondary = ratios.slice(1);
const primaryOnLeft = r0 < 1; // portrait primary -> left; landscape -> top
if (primaryOnLeft) {
// Left column holds the primary (spans all rows). Right column is split
// into one row per secondary image; share width `wr`, heights follow ratio.
const invSum = secondary.reduce((acc, r) => acc + 1 / r, 0);
const wr = 1 / invSum; // right column width when total height = 1
const wl = r0; // primary width when height = 1
const tiles: AlbumTile[] = [
{ colStart: 1, colEnd: 2, rowStart: 1, rowEnd: secondary.length + 1 },
];
secondary.forEach((_, i) => {
tiles.push({ colStart: 2, colEnd: 3, rowStart: i + 1, rowEnd: i + 2 });
});
return {
gridTemplateColumns: fr([wl, wr]),
gridTemplateRows: fr(secondary.map((r) => 1 / r)),
aspectRatio: wl + wr,
tiles,
};
}
// Primary on top (spans all columns). Bottom row holds the secondary images
// side by side; share height `hb`, widths follow ratio.
const sumR = secondary.reduce((acc, r) => acc + r, 0);
const ht = 1 / r0; // primary height when total width = 1
const hb = 1 / sumR; // bottom row height when total width = 1
const tiles: AlbumTile[] = [
{ colStart: 1, colEnd: secondary.length + 1, rowStart: 1, rowEnd: 2 },
];
secondary.forEach((_, i) => {
tiles.push({ colStart: i + 1, colEnd: i + 2, rowStart: 2, rowEnd: 3 });
});
return {
gridTemplateColumns: fr(secondary.map((r) => r)),
gridTemplateRows: fr([ht, hb]),
aspectRatio: 1 / (ht + hb),
tiles,
};
}
/**
* Compute the mosaic for the given image ratios (already sliced to the visible
* count, max 4). Returns null for counts this layout doesn't handle (0 or 1).
*/
export function computeAlbumLayout(
rawRatios: (number | undefined)[],
): AlbumLayout | null {
const ratios = rawRatios.map(clampRatio);
if (ratios.length < 2) return null;
if (ratios.length === 2) return layoutTwo(ratios);
return layoutPrimaryPlusLine(ratios);
}