Compare commits
12 Commits
7106f3ac80
...
b6d7febbde
| Author | SHA1 | Date | |
|---|---|---|---|
| b6d7febbde | |||
| aec48a7584 | |||
| 0c1a9ef5d6 | |||
| 515ef798ad | |||
| 9520d9328c | |||
| 7524b3caf1 | |||
| da12ed0bbb | |||
| fa6e32b7ad | |||
| e85be866e8 | |||
| 76977543bd | |||
| a3d0c97d23 | |||
| 33f87eb35d |
@@ -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
|
||||
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:df7c3755-a4d2-4e32-b68b-42bbaebc2fda · 时间未知
|
||||
- Codex:019e3ba6-c669-7521-8092-6ccb0dca2428 · 时间未知
|
||||
- Codex:019e40f8-2f9a-73d0-af47-129bc741af46 · 时间未知
|
||||
- Cursor:未找到匹配当前项目的最近会话
|
||||
|
||||
## Claude 最近会话
|
||||
@@ -49,38 +49,56 @@
|
||||
|
||||
## Codex 最近会话
|
||||
|
||||
- Session ID:019e3ba6-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 ID:019e40f8-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
|
||||
|
||||
|
||||
2450
.memory/worklog.json
2450
.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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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), {
|
||||
headers: {
|
||||
const baseHeaders = {
|
||||
'Content-Type': video.type,
|
||||
'Content-Length': String(video.buf.length),
|
||||
'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: {
|
||||
...baseHeaders,
|
||||
'Content-Length': String(video.size),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
@@ -244,6 +289,7 @@ function ProjectBrief({
|
||||
src={primaryImage.url}
|
||||
alt="当前主方案"
|
||||
className="project-primary-image"
|
||||
decoding="async"
|
||||
onLoad={event => {
|
||||
const image = event.currentTarget;
|
||||
if (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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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-medium text-white">{template.title}</span>
|
||||
<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,19 +355,13 @@ function TextTemplateSection({
|
||||
</svg>
|
||||
Prompt
|
||||
</button>
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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,40 +427,57 @@ 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">
|
||||
<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-[8px]">{template.duration}s</span>
|
||||
<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-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={() => setShowPromptId(isOpen ? null : template.id)}
|
||||
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">
|
||||
@@ -439,21 +485,34 @@ function VideoSection({ videoLoading, primaryImage, locked, session, onGenerateV
|
||||
</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 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>
|
||||
</>
|
||||
)}
|
||||
</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"
|
||||
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">
|
||||
{item.promptTemplate}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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