Compare commits

...

12 Commits

Author SHA1 Message Date
b6d7febbde fix: stream media previews 2026-05-21 21:56:20 +08:00
aec48a7584 auto-save 2026-05-21 21:48 (~2) 2026-05-21 21:48:19 +08:00
0c1a9ef5d6 auto-save 2026-05-21 08:45 (~2) 2026-05-21 08:45:25 +08:00
515ef798ad auto-save 2026-05-21 02:36 (~2) 2026-05-21 02:36:11 +08:00
9520d9328c auto-save 2026-05-21 02:30 (~2) 2026-05-21 02:30:46 +08:00
7524b3caf1 auto-save 2026-05-21 02:25 (~2) 2026-05-21 02:25:21 +08:00
da12ed0bbb auto-save 2026-05-21 02:19 (~2) 2026-05-21 02:19:55 +08:00
fa6e32b7ad auto-save 2026-05-21 02:09 (~5) 2026-05-21 02:09:03 +08:00
e85be866e8 auto-save 2026-05-20 23:53 (~2) 2026-05-20 23:55:28 +08:00
76977543bd auto-save 2026-05-20 22:54 (~3) 2026-05-20 22:54:10 +08:00
a3d0c97d23 auto-save 2026-05-20 22:48 (~2) 2026-05-20 22:48:44 +08:00
33f87eb35d auto-save 2026-05-20 22:43 (~2) 2026-05-20 22:43:19 +08:00
14 changed files with 1929 additions and 1218 deletions

View File

