fix: stream media previews

This commit is contained in:
2026-05-21 21:53:46 +08:00
parent aec48a7584
commit b6d7febbde
9 changed files with 1490 additions and 1378 deletions

View File

@@ -1915,6 +1915,26 @@
"message": "启动 Codex 接力会话 · 已载入 Claude / Codex 最近会话,等待下一条指令 · 分支 master · 2 项未提交变更 · 最近提交auto-save 2026-05-21 08:45 (~2)", "message": "启动 Codex 接力会话 · 已载入 Claude / Codex 最近会话,等待下一条指令 · 分支 master · 2 项未提交变更 · 最近提交auto-save 2026-05-21 08:45 (~2)",
"ts": "2026-05-21T13:43:10Z", "ts": "2026-05-21T13:43:10Z",
"type": "assistant-session" "type": "assistant-session"
},
{
"ts": "2026-05-21T21:48:19+08:00",
"type": "commit",
"message": "auto-save 2026-05-21 21:48 (~2)",
"hash": "aec48a7",
"files_changed": 2
},
{
"ts": "2026-05-21T13:53:12Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 master · 9 项未提交变更 · 最近提交auto-save 2026-05-21 21:48 (~2)",
"files_changed": 9
},
{
"ts": "2026-05-21T21:53:46+08:00",
"type": "commit",
"message": "auto-save 2026-05-21 21:53 (~9)",
"hash": "41e22a3",
"files_changed": 9
} }
] ]
} }

View File

@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { readImageFile, type ImageBucket } from '@/lib/storage'; import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import { statImageFile, type ImageBucket } from '@/lib/storage';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -11,9 +13,14 @@ export async function GET(_req: Request, ctx: { params: Promise<{ bucket: string
if (filename.includes('..') || filename.includes('/')) { if (filename.includes('..') || filename.includes('/')) {
return NextResponse.json({ error: 'bad filename' }, { status: 400 }); return NextResponse.json({ error: 'bad filename' }, { status: 400 });
} }
const r = await readImageFile(bucket as ImageBucket, filename); const r = await statImageFile(bucket as ImageBucket, filename);
if (!r) return NextResponse.json({ error: 'not found' }, { status: 404 }); if (!r) return NextResponse.json({ error: 'not found' }, { status: 404 });
return new NextResponse(new Uint8Array(r.buf), { const stream = Readable.toWeb(createReadStream(r.filePath));
headers: { 'Content-Type': r.type, 'Cache-Control': 'public, max-age=31536000, immutable' }, return new Response(stream as ReadableStream, {
headers: {
'Content-Type': r.type,
'Content-Length': String(r.size),
'Cache-Control': 'public, max-age=31536000, immutable',
},
}); });
} }

View File

@@ -1,20 +1,72 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { readVideoFile } from '@/lib/storage'; import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import { statVideoFile } from '@/lib/storage';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function GET(_req: Request, ctx: { params: Promise<{ filename: string }> }) { function parseRange(range: string | null, size: number): { start: number; end: number } | null {
if (!range) return null;
const match = range.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, rawStart, rawEnd] = match;
if (!rawStart && !rawEnd) return null;
if (!rawStart) {
const suffixLength = Number(rawEnd);
if (!Number.isFinite(suffixLength) || suffixLength <= 0) return null;
return { start: Math.max(size - suffixLength, 0), end: size - 1 };
}
const start = Number(rawStart);
const end = rawEnd ? Number(rawEnd) : size - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= size) {
return null;
}
return { start, end: Math.min(end, size - 1) };
}
export async function GET(req: Request, ctx: { params: Promise<{ filename: string }> }) {
const { filename } = await ctx.params; const { filename } = await ctx.params;
const video = await readVideoFile(filename); const video = await statVideoFile(filename);
if (!video) return NextResponse.json({ error: 'video not found' }, { status: 404 }); if (!video) return NextResponse.json({ error: 'video not found' }, { status: 404 });
return new Response(new Uint8Array(video.buf), { const baseHeaders = {
headers: {
'Content-Type': video.type, 'Content-Type': video.type,
'Content-Length': String(video.buf.length),
'Cache-Control': 'private, max-age=3600', 'Cache-Control': 'private, max-age=3600',
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
};
const requestedRange = req.headers.get('range');
if (requestedRange) {
const range = parseRange(requestedRange, video.size);
if (!range) {
return new Response(null, {
status: 416,
headers: {
...baseHeaders,
'Content-Range': `bytes */${video.size}`,
},
});
}
const length = range.end - range.start + 1;
const stream = Readable.toWeb(createReadStream(video.filePath, { start: range.start, end: range.end }));
return new Response(stream as ReadableStream, {
status: 206,
headers: {
...baseHeaders,
'Content-Length': String(length),
'Content-Range': `bytes ${range.start}-${range.end}/${video.size}`,
},
});
}
const stream = Readable.toWeb(createReadStream(video.filePath));
return new Response(stream as ReadableStream, {
headers: {
...baseHeaders,
'Content-Length': String(video.size),
}, },
}); });
} }

View File

