fix: stream media previews
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user