Files
photo-live/apps/viewer/src/embed/stream.ts
T
浅念 998565e439 feat(viewer,ui04): E03 continuous scroll engine — pure state machine, rAF orchestration, boundary continuity
- stream.ts: advanceStream (per-frame, dt cap 50ms), loop generation keys, window math (DOM<=16, queue<=30, 480px buffer), anchorRebuild (photo-position preserving), removePhoto, single-photo static
- useVerticalPhotoStream: rAF integration, direct transform writes (no per-frame Vue reactivity), low-frequency item sync, ResizeObserver re-anchor, stepForTest hook
- EmbedStream wired to engine; cycle copies aria-hidden; img error -> controlled drop
- tests: 14 unit cases (236 viewer total), e2e: 32px/2s speed, boundary continuity <2.5px, DOM<=16 over 20s, 32s recording with loop boundaries

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 13:19:21 +08:00

313 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// apps/viewer/src/embed/stream.ts —— v2 垂直照片流纯函数集(UI-04 §2.3/§3.2)。
// 高度比例防御、环形队列窗口数学、锚定计算全部收敛为纯函数(vitest 直接覆盖);
// 组件/编排层(EmbedStream.vue / useVerticalPhotoStream.ts)只做装配。
/** 元数据异常时的高度比例回退(4:3,§2.3)。 */
export const FALLBACK_RATIO = 4 / 3;
/** 超长图安全钳(高度/宽度上限;防无穷高度破坏循环,§2.3)。 */
export const MAX_RATIO = 8;
/** 照片缝宽度(px,§2.2 硬性 3px)。 */
export const PHOTO_GAP_PX = 3;
export interface StreamPhotoSize {
width: number;
height: number;
}
/** 元数据 → 高度比例(height/width);宽高须 >0 且有限,否则回退 4:3。 */
export function photoRatio(size: StreamPhotoSize): number {
if (
Number.isFinite(size.width) &&
Number.isFinite(size.height) &&
size.width > 0 &&
size.height > 0
) {
return Math.min(size.height / size.width, MAX_RATIO);
}
return FALLBACK_RATIO;
}
/** 容器内宽 → 照片展示高度(按比例;内宽 = 窗宽 - 边框)。 */
export function photoDisplayHeight(size: StreamPhotoSize, innerWidth: number): number {
return photoRatio(size) * innerWidth;
}
/**
* 环形队列窗口(§3.2 步骤 1):给定队列、窗口高度与预载余量,计算从
* startIndex 起需要装载的条目数(高度和 ≥ 窗口高 + 上下余量)。返回条目
* 数量(至少 1,队列空返回 0)。纯计算——实际 DOM 复用由编排层负责。
*/
export function windowCount(
sizes: StreamPhotoSize[],
startIndex: number,
innerWidth: number,
viewportHeight: number,
bufferPx: number,
): number {
if (sizes.length === 0) return 0;
const target = viewportHeight + bufferPx;
let acc = 0;
let count = 0;
for (let i = 0; i < sizes.length; i++) {
const item = sizes[(startIndex + i) % sizes.length];
acc += photoDisplayHeight(item, innerWidth) + PHOTO_GAP_PX;
count += 1;
if (acc >= target) return count;
}
// 一轮高度不足以覆盖窗口(极矮照片):返回整队(§3.2.7 少量可见循环
// 副本由编排层处理,本函数不放大队列)。
return count;
}
// ── E03 连续滚动引擎的纯状态机(§3.1/§3.2)──
// 引擎状态与推进/锚定全部收敛为纯函数:rAF 编排层(useVerticalPhotoStream)
// 只做「每帧积分 → DOM transform」「退役/补尾 → Vue 渲染列表」两件事。
/** 渲染实例:有限窗口内的一条照片引用(key 含循环代次防同照重复 key)。 */
export interface StreamRenderItem {
/** Vue :key 与 DOM 复用标识:`{photoId}#{generation}`。 */
key: string;
photoId: string;
/** 数据队列下标(循环取模)。 */
queueIdx: number;
/** 该实例的循环代次(每绕队列一周 +1)。 */
gen: number;
}
/** 引擎可变状态(advance/anchor 纯函数的输入输出载体)。 */
export interface StreamEngineState {
/** 队列(photoId 序,最新在前——与 feed 合并结果同序)。 */
queue: string[];
/** 各队列条目展示尺寸(与 queue 同长同序)。 */
sizes: StreamPhotoSize[];
/** 顶部渲染实例的队列下标。 */
headIdx: number;
/** 已滚过头条目顶部的像素(0 ≤ offset < 头条目高+缝)。 */
offset: number;
/** 当前循环代次(headIdx 回绕 0 时 +1,作新尾实例 gen)。 */
gen: number;
/** 有限窗口渲染实例(head 起,向前覆盖窗口+缓冲)。 */
items: StreamRenderItem[];
}
/** DOM 实例上限(§3.2.6:最多 16 个图片实例)。 */
export const MAX_DOM_INSTANCES = 16;
/** 数据队列上限(§3.2.6;feed 单次 ≤20,合并后截尾保序)。 */
export const MAX_QUEUE = 30;
/** 窗口上下缓冲(px):预加载余量(§3.3 当前+接下来 2 张的覆盖带)。 */
export const WINDOW_BUFFER_PX = 480;
/** 条目展示高(含缝):比例 × 内宽。 */
function entryHeight(size: StreamPhotoSize, innerWidth: number): number {
return photoDisplayHeight(size, innerWidth) + PHOTO_GAP_PX;
}
/** 覆盖窗口+缓冲所需的实例数(自 headIdx 起,循环计数;含极矮一轮不足的
* 整队情形——窗口副本语义由 items 天然形成:count 可 > queue.length)。 */
function coverageCount(
state: Pick<StreamEngineState, 'queue' | 'sizes' | 'headIdx'>,
innerWidth: number,
viewportHeight: number,
): number {
const queueLen = state.queue.length;
if (queueLen === 0) return 0;
const target = viewportHeight + WINDOW_BUFFER_PX;
let acc = 0;
for (let i = 0; i < MAX_DOM_INSTANCES; i++) {
acc += entryHeight(state.sizes[(state.headIdx + i) % queueLen], innerWidth);
if (acc >= target) return i + 1;
}
return MAX_DOM_INSTANCES;
}
/** 重建有限窗口(headIdx/gen 基准;覆盖不足时循环副本,副本 key 含代次)。
* 单张静态(§3.2.8):不复制刷屏,恒一个实例。 */
function rebuildItems(
state: StreamEngineState,
innerWidth: number,
viewportHeight: number,
): void {
const queueLen = state.queue.length;
if (queueLen === 0) {
state.items = [];
return;
}
if (queueLen === 1) {
state.items = [
{
key: `${state.queue[0]}#${state.gen}`,
photoId: state.queue[0],
queueIdx: 0,
gen: state.gen,
},
];
return;
}
const count = Math.max(1, coverageCount(state, innerWidth, viewportHeight));
const items: StreamRenderItem[] = [];
let gen = state.gen;
for (let i = 0; i < count; i++) {
const queueIdx = (state.headIdx + i) % queueLen;
// 实例的 gen:从 head 起向前,每回绕到更小下标(一圈)+1。
if (i > 0 && queueIdx === 0) gen += 1;
items.push({
key: `${state.queue[queueIdx]}#${gen}`,
photoId: state.queue[queueIdx],
queueIdx,
gen,
});
}
state.items = items;
}
export interface CreateStreamStateInput {
photos: Array<{ photoId: string } & StreamPhotoSize>;
innerWidth: number;
viewportHeight: number;
}
/** 由 feed 照片序列创建引擎状态(截尾 MAX_QUEUE;head=最新)。 */
export function createStreamState(input: CreateStreamStateInput): StreamEngineState {
const queue = input.photos.map((p) => p.photoId);
const sizes = input.photos.map((p) => ({ width: p.width, height: p.height }));
const state: StreamEngineState = {
queue,
sizes,
headIdx: 0,
offset: 0,
gen: 0,
items: [],
};
rebuildItems(state, input.innerWidth, input.viewportHeight);
return state;
}
export interface AdvanceResult {
/** 头条目是否退役(调用方据此重算渲染列表——低频,不逐帧)。 */
retired: boolean;
}
/** 单帧时间上限(ms):后台长停顿恢复不补跑(§3.1 deltaTime)。 */
export const MAX_FRAME_MS = 50;
/**
* 单帧推进(§3.1):offset += speed×dt(dt 上限 50ms,长停顿不补跑);
* 头条目完整滚出(offset ≥ 头条高+缝)即退役:offset 回扣、headIdx 前进、
* 跨界过程屏幕内容不变(不瞬移)。返回是否发生退役。
*/
export function advanceStream(
state: StreamEngineState,
dtMs: number,
speedPxPerSec: number,
innerWidth: number,
viewportHeight: number,
): AdvanceResult {
const queueLen = state.queue.length;
if (queueLen === 0) return { retired: false };
const dt = Math.min(dtMs, MAX_FRAME_MS);
state.offset += (speedPxPerSec * dt) / 1000;
let retired = false;
// 一帧内可能跨多条极矮条目:循环退役直至 offset 落回头条目内。
for (let guard = 0; guard < MAX_DOM_INSTANCES; guard++) {
const headH = entryHeight(state.sizes[state.headIdx], innerWidth);
if (state.offset < headH) break;
state.offset -= headH;
state.headIdx = (state.headIdx + 1) % queueLen;
if (state.headIdx === 0) state.gen += 1;
retired = true;
}
if (retired) rebuildItems(state, innerWidth, viewportHeight);
return { retired };
}
/** 多帧受控推进(测试/校准用):按 ≤50ms 帧分块累计 durationMs(与运行时
* rAF 逐帧语义一致;大 dt 不经单帧捷径绕过退役边界)。 */
export function advanceStreamFor(
state: StreamEngineState,
durationMs: number,
speedPxPerSec: number,
innerWidth: number,
viewportHeight: number,
): void {
let remaining = durationMs;
while (remaining > 0) {
const dt = Math.min(remaining, 50);
advanceStream(state, dt, speedPxPerSec, innerWidth, viewportHeight);
remaining -= dt;
}
}
/**
* 数据更新锚定(§6.2):以「首个与视口相交的 photoId + 图内偏移」为锚,
* 队列合并(幸存保序、新图入未显示区)后重建状态,保持锚照片屏幕位置不变
* (±2px 内,允许新尺寸重算)。锚照片被移除时回退到最近幸存条目。
*/
export function anchorRebuild(
state: StreamEngineState,
nextPhotos: Array<{ photoId: string } & StreamPhotoSize>,
innerWidth: number,
viewportHeight: number,
): void {
const queueLen = state.queue.length;
// ① 找锚:自 head 起首个「底边超过 offset」的实例(即视口顶可见者)。
let anchorPhotoId: string | null = null;
let anchorInPhoto = 0;
if (queueLen > 0) {
let acc = -state.offset;
for (let i = 0; i < state.items.length; i++) {
const item = state.items[i];
const h = entryHeight(state.sizes[item.queueIdx], innerWidth);
if (acc + h > 0) {
anchorPhotoId = item.photoId;
// 照片顶部在视口上方 acc(负值)→ 图内偏移 = -acc(已滚入部分)。
anchorInPhoto = Math.max(0, -acc);
break;
}
acc += h;
}
}
// ② 合并到新队列(幸存保序 + 新图追加——与 feed.mergePhotoQueue 同语义,
// 此处直接按 nextPhotos 服务端序重建,锚定保位置、新增不入显示区)。
const nextQueue = nextPhotos.map((p) => p.photoId);
const nextSizes = nextPhotos.map((p) => ({ width: p.width, height: p.height }));
state.queue = nextQueue;
state.sizes = nextSizes;
// ③ 重定位 head:锚照片仍存在 → head=锚(图内偏移保留,按新尺寸夹取);
// 已移除 → 保持原 headIdx 相对位置(夹取到新队列长度内)。
let headIdx = 0;
if (anchorPhotoId !== null) {
const idx = nextQueue.indexOf(anchorPhotoId);
if (idx >= 0) headIdx = idx;
else headIdx = Math.min(state.headIdx, Math.max(0, nextQueue.length - 1));
}
state.headIdx = headIdx;
const newHeadH =
nextQueue.length > 0 ? entryHeight(nextSizes[headIdx], innerWidth) - PHOTO_GAP_PX : 0;
state.offset = Math.max(0, Math.min(anchorInPhoto, Math.max(0, newHeadH - 1)));
rebuildItems(state, innerWidth, viewportHeight);
}
/** 移除指定照片(隐藏/删除事件):重定位到下一有效项,保锚不跳屏(§6.3)。
* 锚点在旧队列上解析(anchorRebuild 内部先找锚后换队列)。 */
export function removePhoto(
state: StreamEngineState,
photoId: string,
innerWidth: number,
viewportHeight: number,
): void {
const idx = state.queue.indexOf(photoId);
if (idx < 0) return;
const queue = state.queue.filter((_, i) => i !== idx);
const sizes = state.sizes.filter((_, i) => i !== idx);
const nextPhotos = queue.map((id, i) => ({
photoId: id,
width: sizes[i].width,
height: sizes[i].height,
}));
anchorRebuild(state, nextPhotos, innerWidth, viewportHeight);
}