@@ -1,15 +1,15 @@
# 项目接力
- 生成时间May 19, 2026 at 08:49
- 生成时间May 21, 2026 at 21:43
- 项目AI玩具专利生成工作流
- 路径:/Users/kangwan/Projects/code/20260518-ai-toy-patent-workflow
- 状态active
- 主链接http://localhost:4560
- 主链接https://ai-toy.kang-kang.com
## 最近助手会话概览
- Claudedf7c3755-a4d2-4e32-b68b-42bbaebc2fda · 时间未知
- Codex019e3ba6-c669-7521-8092-6ccb0dca2428 · 时间未知
- Codex019e40f8-2f9a-73d0-af47-129bc741af46 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话
@@ -49,38 +49,56 @@
## Codex 最近会话
- Session ID019e3ba6-c669-7521-8092-6ccb0dca2428
- Transcript/Users/kangwan/.codex/sessions/2026/05/18/rollout-2026-05-18T23-14-06-019e3ba6-c669-7521-8092-6ccb0dca2428.jsonl
- Session ID019e40f8-2f9a-73d0-af47-129bc741af46
- Transcript/Users/kangwan/.codex/sessions/2026/05/20/rollout-2026-05-20T00-01-07-019e40f8-2f9a-73d0-af47-129bc741af46.jsonl
- 工作目录:/Users/kangwan/Projects/code/20260518-ai-toy-patent-workflow
- 分支master
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- # AGENTS.md instructions for /Users/kangwan/Projects/code/20260518-ai-toy-patent-workflow <INSTRUCTIONS> # AI玩具专利生成工作流 Agent Rules ## Must Read First - 全项目规则真源:`/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md` - 全项目元数据规范:`/Users/kangwan/Projects/code/20260317-rules-dashboard/SCHEMA.md` - `.project.json` 是机器真源:公网链接、快捷登录、凭证引用都以它为准 - `RULES.md` 是人工规则和部署事实:启动命令、平台、域名、注意事项都写这里 - 不允许编造不存在的域名、账号、密码;未知就保持空白并明确标记待补充 ## Deployment Metadata Contract - 任何任务只要新增、删除或修改公网地址,必须在同一次任务里更新 `.project.json` - `urls[]` 推荐显式写 `type``app``backend``docs``admin``repo` - 项目专属的网页登录信息,如果允许放进仓库,就写 …
- 你现在是在 Codex CLI 里的项目承接会话。 这不是对话立项台;项目目录已经存在,当前目标是接住上下文并等待用户下一步。 项目路径:/Users/kangwan/Projects/code/20260518-ai-toy-patent-workflow 承接步骤: - 先读取 `.memory/assistant-handoff.md` - 再阅读 `.project.json``RULES.md``AGENTS.md` - 结合当前仓库状态建立上下文 要求: - 不要要求用户重新粘贴 Claude / Codex / Cursor 的总结 - 先理解最近进度、未完成项和下一步候选 - 不要立即修改代码、继续上一个任务或自行执行命令 - 等待用户给出下一条明确指令后再开始动手 - 如果后续需要 Cursor提醒用户从看板打开 Cursor GUI 到当前项目目录
- 排版麻烦你全部给我打乱,重新来 ,是有给你强制的结构框架构么?不需要现有的结构 你给我重新设计明白么?
- 流程错误了 每一个先从专利包开始 都是一步做完才能做下一步 ,不能一按键全包
- 视频模型先接上2315eb28-4db5-4907-a2bb-dcbb23b106f3 https://ark.cn-beijing.volces.com/api/v3
### Codex 最近回复
> 远端目前没有配置,`git remote -v` 为空;分支也没有 upstream。最近提交列表现在显示 `e3555da auto-save` 在 `8e1147c` 之后,我再取一次当前状态,避免给你过时结论
> 已把文档修正整理成语义 commit 并推送。生产运行态和生产 env 都确认是已验证可提交的 `doubao-seedance-2-0-260128`,没有使用那个无效 UUID。最后我再查公网入口和 git 状态
> 已接住上下文。已读取 handoff、`.project.json`、`RULES.md`、`AGENTS.md`、全局 `RULES.md` / `SCHEMA.md`,并核对了仓库状态和关键实现文件。
> 这个 UUID 我接不上,已经实测过了:
>
> 当前理解:项目是本地 Next.js 15 + React 19 工具,端口 `4560`,入口 `http://localhost:4560`。MVP Step1+2 已跑通prompt + 参考图 + 风格/数量生成、九宫格快筛、选中落盘、历史会话。当前无 `POE_API_KEY` 时走 mock SVG占位图质量为零真实 Poe `nano-banana-pro` 调用已在 `src/lib/providers.ts`。
> - `2315eb28-4db5-4907-a2bb-dcbb23b106f3` 提交到 Ark 返回:`InvalidEndpointOrModel.NotFound`
> - `/models` 能 200说明 base 和 key 是通的,但这个 UUID 不在当前账号可访问模型里
> - 可见的 `doubao-seedance-1-5-pro-251215` 也返回“账号未激活该模型”
> - 当前已验证可提交的是原来的 `doubao-seedance-2-0-260128`6 秒最小任务提交成功:`cgt-20260520160907-wjgl8`
>
> 下一步候选只有候选,不会自动继续:接入新 Poe Key、做 Step3 多视图、做 Step4 专利蓝图/尺寸标注、或做导出专利包。
> 我没有把这个无效 UUID 留在本地或生产运行态,已经回退到可用模型:
>
> 当前仓库状态:
> - 本地 `.env.local``SEEDANCE_MODEL=doubao-seedance-2-0-260128`
> - 生产 `deploy/.env.production`:已回退
> - 生产容器运行态:`doubao-seedance-2-0-260128`
> - 生产容器healthy
> - 公网登录页200
>
> ```bash
> git status -sb
> ## master
> M .memory/worklog.json
> 验证已做:
> - `npm run build` 通过
> - `docker compose up -d --build` 通过,本地容器 healthy
> - 生产容器 healthy
> - 代码无功能改动,只修正了 handoff 文档里的 Seedance 默认模型引用
>
> 已提交并推送:
> `b6f7a44 docs: align seedance model reference`
>
> 最终 `git status -sb`
> ```text
> ## master...origin/master
> ```
>
> HEAD 是 `e3555da auto-save 2026-05-18 23:15 (+1, ~1)`;当前没有配置 `origin` 远端,所以后续即使有代码改动,也需要先明确远端后才能推送。`.memory/worklog.json` 还有未提交变更,主要是格式变化和记录 `e3555da` 这次 auto-save
> 要用你给的这个 UUID需要在 Ark Console 里确认它是当前 `SEEDANCE_API_KEY` 所属账号可访问/已激活的模型或 endpoint
>
> 我现在等待你的下一条明确指令。后续如果需要 Cursor请从看板打开 Cursor GUI并直接打开当前项目目录`/Users/kangwan/Projects/code/20260518-ai-toy-patent-workflow`。
> <oai-mem-citation>
> <citation_entries>
> MEMORY.md:196-198|note=…
## Cursor 最近会话
@@ -90,7 +108,7 @@
- 当前分支master
- 未提交变更1 项
- 最近提交auto-save 2026-05-19 08:40 (~3)
- 最近提交auto-save 2026-05-21 08:45 (~2)
- 变更文件:
- M .memory/worklog.json

File diff suppressed because it is too large Load Diff

View File

@@ -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',
},
});
}

View File

@@ -13,12 +13,13 @@ export const dynamic = 'force-dynamic';
const PACK_KINDS: PackKind[] = PACK_ORDER;
async function persistPackProgress(session: GenSession, imageId: string, pack: AssetPack) {
session.characterSpec = pack.characterSpec;
session.packs = [
...(session.packs ?? []).filter(existing => !(existing.kind === pack.kind && existing.sourceImageId === imageId)),
const latest = await loadSession(session.id) ?? session;
latest.characterSpec = pack.characterSpec;
latest.packs = [
...(latest.packs ?? []).filter(existing => !(existing.kind === pack.kind && existing.sourceImageId === imageId)),
{ ...pack, assets: [...pack.assets] },
];
await saveSession(session);
await saveSession(latest);
}
function isCompletePack(pack: AssetPack, imageId: string): boolean {
@@ -140,16 +141,17 @@ export async function POST(req: Request) {
});
},
});
baseSession.characterSpec = pack.characterSpec;
baseSession.packs = [
...(baseSession.packs ?? []).filter(existing => !(existing.kind === kind && existing.sourceImageId === imageId)),
const latestSession = await loadSession(baseSession.id) ?? baseSession;
latestSession.characterSpec = pack.characterSpec;
latestSession.packs = [
...(latestSession.packs ?? []).filter(existing => !(existing.kind === kind && existing.sourceImageId === imageId)),
pack,
];
baseSession.exports = [
...(baseSession.exports ?? []).filter(existing => !(existing.packKind === kind && existing.source.sourceImageId === imageId)),
latestSession.exports = [
...(latestSession.exports ?? []).filter(existing => !(existing.packKind === kind && existing.source.sourceImageId === imageId)),
manifest,
];
await saveSession(baseSession);
await saveSession(latestSession);
recordEvent({
action: 'pack.generate_completed',
sessionId,

View File

@@ -27,11 +27,12 @@ export async function POST(req: Request) {
});
const generated = await generateTextAssets({ session, templateIds: body.templateIds });
const nextIds = new Set(generated.textAssets.map(asset => asset.templateId));
session.textAssets = [
...(session.textAssets ?? []).filter(asset => !nextIds.has(asset.templateId)),
const latest = await loadSession(session.id) ?? session;
latest.textAssets = [
...(latest.textAssets ?? []).filter(asset => !nextIds.has(asset.templateId)),
...generated.textAssets,
];
await saveSession(session);
await saveSession(latest);
recordEvent({
action: 'text.generate_completed',
sessionId: session.id,

View File

@@ -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),
},
});
}

View File

@@ -1028,7 +1028,8 @@ input, textarea {
0 0 0 1px rgba(255, 255, 255, 0.22);
}
.gallery-center-preview img {
.gallery-center-preview img,
.gallery-center-preview video {
display: block;
width: min(74vw, 960px);
height: 82vh;
@@ -1090,7 +1091,8 @@ input, textarea {
background: #fff;
}
.gallery-drawer-card__image img {
.gallery-drawer-card__image img,
.gallery-drawer-card__image video {
display: block;
width: 100%;
height: 100%;

View File

@@ -58,6 +58,51 @@ const PACK_BRIEF_DESCRIPTIONS: Record<PackKind, string> = {
marketing: '白底商品图 · 场景图 · 细节图 · 社媒图',
};
const DEFAULT_ASSET_PANEL = 'pack-patent';
const VALID_ASSET_PANELS = new Set([
...PACK_ORDER.map(kind => `pack-${kind}`),
'pack-text',
'pack-video',
]);
const LAST_SESSION_STORAGE_KEY = 'ai-toy:last-session-id';
const LAST_PANEL_STORAGE_KEY = 'ai-toy:last-asset-panel';
function normalizeAssetPanel(panel?: string | null) {
return panel && VALID_ASSET_PANELS.has(panel) ? panel : DEFAULT_ASSET_PANEL;
}
function readPersistedProjectState() {
if (typeof window === 'undefined') {
return { sessionId: null as string | null, panel: DEFAULT_ASSET_PANEL };
}
const params = new URLSearchParams(window.location.search);
return {
sessionId: params.get('session') || window.localStorage.getItem(LAST_SESSION_STORAGE_KEY),
panel: normalizeAssetPanel(params.get('panel') || window.localStorage.getItem(LAST_PANEL_STORAGE_KEY)),
};
}
function writePersistedProjectState(sessionId: string, panel: string) {
if (typeof window === 'undefined') return;
const normalizedPanel = normalizeAssetPanel(panel);
window.localStorage.setItem(LAST_SESSION_STORAGE_KEY, sessionId);
window.localStorage.setItem(LAST_PANEL_STORAGE_KEY, normalizedPanel);
const url = new URL(window.location.href);
url.searchParams.set('session', sessionId);
url.searchParams.set('panel', normalizedPanel);
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
}
function clearPersistedProjectState() {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(LAST_SESSION_STORAGE_KEY);
window.localStorage.removeItem(LAST_PANEL_STORAGE_KEY);
const url = new URL(window.location.href);
url.searchParams.delete('session');
url.searchParams.delete('panel');
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
}
function ProjectStat({ label, value, tone }: { label: string; value: string | number; tone?: 'accent' | 'soft' }) {
return (
<div className={`project-stat ${tone === 'accent' ? 'project-stat--accent' : ''}`}>
@@ -87,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>
))}
@@ -240,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}`);
@@ -258,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,
)}
@@ -339,7 +385,8 @@ export default function Home() {
const [uploadLoading, setUploadLoading] = useState(false);
const [provider, setProvider] = useState<string>('?');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [activeAssetPanel, setActiveAssetPanel] = useState('pack-patent');
const [activeAssetPanel, setActiveAssetPanel] = useState(DEFAULT_ASSET_PANEL);
const [restoreAttempted, setRestoreAttempted] = useState(false);
const refreshSessions = useCallback(async () => {
const r = await fetch('/api/sessions');
@@ -349,7 +396,24 @@ export default function Home() {
}, []);
useEffect(() => { refreshSessions(); }, [refreshSessions]);
useEffect(() => { setActiveAssetPanel('pack-patent'); }, [current?.id]);
useEffect(() => {
if (restoreAttempted || !sessions.length) return;
const persisted = readPersistedProjectState();
if (persisted.sessionId) {
const restored = sessions.find(session => session.id === persisted.sessionId);
if (restored) {
setCurrent(restored);
setActiveAssetPanel(persisted.panel);
}
}
setRestoreAttempted(true);
}, [restoreAttempted, sessions]);
useEffect(() => {
if (!current) return;
writePersistedProjectState(current.id, activeAssetPanel);
}, [activeAssetPanel, current]);
async function handleGenerate(opts: { prompt: string; refImages: string[]; count: number; style?: string }) {
setLoading(true);
@@ -613,7 +677,11 @@ export default function Home() {
sessions={sessions}
currentId={current?.id ?? null}
onPick={id => setCurrent(sessions.find(s => s.id === id) ?? null)}
onNew={() => setCurrent(null)}
onNew={() => {
setCurrent(null);
setActiveAssetPanel(DEFAULT_ASSET_PANEL);
clearPersistedProjectState();
}}
/>
<main className="project-stage min-w-0 flex-1">
<div className="project-stage-inner">
@@ -684,7 +752,7 @@ export default function Home() {
<PackPanel
session={current}
activeNav={activeAssetPanel}
onActiveNavChange={setActiveAssetPanel}
onActiveNavChange={id => setActiveAssetPanel(normalizeAssetPanel(id))}
textLoading={textLoading}
videoLoading={videoLoading}
onGenerateText={handleGenerateText}

View File

@@ -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,62 @@ export function HoverImagePreview({
src={src}
alt=""
className="h-full w-full object-contain"
decoding="async"
/>
</div>,
document.body
)}
</>
);
}
export function HoverVideoPreview({
src,
className,
aspectRatio,
}: {
src: string;
className?: string;
aspectRatio?: string;
}) {
const [preview, setPreview] = useState<PreviewState | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<>
<video
src={src}
className={className}
muted
loop
playsInline
preload="metadata"
onPointerMove={event => {
if (event.pointerType === 'touch') return;
setPreview(nextPreviewState(event, aspectRatio));
event.currentTarget.play().catch(() => undefined);
}}
onPointerLeave={event => {
setPreview(null);
event.currentTarget.pause();
}}
/>
{preview && mounted && createPortal(
<div
className="pointer-events-none fixed z-[90] overflow-hidden rounded-[8px] bg-black shadow-[0_24px_80px_-24px_rgba(0,0,0,0.86)] ring-1 ring-white/20"
style={{ left: preview.left, top: preview.top, width: preview.width, height: preview.height }}
>
<video
src={src}
className="h-full w-full object-contain"
muted
autoPlay
loop
playsInline
/>
</div>,
document.body

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AssetPack, AssetTemplate, GenImage, GenSession, PackKind, ToyAsset } from '@/lib/types';
import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates';
import { HoverImagePreview } from './HoverImagePreview';
import { HoverImagePreview, HoverVideoPreview } from './HoverImagePreview';
const PACK_DESCRIPTIONS: Record<PackKind, string> = {
patent: '六面视图 · 45° 立体图 · 局部放大',
@@ -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>
)}
@@ -310,30 +310,42 @@ function TextTemplateSection({
</div>
</div>
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
<div className="grid grid-cols-1 gap-3 border-t border-white/[0.05] p-4 lg:grid-cols-2 2xl:grid-cols-3">
{TEXT_TEMPLATES.map(template => {
const isOpen = showPromptId === template.id;
const asset = byTemplate.get(template.id);
return (
<div key={template.id} className="grid grid-cols-[72px_minmax(0,1fr)_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#8cb478]/15 to-[#d6b36a]/15 ring-1 ring-[#8cb478]/20 flex flex-col items-center justify-center text-[#cfe7a7] text-[9px] font-mono gap-0.5">
<span>text</span>
<span className="text-[8px] text-[#cfe7a7]/60">{template.outputFormat}</span>
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium text-white">{template.title}</span>
<div key={template.id} className="flex min-h-[240px] min-w-0 flex-col rounded-[8px] bg-white/[0.025] p-3 ring-1 ring-white/[0.05] transition-all hover:ring-white/[0.12]">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-semibold text-white">{template.title}</span>
{template.required && <span className="text-[9px] text-[#e6f578]/80 uppercase tracking-widest"></span>}
</div>
<p className="mt-1 line-clamp-2 text-[11px] leading-relaxed text-white/45">{template.description}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
<span className={`chip text-[10px] py-0 ${template.kind === 'patent' ? 'bg-[#e6f578]/15 text-[#e6f578] border-[#e6f578]/30' : template.kind === 'production' ? 'bg-[#d6b36a]/15 text-[#f2d38c] border-[#d6b36a]/30' : template.kind === 'accessories' ? 'bg-[#8cb478]/15 text-[#cfe7a7] border-[#8cb478]/30' : template.kind === 'marketing' ? 'bg-[#b6df72]/15 text-[#dff5a8] border-[#b6df72]/30' : 'chip-neutral'}`}>
{template.kind}
</span>
{template.required && <span className="text-[9px] text-[#e6f578]/80 uppercase tracking-widest"></span>}
<span className="font-mono text-[9px] uppercase tracking-wider text-white/28">{template.outputFormat}</span>
</div>
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
{asset && (
<pre className="mt-2 max-h-36 overflow-y-auto rounded-lg bg-black/32 p-3 text-[11px] leading-relaxed text-white/72 ring-1 ring-white/[0.06] whitespace-pre-wrap">
</div>
<div className="min-h-0 flex-1">
{asset ? (
<pre className="h-full max-h-52 overflow-y-auto rounded-lg bg-black/32 p-3 text-[11px] leading-relaxed text-white/72 ring-1 ring-white/[0.06] whitespace-pre-wrap">
{asset.content}
</pre>
) : (
<div className="grid h-full min-h-28 place-items-center rounded-lg border border-dashed border-white/10 bg-black/16 text-[11px] text-white/28">
</div>
)}
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span className={`text-[10px] ${asset ? 'text-[#dff5a8]' : 'text-white/25'}`}>
{asset ? '完成' : '待生成'}
</span>
<button
onClick={() => setShowPromptId(isOpen ? null : template.id)}
className="text-[10px] text-white/30 hover:text-[#cfe7a7] transition-colors flex items-center gap-1"
@@ -343,18 +355,12 @@ function TextTemplateSection({
</svg>
Prompt
</button>
{isOpen && (
<pre className="p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto">
{template.promptTemplate}
</pre>
)}
</div>
<div className="flex flex-col items-end justify-between text-right shrink-0">
<span className="text-[9px] text-white/25 uppercase tracking-wider">{template.outputFormat}</span>
<span className={`text-[10px] ${asset ? 'text-[#dff5a8]' : 'text-white/25'}`}>
{asset ? '完成' : '待生成'}
</span>
</div>
{isOpen && (
<pre className="mt-3 p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto">
{template.promptTemplate}
</pre>
)}
</div>
);
})}
@@ -375,7 +381,30 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
const [showPromptId, setShowPromptId] = useState<string | null>(null);
const videoTasks = session.videoTasks ?? [];
const byTemplate = new Map(videoTasks.map(task => [task.templateId, task]));
const submittedCount = VIDEO_TEMPLATES.filter(template => byTemplate.has(template.id)).length;
const builtInIds = new Set<string>(VIDEO_TEMPLATES.map(template => template.id));
const extraTasks = videoTasks.filter(task => !builtInIds.has(task.templateId) && !/_part[12]$/.test(task.templateId));
const videoItems = [
...VIDEO_TEMPLATES.map(template => ({
id: template.id,
title: template.title,
description: template.description,
duration: template.duration,
ratio: template.ratio,
promptTemplate: template.promptTemplate,
template,
})),
...extraTasks.map(task => ({
id: task.templateId,
title: task.title,
description: task.description || '已回填的合成视频成片',
duration: task.duration,
ratio: task.ratio,
promptTemplate: task.prompt,
template: null,
})),
];
const submittedCount = videoItems.filter(item => byTemplate.has(item.id)).length;
const totalCount = Math.max(videoItems.length, 1);
return (
<section className={`card overflow-hidden ${locked ? 'opacity-60' : ''}`} id="pack-video" aria-disabled={locked}>
@@ -388,9 +417,9 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden">
<div className="h-full bg-gradient-to-r from-[#e6f578] to-[#8cb478]" style={{ width: `${Math.round((submittedCount / VIDEO_TEMPLATES.length) * 100)}%` }} />
<div className="h-full bg-gradient-to-r from-[#e6f578] to-[#8cb478]" style={{ width: `${Math.round((submittedCount / totalCount) * 100)}%` }} />
</div>
<span className="text-[10px] font-mono text-white/35 shrink-0">{submittedCount}/{VIDEO_TEMPLATES.length}</span>
<span className="text-[10px] font-mono text-white/35 shrink-0">{submittedCount}/{totalCount}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
@@ -398,61 +427,91 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
</div>
</div>
<div className="px-4 pb-4 border-t border-white/[0.05] space-y-2 pt-3">
{VIDEO_TEMPLATES.map(template => {
const isOpen = showPromptId === template.id;
const task = byTemplate.get(template.id);
const loadingThis = videoLoading === template.id;
<div className="grid grid-cols-1 gap-2 border-t border-white/[0.05] p-3 2xl:grid-cols-2">
{videoItems.map(item => {
const isOpen = showPromptId === item.id;
const task = byTemplate.get(item.id);
const loadingThis = videoLoading === item.id;
return (
<div key={template.id} className="grid grid-cols-[72px_minmax(0,1fr)_auto] gap-3 p-3 rounded-[8px] bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
<div className="aspect-square rounded-[8px] bg-gradient-to-br from-[#e6f578]/15 to-[#8cb478]/15 ring-1 ring-[#e6f578]/20 flex flex-col items-center justify-center text-[#e6f578] text-[9px] font-mono gap-1">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5.2v13.6L18.5 12 8 5.2z" />
</svg>
<span className="text-[8px]">{template.duration}s</span>
<div key={item.id} className="grid min-w-0 grid-cols-[128px_minmax(0,1fr)] gap-3 rounded-[8px] bg-white/[0.025] p-2.5 ring-1 ring-white/[0.05] transition-all hover:ring-white/[0.12]">
<div className="relative h-24 overflow-hidden rounded-[8px] bg-black/70">
{task?.videoUrl ? (
<HoverVideoPreview
src={task.videoUrl}
aspectRatio={item.ratio}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 bg-gradient-to-br from-[#e6f578]/12 to-[#8cb478]/12 text-[#e6f578]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5.2v13.6L18.5 12 8 5.2z" />
</svg>
<span className="text-[10px] font-mono">{item.duration}s</span>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-between bg-gradient-to-t from-black/82 to-transparent p-1.5">
<span className="rounded-[6px] bg-black/58 px-1.5 py-0.5 text-[8px] font-semibold text-white/72">{item.ratio}</span>
<span className="rounded-[6px] bg-[#e6f578]/90 px-1.5 py-0.5 text-[8px] font-bold text-[#081006]">{item.duration}s</span>
</div>
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium text-white">{template.title}</span>
<span className="chip chip-neutral text-[10px] py-0">{template.ratio}</span>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[12px] font-semibold text-white">{item.title}</span>
{!item.template && <span className="chip chip-live text-[10px] py-0"></span>}
</div>
<p className="mt-1 line-clamp-1 text-[10px] leading-relaxed text-white/42">{item.description}</p>
</div>
<span className={`shrink-0 text-[10px] ${task ? 'text-[#dff5a8]' : 'text-white/25'}`}>
{task?.status ?? '待提交'}
</span>
</div>
<p className="text-[11px] text-white/45 line-clamp-1">{template.description}</p>
{task && (
<div className="mt-2 rounded-lg bg-black/28 p-2 text-[10px] leading-relaxed text-white/48 ring-1 ring-white/[0.06]">
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-lg bg-black/24 px-2 py-1 text-[9px] leading-relaxed text-white/44 ring-1 ring-white/[0.05]">
<div className="flex min-w-0 items-center gap-2">
<span className="text-[#dff5a8]">{task.status}</span>
{task.taskId && <span className="font-mono text-white/34">{task.taskId}</span>}
{task.videoUrl && (
<a href={task.videoUrl} target="_blank" rel="noreferrer" className="text-[#e6f578] hover:text-white">
</a>
)}
{task.taskId && <span className="truncate font-mono text-white/30">{task.taskId}</span>}
</div>
</div>
)}
<button
onClick={() => setShowPromptId(isOpen ? null : template.id)}
className="text-[10px] text-white/30 hover:text-[#e6f578] transition-colors flex items-center gap-1"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={isOpen ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
Prompt
</button>
<div className="flex flex-wrap items-center justify-between gap-2">
<button
onClick={() => setShowPromptId(isOpen ? null : item.id)}
className="text-[10px] text-white/30 hover:text-[#e6f578] transition-colors flex items-center gap-1"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d={isOpen ? 'M6 9l6 6 6-6' : 'M9 6l6 6-6 6'} strokeLinecap="round" />
</svg>
Prompt
</button>
<div className="flex items-center gap-2">
{task?.videoUrl && (
<>
<a href={task.videoUrl} target="_blank" rel="noreferrer" className="btn btn-outline px-3 py-1.5 text-[11px]">
</a>
<a href={task.videoUrl} download={`${session.id}_${item.id}.mp4`} className="btn btn-outline px-2.5 py-1.5 text-[11px]">
</a>
</>
)}
<button
onClick={() => task?.taskId ? onRefreshVideo(task.taskId) : item.template ? onGenerateVideo(primaryImage, item.template) : undefined}
disabled={Boolean(videoLoading) || locked || task?.status === 'succeeded' || (!item.template && !task?.taskId)}
className="btn btn-primary px-2.5 py-1.5 text-[11px] disabled:opacity-40"
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
>
{locked ? '锁定' : loadingThis ? '...' : task ? task.status === 'succeeded' ? '完成' : '刷新' : '提交'}
</button>
</div>
</div>
{isOpen && (
<pre className="p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto">
{template.promptTemplate}
{item.promptTemplate}
</pre>
)}
</div>
<button
onClick={() => task?.taskId ? onRefreshVideo(task.taskId) : onGenerateVideo(primaryImage, template)}
disabled={Boolean(videoLoading) || locked || task?.status === 'succeeded'}
className="self-center btn btn-primary text-[11px] px-3 py-1.5 disabled:opacity-40"
title={locked ? '四个图片包完成后解锁视频任务' : undefined}
>
{locked ? '锁定' : loadingThis ? '...' : task ? task.status === 'succeeded' ? '完成' : '刷新' : '提交'}
</button>
</div>
);
})}

View File

@@ -19,10 +19,12 @@ type GalleryItem = {
description?: string;
status?: string;
aspectRatio?: string;
mediaType?: 'image' | 'video';
downloadName?: string;
};
type GalleryPanel = {
mode: 'pack' | 'empty' | 'project';
mode: 'pack' | 'empty' | 'project' | 'video';
kind?: PackKind;
label: string;
description: string;
@@ -77,7 +79,26 @@ function galleryForPanel(session: GenSession, activeNav: string): GalleryPanel {
return { mode: 'empty' as const, label: '文字', description: '文字区块暂无图片素材', total: 0, images: [] as GalleryItem[] };
}
if (activeNav === 'pack-video') {
return { mode: 'empty' as const, label: '视频', description: '视频区块暂无图片素材', total: 0, images: [] as GalleryItem[] };
const videos: GalleryItem[] = (session.videoTasks ?? [])
.filter(task => !/_part[12]$/.test(task.templateId))
.filter(task => task.videoUrl)
.map(task => ({
id: task.templateId,
url: task.videoUrl!,
title: task.title,
description: `${task.ratio} · ${task.duration}s · ${task.status}`,
status: task.status,
aspectRatio: task.ratio,
mediaType: 'video' as const,
downloadName: `${session.id}_${task.templateId}.mp4`,
}));
return {
mode: 'video' as const,
label: '视频',
description: '当前项目所有视频成片',
total: videos.length,
images: videos,
};
}
return {
@@ -150,7 +171,11 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
{gallery.images.map((image, index) => (
<div key={image.id} className="gallery-drawer-card">
<div className="gallery-drawer-card__image" style={{ aspectRatio: aspectCss(image.aspectRatio) }}>
<img src={image.url} alt={image.title} />
{image.mediaType === 'video' ? (
<video src={image.url} controls muted playsInline preload="metadata" />
) : (
<img src={image.url} alt={image.title} loading="lazy" decoding="async" />
)}
</div>
<div className="mt-3 min-w-0">
<div className="flex min-w-0 items-center gap-2">
@@ -160,6 +185,11 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
{image.description && (
<p className="mt-1 line-clamp-2 text-[10px] leading-relaxed text-white/42">{image.description}</p>
)}
{image.mediaType === 'video' && (
<a href={image.url} download={image.downloadName} className="mt-2 inline-flex text-[10px] font-semibold text-[#e6f578] hover:text-white">
</a>
)}
</div>
</div>
))}
@@ -175,7 +205,11 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
const centerPreview = preview ? (
<div className="gallery-center-preview" aria-hidden="true">
<img src={preview.url} alt="" />
{preview.mediaType === 'video' ? (
<video src={preview.url} muted autoPlay loop playsInline />
) : (
<img src={preview.url} alt="" decoding="async" />
)}
</div>
) : null;
@@ -231,10 +265,17 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
title={`查看${image.title}`}
aria-label={`查看${image.title}`}
>
<img src={image.url} alt="" className="h-full w-full object-contain" />
{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" 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}
</span>
{image.mediaType === 'video' && (
<span className="absolute right-1 top-1 rounded-[6px] bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold text-[#e6f578]"></span>
)}
{image.status === 'selected' && (
<span className="absolute right-1 top-1 grid h-5 w-5 place-items-center rounded-full bg-[#e6f578] text-[10px] font-bold text-[#081006]"></span>
)}

View File

@@ -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 && (

View File

@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto';
import type {
AssetPack,
AssetTemplate,
CharacterSpec,
ExportManifest,
GenImage,
@@ -151,6 +152,8 @@ function sortTemplatesByAnchor<T extends { id: string; anchorTemplateId?: string
return sorted;
}
const PACK_ASSET_CONCURRENCY = 4;
export async function cleanupCharacterAnchor(opts: {
session: GenSession;
sourceImage: GenImage;
@@ -287,11 +290,13 @@ export async function generateAssetPack(opts: {
});
const characterSpec = cleaned.characterSpec;
const version = 'v01';
const packId = `pack_${opts.kind}_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
const createdAt = Date.now();
const existingPack = opts.session.packs?.find(pack => pack.kind === opts.kind && pack.sourceImageId === opts.sourceImage.id && pack.status !== 'complete');
const packId = existingPack?.id ?? `pack_${opts.kind}_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
const createdAt = existingPack?.createdAt ?? Date.now();
const provider = detectProvider();
const assets: ToyAsset[] = [];
const templateIds = new Set(templates.map(template => template.id));
const assets: ToyAsset[] = (existingPack?.assets ?? []).filter(asset => templateIds.has(asset.templateId));
const pack: AssetPack = {
id: packId,
kind: opts.kind,
@@ -300,13 +305,15 @@ export async function generateAssetPack(opts: {
sourceImageUrl: opts.sourceImage.url,
characterSpec,
assets,
manifestId: `manifest_${packId}`,
manifestId: existingPack?.manifestId ?? `manifest_${packId}`,
createdAt,
version,
status: 'draft',
};
for (const template of templates) {
const generatedTemplateIds = new Set(assets.map(asset => asset.templateId));
const remainingTemplates = templates.filter(template => !generatedTemplateIds.has(template.id));
async function createAsset(template: AssetTemplate): Promise<ToyAsset> {
const assetId = `${opts.kind}_${template.filenamePart}_${randomBytes(3).toString('hex')}`;
const anchorAsset = template.anchorTemplateId
? assets.find(asset => asset.templateId === template.anchorTemplateId)
@@ -318,7 +325,7 @@ export async function generateAssetPack(opts: {
const prompt = renderPrompt(template.promptTemplate, characterSpec, anchorImageUrl);
const preFilledSlot = opts.session.preFilledSlots?.find(slot => slot.templateId === template.id);
if (preFilledSlot) {
assets.push({
return {
id: assetId,
templateId: template.id,
kind: opts.kind,
@@ -346,9 +353,7 @@ export async function generateAssetPack(opts: {
templateFreezeVersion: TEMPLATE_FREEZE_VERSION,
anchorTemplateId: template.anchorTemplateId,
},
});
await opts.onProgress?.(pack);
continue;
};
}
const generated = await generateAssetImage({
packId,
@@ -357,7 +362,7 @@ export async function generateAssetPack(opts: {
anchorImageUrl,
aspectRatio: template.aspectRatio,
});
assets.push({
return {
id: assetId,
templateId: template.id,
kind: opts.kind,
@@ -381,10 +386,45 @@ export async function generateAssetPack(opts: {
anchorTemplateId: template.anchorTemplateId,
raw: generated.raw,
},
});
await opts.onProgress?.(pack);
};
}
const inFlight = new Set<Promise<void>>();
function takeReadyTemplate(): AssetTemplate | undefined {
const index = remainingTemplates.findIndex(template => !template.anchorTemplateId || generatedTemplateIds.has(template.anchorTemplateId));
if (index === -1) return undefined;
const [template] = remainingTemplates.splice(index, 1);
return template;
}
function scheduleReadyTemplates() {
while (inFlight.size < PACK_ASSET_CONCURRENCY) {
const template = takeReadyTemplate();
if (!template) return;
const task = (async () => {
const asset = await createAsset(template);
assets.push(asset);
generatedTemplateIds.add(template.id);
await opts.onProgress?.(pack);
})();
inFlight.add(task);
task.then(
() => inFlight.delete(task),
() => inFlight.delete(task),
);
}
}
while (remainingTemplates.length > 0 || inFlight.size > 0) {
scheduleReadyTemplates();
if (inFlight.size === 0) {
throw new Error(`template anchor cycle or missing root: ${remainingTemplates.map(template => template.id).join(', ')}`);
}
await Promise.race(inFlight);
}
const templateOrder = new Map(templates.map((template, index) => [template.id, index]));
assets.sort((a, b) => (templateOrder.get(a.templateId) ?? 0) - (templateOrder.get(b.templateId) ?? 0));
pack.status = 'complete';
const manifest: ExportManifest = {

View File

@@ -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;
}