fix: stream media previews
This commit is contained in:
2698
.memory/worklog.json
2698
.memory/worklog.json
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -11,9 +13,14 @@ export async function GET(_req: Request, ctx: { params: Promise<{ bucket: string
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
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 });
|
||||
return new NextResponse(new Uint8Array(r.buf), {
|
||||
headers: { 'Content-Type': r.type, 'Cache-Control': 'public, max-age=31536000, immutable' },
|
||||
const stream = Readable.toWeb(createReadStream(r.filePath));
|
||||
return new Response(stream as ReadableStream, {
|
||||
headers: {
|
||||
'Content-Type': r.type,
|
||||
'Content-Length': String(r.size),
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,20 +1,72 @@
|
||||
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 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 video = await readVideoFile(filename);
|
||||
const video = await statVideoFile(filename);
|
||||
if (!video) return NextResponse.json({ error: 'video not found' }, { status: 404 });
|
||||
|
||||
return new Response(new Uint8Array(video.buf), {
|
||||
const baseHeaders = {
|
||||
'Content-Type': video.type,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'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: {
|
||||
'Content-Type': video.type,
|
||||
'Content-Length': String(video.buf.length),
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'Accept-Ranges': 'bytes',
|
||||
...baseHeaders,
|
||||
'Content-Length': String(video.size),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ function ReferenceStrip({ session }: { session: GenSession }) {
|
||||
<div className="project-reference-strip">
|
||||
{refs.slice(0, 6).map((ref, index) => (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
@@ -285,11 +285,12 @@ function ProjectBrief({
|
||||
onBlur={() => setPreviewOpen(false)}
|
||||
>
|
||||
<div className="project-primary-preview" style={{ aspectRatio: primaryAspectRatio }}>
|
||||
<img
|
||||
src={primaryImage.url}
|
||||
alt="当前主方案"
|
||||
className="project-primary-image"
|
||||
onLoad={event => {
|
||||
<img
|
||||
src={primaryImage.url}
|
||||
alt="当前主方案"
|
||||
className="project-primary-image"
|
||||
decoding="async"
|
||||
onLoad={event => {
|
||||
const image = event.currentTarget;
|
||||
if (image.naturalWidth && image.naturalHeight) {
|
||||
setPrimaryAspectRatio(`${image.naturalWidth} / ${image.naturalHeight}`);
|
||||
@@ -303,7 +304,7 @@ function ProjectBrief({
|
||||
</div>
|
||||
{previewOpen && typeof document !== 'undefined' && createPortal(
|
||||
<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>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -65,12 +65,14 @@ export function HoverImagePreview({
|
||||
imageClassName,
|
||||
aspectRatio,
|
||||
onImageLoad,
|
||||
loading = 'lazy',
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
imageClassName?: string;
|
||||
aspectRatio?: string;
|
||||
onImageLoad?: (image: HTMLImageElement) => void;
|
||||
loading?: 'eager' | 'lazy';
|
||||
}) {
|
||||
const [preview, setPreview] = useState<PreviewState | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -85,6 +87,8 @@ export function HoverImagePreview({
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={imageClassName}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
onPointerMove={event => {
|
||||
if (event.pointerType === 'touch') return;
|
||||
setPreview(nextPreviewState(event, aspectRatio));
|
||||
@@ -101,6 +105,7 @@ export function HoverImagePreview({
|
||||
src={src}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -145,7 +145,7 @@ function AssetDetailDrawer({ detail, onClose }: {
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<div className="asset-detail-image" style={{ aspectRatio: aspectCss(asset?.aspectRatio ?? template.aspectRatio) }}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
|
||||
{image.mediaType === 'video' ? (
|
||||
<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 className="mt-3 min-w-0">
|
||||
@@ -208,7 +208,7 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
|
||||
{preview.mediaType === 'video' ? (
|
||||
<video src={preview.url} muted autoPlay loop playsInline />
|
||||
) : (
|
||||
<img src={preview.url} alt="" />
|
||||
<img src={preview.url} alt="" decoding="async" />
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
@@ -268,7 +268,7 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
|
||||
{image.mediaType === 'video' ? (
|
||||
<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">
|
||||
{index + 1}
|
||||
|
||||
@@ -21,7 +21,7 @@ function ProjectThumbs({ session }: { session: GenSession }) {
|
||||
<div className="flex -space-x-2">
|
||||
{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">
|
||||
<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>
|
||||
))}
|
||||
{thumbs.length === 0 && (
|
||||
|
||||
@@ -347,20 +347,33 @@ export async function copyToSelected(sessionId: string, imageId: string, srcUrl:
|
||||
|
||||
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> {
|
||||
try {
|
||||
const dir = bucket === 'generated' ? GEN_DIR
|
||||
: bucket === 'selected' ? SEL_DIR
|
||||
: bucket === 'refs' ? REF_DIR
|
||||
: bucket === 'packs' ? PACK_DIR
|
||||
: 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 };
|
||||
const info = await statImageFile(bucket, filename);
|
||||
if (!info) return null;
|
||||
const buf = await fs.readFile(info.filePath);
|
||||
return { buf, type: info.type };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -403,6 +416,19 @@ function videoTypeFromFilename(filename: string) {
|
||||
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> {
|
||||
if (url.startsWith('/api/video-file/')) 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> {
|
||||
try {
|
||||
const safeFilename = path.basename(filename);
|
||||
const buf = await fs.readFile(path.join(VIDEO_DIR, safeFilename));
|
||||
return { buf, type: videoTypeFromFilename(safeFilename) };
|
||||
const info = await statVideoFile(filename);
|
||||
if (!info) return null;
|
||||
const buf = await fs.readFile(info.filePath);
|
||||
return { buf, type: info.type };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user