@@ -132,7 +132,7 @@ function ReferenceStrip({ session }: { session: GenSession }) {
<div className="project-reference-strip"> <div className="project-reference-strip">
{refs.slice(0, 6).map((ref, index) => ( {refs.slice(0, 6).map((ref, index) => (
<div key={`${ref.url}-${index}`} className="project-reference-tile"> <div key={`${ref.url}-${index}`} className="project-reference-tile">
<img src={ref.url} alt={ref.label} className="h-full w-full object-contain" /> <img src={ref.url} alt={ref.label} className="h-full w-full object-contain" loading="lazy" decoding="async" />
<span>{ref.label}</span> <span>{ref.label}</span>
</div> </div>
))} ))}
@@ -289,6 +289,7 @@ function ProjectBrief({
src={primaryImage.url} src={primaryImage.url}
alt="当前主方案" alt="当前主方案"
className="project-primary-image" className="project-primary-image"
decoding="async"
onLoad={event => { onLoad={event => {
const image = event.currentTarget; const image = event.currentTarget;
if (image.naturalWidth && image.naturalHeight) { if (image.naturalWidth && image.naturalHeight) {
@@ -303,7 +304,7 @@ function ProjectBrief({
</div> </div>
{previewOpen && typeof document !== 'undefined' && createPortal( {previewOpen && typeof document !== 'undefined' && createPortal(
<div className="project-image-popover project-image-popover--open" aria-hidden="true"> <div className="project-image-popover project-image-popover--open" aria-hidden="true">
<img src={primaryImage.url} alt="" /> <img src={primaryImage.url} alt="" decoding="async" />
</div>, </div>,
document.body, document.body,
)} )}

View File

@@ -65,12 +65,14 @@ export function HoverImagePreview({
imageClassName, imageClassName,
aspectRatio, aspectRatio,
onImageLoad, onImageLoad,
loading = 'lazy',
}: { }: {
src: string; src: string;
alt: string; alt: string;
imageClassName?: string; imageClassName?: string;
aspectRatio?: string; aspectRatio?: string;
onImageLoad?: (image: HTMLImageElement) => void; onImageLoad?: (image: HTMLImageElement) => void;
loading?: 'eager' | 'lazy';
}) { }) {
const [preview, setPreview] = useState<PreviewState | null>(null); const [preview, setPreview] = useState<PreviewState | null>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -85,6 +87,8 @@ export function HoverImagePreview({
src={src} src={src}
alt={alt} alt={alt}
className={imageClassName} className={imageClassName}
loading={loading}
decoding="async"
onPointerMove={event => { onPointerMove={event => {
if (event.pointerType === 'touch') return; if (event.pointerType === 'touch') return;
setPreview(nextPreviewState(event, aspectRatio)); setPreview(nextPreviewState(event, aspectRatio));
@@ -101,6 +105,7 @@ export function HoverImagePreview({
src={src} src={src}
alt="" alt=""
className="h-full w-full object-contain" className="h-full w-full object-contain"
decoding="async"
/> />
</div>, </div>,
document.body document.body

View File

@@ -145,7 +145,7 @@ function AssetDetailDrawer({ detail, onClose }: {
<div className="min-h-0 flex-1 overflow-y-auto p-5"> <div className="min-h-0 flex-1 overflow-y-auto p-5">
<div className="asset-detail-image" style={{ aspectRatio: aspectCss(asset?.aspectRatio ?? template.aspectRatio) }}> <div className="asset-detail-image" style={{ aspectRatio: aspectCss(asset?.aspectRatio ?? template.aspectRatio) }}>
{ready ? ( {ready ? (
<img src={asset.url} alt={template.title} /> <img src={asset.url} alt={template.title} loading="lazy" decoding="async" />
) : ( ) : (
<div className="grid h-full w-full place-items-center text-sm text-white/36"></div> <div className="grid h-full w-full place-items-center text-sm text-white/36"></div>
)} )}

View File

@@ -174,7 +174,7 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
{image.mediaType === 'video' ? ( {image.mediaType === 'video' ? (
<video src={image.url} controls muted playsInline preload="metadata" /> <video src={image.url} controls muted playsInline preload="metadata" />
) : ( ) : (
<img src={image.url} alt={image.title} /> <img src={image.url} alt={image.title} loading="lazy" decoding="async" />
)} )}
</div> </div>
<div className="mt-3 min-w-0"> <div className="mt-3 min-w-0">
@@ -208,7 +208,7 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
{preview.mediaType === 'video' ? ( {preview.mediaType === 'video' ? (
<video src={preview.url} muted autoPlay loop playsInline /> <video src={preview.url} muted autoPlay loop playsInline />
) : ( ) : (
<img src={preview.url} alt="" /> <img src={preview.url} alt="" decoding="async" />
)} )}
</div> </div>
) : null; ) : null;
@@ -268,7 +268,7 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
{image.mediaType === 'video' ? ( {image.mediaType === 'video' ? (
<video src={image.url} muted playsInline preload="metadata" className="h-full w-full object-contain" /> <video src={image.url} muted playsInline preload="metadata" className="h-full w-full object-contain" />
) : ( ) : (
<img src={image.url} alt="" className="h-full w-full object-contain" /> <img src={image.url} alt="" className="h-full w-full object-contain" loading="lazy" decoding="async" />
)} )}
<span className="absolute left-1 top-1 rounded-[6px] bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white"> <span className="absolute left-1 top-1 rounded-[6px] bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
{index + 1} {index + 1}

View File

@@ -21,7 +21,7 @@ function ProjectThumbs({ session }: { session: GenSession }) {
<div className="flex -space-x-2"> <div className="flex -space-x-2">
{thumbs.slice(0, 3).map((url, index) => ( {thumbs.slice(0, 3).map((url, index) => (
<span key={`${url}-${index}`} className="grid h-8 w-8 overflow-hidden rounded-[8px] bg-white ring-1 ring-white/15"> <span key={`${url}-${index}`} className="grid h-8 w-8 overflow-hidden rounded-[8px] bg-white ring-1 ring-white/15">
<img src={url} alt="" className="h-full w-full object-contain" /> <img src={url} alt="" className="h-full w-full object-contain" loading="lazy" decoding="async" />
</span> </span>
))} ))}
{thumbs.length === 0 && ( {thumbs.length === 0 && (

View File

@@ -347,20 +347,33 @@ export async function copyToSelected(sessionId: string, imageId: string, srcUrl:
export type ImageBucket = 'generated' | 'selected' | 'refs' | 'packs' | 'anchors' | 'uploads'; export type ImageBucket = 'generated' | 'selected' | 'refs' | 'packs' | 'anchors' | 'uploads';
function imageTypeFromFilename(filename: string) {
const ext = path.extname(filename).slice(1).toLowerCase();
if (ext === 'jpg') return 'image/jpeg';
if (ext === 'svg') return 'image/svg+xml';
return `image/${ext || 'png'}`;
}
export async function statImageFile(bucket: ImageBucket, filename: string): Promise<{ filePath: string; size: number; type: string } | null> {
try {
const dir = BUCKET_DIRS[bucket];
const safeFilename = path.basename(filename);
if (safeFilename !== filename || safeFilename.includes('..')) return null;
const filePath = path.join(dir, safeFilename);
const stat = await fs.stat(filePath);
if (!stat.isFile()) return null;
return { filePath, size: stat.size, type: imageTypeFromFilename(safeFilename) };
} catch {
return null;
}
}
export async function readImageFile(bucket: ImageBucket, filename: string): Promise<{ buf: Buffer; type: string } | null> { export async function readImageFile(bucket: ImageBucket, filename: string): Promise<{ buf: Buffer; type: string } | null> {
try { try {
const dir = bucket === 'generated' ? GEN_DIR const info = await statImageFile(bucket, filename);
: bucket === 'selected' ? SEL_DIR if (!info) return null;
: bucket === 'refs' ? REF_DIR const buf = await fs.readFile(info.filePath);
: bucket === 'packs' ? PACK_DIR return { buf, type: info.type };
: bucket === 'anchors' ? ANCHOR_DIR
: UPLOAD_DIR;
const buf = await fs.readFile(path.join(dir, filename));
const ext = path.extname(filename).slice(1).toLowerCase();
const type = ext === 'jpg' ? 'image/jpeg'
: ext === 'svg' ? 'image/svg+xml'
: `image/${ext}`;
return { buf, type };
} catch { } catch {
return null; return null;
} }
@@ -403,6 +416,19 @@ function videoTypeFromFilename(filename: string) {
return 'video/mp4'; return 'video/mp4';
} }
export async function statVideoFile(filename: string): Promise<{ filePath: string; size: number; type: string } | null> {
try {
const safeFilename = path.basename(filename);
if (safeFilename !== filename || safeFilename.includes('..')) return null;
const filePath = path.join(VIDEO_DIR, safeFilename);
const stat = await fs.stat(filePath);
if (!stat.isFile()) return null;
return { filePath, size: stat.size, type: videoTypeFromFilename(safeFilename) };
} catch {
return null;
}
}
export async function saveRemoteVideo(sessionId: string, taskId: string, url: string): Promise<string> { export async function saveRemoteVideo(sessionId: string, taskId: string, url: string): Promise<string> {
if (url.startsWith('/api/video-file/')) return url; if (url.startsWith('/api/video-file/')) return url;
if (!/^https?:\/\//i.test(url)) return url; if (!/^https?:\/\//i.test(url)) return url;
@@ -433,9 +459,10 @@ export async function saveRemoteVideo(sessionId: string, taskId: string, url: st
export async function readVideoFile(filename: string): Promise<{ buf: Buffer; type: string } | null> { export async function readVideoFile(filename: string): Promise<{ buf: Buffer; type: string } | null> {
try { try {
const safeFilename = path.basename(filename); const info = await statVideoFile(filename);
const buf = await fs.readFile(path.join(VIDEO_DIR, safeFilename)); if (!info) return null;
return { buf, type: videoTypeFromFilename(safeFilename) }; const buf = await fs.readFile(info.filePath);
return { buf, type: info.type };
} catch { } catch {
return null; return null;
} }