feat: add audit database and safer image review
This commit is contained in:
@@ -710,6 +710,72 @@
|
|||||||
"message": "auto-save 2026-05-19 13:56 (+1, ~1)",
|
"message": "auto-save 2026-05-19 13:56 (+1, ~1)",
|
||||||
"hash": "cdda350",
|
"hash": "cdda350",
|
||||||
"files_changed": 2
|
"files_changed": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T13:58:09+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "feat: add generated image gallery",
|
||||||
|
"hash": "8ddda6a",
|
||||||
|
"files_changed": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T06:00:02Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:feat: add generated image gallery",
|
||||||
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T14:07:36+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-19 14:07 (+1, ~1)",
|
||||||
|
"hash": "eaed492",
|
||||||
|
"files_changed": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T06:10:02Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 14:07 (+1, ~1)",
|
||||||
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T14:13:02+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-19 14:13 (~14)",
|
||||||
|
"hash": "d327949",
|
||||||
|
"files_changed": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T14:18:28+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-19 14:18 (+1, ~10)",
|
||||||
|
"hash": "49db765",
|
||||||
|
"files_changed": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T06:20:02Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 14:18 (+1, ~10)",
|
||||||
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T14:29:21+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-19 14:29 (~2)",
|
||||||
|
"hash": "6dfcd08",
|
||||||
|
"files_changed": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T06:30:02Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 14:29 (~2)",
|
||||||
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-19T14:31:16+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "feat: add audit database and safer image review",
|
||||||
|
"hash": "a4fffd4",
|
||||||
|
"files_changed": 23
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV PORT=4560
|
ENV PORT=4560
|
||||||
|
RUN apk add --no-cache sqlite
|
||||||
RUN addgroup -S nextjs -g 1001 && adduser -S nextjs -u 1001
|
RUN addgroup -S nextjs -g 1001 && adduser -S nextjs -u 1001
|
||||||
COPY --from=builder --chown=nextjs:nextjs /app/package.json /app/package-lock.json ./
|
COPY --from=builder --chown=nextjs:nextjs /app/package.json /app/package-lock.json ./
|
||||||
COPY --from=builder --chown=nextjs:nextjs /app/node_modules ./node_modules
|
COPY --from=builder --chown=nextjs:nextjs /app/node_modules ./node_modules
|
||||||
|
|||||||
5
RULES.md
5
RULES.md
@@ -23,6 +23,8 @@
|
|||||||
## 元数据回写清单
|
## 元数据回写清单
|
||||||
- 改公网域名或迁移部署时,更新 `.project.json.urls` + 本节
|
- 改公网域名或迁移部署时,更新 `.project.json.urls` + 本节
|
||||||
- 数据持久化在 `data/`(gitignored),不入库;上传原图在 `data/uploads/`
|
- 数据持久化在 `data/`(gitignored),不入库;上传原图在 `data/uploads/`
|
||||||
|
- 后端审计库:`data/app.db`(SQLite);Docker 镜像内置 `sqlite3`,记录上传、生成、选择、角色锁定、素材包进度、重做、视频提交、图库/记录查看等事件
|
||||||
|
- 审计兜底:非 Docker 本地如果缺少 `sqlite3`,写入 `data/audit-fallback.jsonl`,不阻断生成流程
|
||||||
- 本地 Docker 使用 `docker-compose.yml`,挂载 `./data:/app/data`,读取 `.env.local`,并强制 `PUBLIC_APP_URL=http://localhost:4560`
|
- 本地 Docker 使用 `docker-compose.yml`,挂载 `./data:/app/data`,读取 `.env.local`,并强制 `PUBLIC_APP_URL=http://localhost:4560`
|
||||||
- VPS 生产 Docker 使用 `docker-compose.prod.yml`,挂载 `./data:/app/data`,读取 `deploy/.env.production`,并强制 `PUBLIC_APP_URL=https://ai-toy.kang-kang.com`
|
- VPS 生产 Docker 使用 `docker-compose.prod.yml`,挂载 `./data:/app/data`,读取 `deploy/.env.production`,并强制 `PUBLIC_APP_URL=https://ai-toy.kang-kang.com`
|
||||||
- VPS 数据持久化在 `/opt/ai-toy-patent-workflow/data`
|
- VPS 数据持久化在 `/opt/ai-toy-patent-workflow/data`
|
||||||
@@ -55,6 +57,9 @@
|
|||||||
- L3:包内其它图,基于对应 L2 根图生成
|
- L3:包内其它图,基于对应 L2 根图生成
|
||||||
- pack 图像生成必须走真实图生图:读取 anchor 图片字节后调用 GPT image edit,不再把参考图 URL 当纯文本拼进 prompt
|
- pack 图像生成必须走真实图生图:读取 anchor 图片字节后调用 GPT image edit,不再把参考图 URL 当纯文本拼进 prompt
|
||||||
- 单张重做接口:`POST /api/assets/[assetId]/regenerate`,必须沿用该图的 anchor
|
- 单张重做接口:`POST /api/assets/[assetId]/regenerate`,必须沿用该图的 anchor
|
||||||
|
- 单张重做需要 `confirmCost=true`;前端会二次确认,服务端会拒绝未确认请求,并对同一 session/asset 加并发锁防连点烧钱
|
||||||
|
- 生成图库:`/api/gallery/[sessionId]`,按真实图片宽高比例展示缩略图,鼠标悬停显示大图;同页可跳转操作记录
|
||||||
|
- 操作记录:`/api/audit/[sessionId]`,读取 `data/app.db` 展示事件流水和图片索引
|
||||||
- 视频参考优先级:宣发白底图 `mkt_white_front` → 专利主图 `patent_front` → L1 白底锚图 → L0 意向图
|
- 视频参考优先级:宣发白底图 `mkt_white_front` → 专利主图 `patent_front` → L1 白底锚图 → L0 意向图
|
||||||
- 上传 API:`POST /api/uploads`,multipart 图片存入 `data/uploads/`,返回 `UploadedImage`
|
- 上传 API:`POST /api/uploads`,multipart 图片存入 `data/uploads/`,返回 `UploadedImage`
|
||||||
- 复刻建项目 API:`POST /api/projects/from-upload`,`mode=replicate` 时跳过批量生图,创建 selected L0,Vision 推断 `CharacterSpec`,并用 strict L1 净化 prompt
|
- 复刻建项目 API:`POST /api/projects/from-upload`,`mode=replicate` 时跳过批量生图,创建 selected L0,Vision 推断 `CharacterSpec`,并用 strict L1 净化 prompt
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
|
import { startGenerationLock } from '@/lib/generationLocks';
|
||||||
import { regeneratePackAsset } from '@/lib/packGenerator';
|
import { regeneratePackAsset } from '@/lib/packGenerator';
|
||||||
|
import { detectProvider } from '@/lib/providers';
|
||||||
import { loadSession, saveSession } from '@/lib/storage';
|
import { loadSession, saveSession } from '@/lib/storage';
|
||||||
import type { RegenerateAssetRequest, RegenerateAssetResponse } from '@/lib/types';
|
import type { RegenerateAssetRequest, RegenerateAssetResponse } from '@/lib/types';
|
||||||
|
|
||||||
@@ -8,24 +11,69 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export async function POST(req: Request, ctx: { params: Promise<{ assetId: string }> }) {
|
export async function POST(req: Request, ctx: { params: Promise<{ assetId: string }> }) {
|
||||||
const { assetId } = await ctx.params;
|
const { assetId } = await ctx.params;
|
||||||
const { sessionId, userRefinement } = (await req.json()) as RegenerateAssetRequest;
|
const { sessionId, userRefinement, confirmCost } = (await req.json()) as RegenerateAssetRequest;
|
||||||
|
|
||||||
if (!assetId || !sessionId) {
|
if (!assetId || !sessionId) {
|
||||||
return NextResponse.json({ error: 'assetId and sessionId required' }, { status: 400 });
|
return NextResponse.json({ error: 'assetId and sessionId required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (confirmCost !== true) {
|
||||||
|
recordEvent({
|
||||||
|
action: 'asset.regenerate_blocked_unconfirmed',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'asset',
|
||||||
|
targetId: assetId,
|
||||||
|
status: 'blocked',
|
||||||
|
provider: detectProvider(),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'confirmCost required for paid regenerate' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const session = await loadSession(sessionId);
|
const session = await loadSession(sessionId);
|
||||||
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const releaseLock = startGenerationLock(`regenerate:${sessionId}:${assetId}`);
|
||||||
|
if (!releaseLock) {
|
||||||
|
recordEvent({
|
||||||
|
action: 'asset.regenerate_blocked_running',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'asset',
|
||||||
|
targetId: assetId,
|
||||||
|
status: 'blocked',
|
||||||
|
provider: detectProvider(),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'asset regenerate already running' }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
recordEvent({
|
||||||
|
action: 'asset.regenerate_started',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'asset',
|
||||||
|
targetId: assetId,
|
||||||
|
status: 'started',
|
||||||
|
provider: detectProvider(),
|
||||||
|
metadata: { refinement: Boolean(userRefinement?.trim()) },
|
||||||
|
});
|
||||||
const regenerated = await regeneratePackAsset({ session, assetId, userRefinement });
|
const regenerated = await regeneratePackAsset({ session, assetId, userRefinement });
|
||||||
await saveSession(session);
|
await saveSession(session);
|
||||||
|
recordEvent({
|
||||||
|
action: 'asset.regenerate_completed',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'asset',
|
||||||
|
targetId: assetId,
|
||||||
|
status: 'ok',
|
||||||
|
provider: regenerated.provider,
|
||||||
|
metadata: { packId: regenerated.pack.id, templateId: regenerated.asset.templateId, url: regenerated.asset.url },
|
||||||
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
asset: regenerated.asset,
|
asset: regenerated.asset,
|
||||||
pack: regenerated.pack,
|
pack: regenerated.pack,
|
||||||
provider: regenerated.provider,
|
provider: regenerated.provider,
|
||||||
} satisfies RegenerateAssetResponse);
|
} satisfies RegenerateAssetResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
recordEvent({ action: 'asset.regenerate_failed', sessionId, targetType: 'asset', targetId: assetId, status: 'error', provider: detectProvider(), message: String(error) });
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
135
src/app/api/audit/[sessionId]/route.ts
Normal file
135
src/app/api/audit/[sessionId]/route.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { listAuditEvents, listImageAssets, recordEvent } from '@/lib/auditDb';
|
||||||
|
import { loadSession } from '@/lib/storage';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function escapeHtml(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactMetadata(metadata: string | null): string {
|
||||||
|
if (!metadata) return '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metadata) as unknown;
|
||||||
|
const raw = JSON.stringify(parsed);
|
||||||
|
return raw.length > 260 ? `${raw.slice(0, 260)}...` : raw;
|
||||||
|
} catch {
|
||||||
|
return metadata.length > 260 ? `${metadata.slice(0, 260)}...` : metadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: string }> }) {
|
||||||
|
const { sessionId } = await ctx.params;
|
||||||
|
if (!/^s_[a-z0-9_-]+$/i.test(sessionId)) {
|
||||||
|
return NextResponse.json({ error: 'bad sessionId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await loadSession(sessionId);
|
||||||
|
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
||||||
|
|
||||||
|
recordEvent({ action: 'audit.view', sessionId, targetType: 'session', targetId: sessionId, status: 'ok' });
|
||||||
|
const events = listAuditEvents(sessionId, 500);
|
||||||
|
const assets = listImageAssets(sessionId, 1000);
|
||||||
|
const imageRows = assets.map(asset => `
|
||||||
|
<tr>
|
||||||
|
<td><a href="${asset.url}" target="_blank" rel="noreferrer">${escapeHtml(asset.filename)}</a></td>
|
||||||
|
<td>${escapeHtml(asset.bucket)}</td>
|
||||||
|
<td>${escapeHtml(asset.kind ?? '')}</td>
|
||||||
|
<td>${escapeHtml(asset.template_id ?? asset.target_id ?? '')}</td>
|
||||||
|
<td>${asset.width && asset.height ? `${asset.width}×${asset.height}` : ''}</td>
|
||||||
|
<td>${asset.current ? '当前' : '历史'}</td>
|
||||||
|
<td>${escapeHtml(asset.status ?? '')}</td>
|
||||||
|
<td>${formatTime(asset.updated_at)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const eventRows = events.map(event => `
|
||||||
|
<tr>
|
||||||
|
<td>${formatTime(event.ts)}</td>
|
||||||
|
<td><span class="status ${escapeHtml(event.status)}">${escapeHtml(event.status)}</span></td>
|
||||||
|
<td>${escapeHtml(event.action)}</td>
|
||||||
|
<td>${escapeHtml(event.target_type ?? '')}</td>
|
||||||
|
<td>${escapeHtml(event.target_id ?? '')}</td>
|
||||||
|
<td>${escapeHtml(event.provider ?? '')}</td>
|
||||||
|
<td>${escapeHtml(event.message ?? compactMetadata(event.metadata))}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${escapeHtml(sessionId)} Audit</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0b0d; color: #f5f5f5; }
|
||||||
|
body { margin: 0; padding: 28px; }
|
||||||
|
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 24px; }
|
||||||
|
h1 { margin: 0 0 8px; font-size: 24px; }
|
||||||
|
h2 { margin: 30px 0 12px; font-size: 18px; }
|
||||||
|
a { color: #c4b5fd; }
|
||||||
|
.muted { color: #9ca3af; }
|
||||||
|
.pillrow { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
|
||||||
|
.pill { border: 1px solid #2d2f36; border-radius: 999px; padding: 6px 10px; background: #15161b; color: #d1d5db; font-size: 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: hidden; }
|
||||||
|
th, td { padding: 9px 10px; border-bottom: 1px solid #252831; text-align: left; font-size: 12px; vertical-align: top; }
|
||||||
|
th { color: #d1d5db; background: #1d1f27; font-weight: 600; position: sticky; top: 0; }
|
||||||
|
td { color: #a7adbb; }
|
||||||
|
tr:hover td { background: #191c23; }
|
||||||
|
.status { display: inline-flex; border-radius: 999px; padding: 2px 7px; background: #2b2f38; color: #d1d5db; }
|
||||||
|
.status.ok { background: rgba(16,185,129,.18); color: #86efac; }
|
||||||
|
.status.error { background: rgba(239,68,68,.18); color: #fca5a5; }
|
||||||
|
.status.blocked { background: rgba(251,191,36,.18); color: #fde68a; }
|
||||||
|
.status.started, .status.running, .status.queued { background: rgba(59,130,246,.18); color: #93c5fd; }
|
||||||
|
.empty { color: #9ca3af; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>操作记录 / 数据库</h1>
|
||||||
|
<div class="muted">Session: ${escapeHtml(sessionId)}</div>
|
||||||
|
<div class="muted">每次上传、生成、选择、锁定、重做和图库查看都会记录在 <code>data/app.db</code></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="pillrow">
|
||||||
|
<span class="pill">事件 ${events.length}</span>
|
||||||
|
<span class="pill">图片索引 ${assets.length}</span>
|
||||||
|
<span class="pill">当前图 ${assets.filter(asset => asset.current).length}</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px;text-align:right"><a href="/api/gallery/${encodeURIComponent(sessionId)}">返回图库</a></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2>图片索引</h2>
|
||||||
|
${assets.length ? `<table>
|
||||||
|
<thead><tr><th>文件</th><th>桶</th><th>包</th><th>槽位/目标</th><th>真实尺寸</th><th>状态</th><th>资产状态</th><th>更新时间</th></tr></thead>
|
||||||
|
<tbody>${imageRows}</tbody>
|
||||||
|
</table>` : '<p class="empty">暂无图片索引。</p>'}
|
||||||
|
|
||||||
|
<h2>操作事件</h2>
|
||||||
|
${events.length ? `<table>
|
||||||
|
<thead><tr><th>时间</th><th>状态</th><th>动作</th><th>目标类型</th><th>目标 ID</th><th>Provider</th><th>信息</th></tr></thead>
|
||||||
|
<tbody>${eventRows}</tbody>
|
||||||
|
</table>` : '<p class="empty">暂无操作记录。</p>'}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return new NextResponse(html, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
||||||
import { loadSession, saveSession } from '@/lib/storage';
|
import { loadSession, saveSession } from '@/lib/storage';
|
||||||
import type { CleanupCharacterRequest, CleanupCharacterResponse } from '@/lib/types';
|
import type { CleanupCharacterRequest, CleanupCharacterResponse } from '@/lib/types';
|
||||||
@@ -20,12 +21,22 @@ export async function POST(req: Request) {
|
|||||||
if (!sourceImage) return NextResponse.json({ error: 'image not found' }, { status: 404 });
|
if (!sourceImage) return NextResponse.json({ error: 'image not found' }, { status: 404 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
recordEvent({ action: 'character.cleanup_started', sessionId, targetType: 'image', targetId: imageId, status: 'started', metadata: { force, preserveLevel } });
|
||||||
const characterSpec = session.characterSpec?.sourceImageId === imageId
|
const characterSpec = session.characterSpec?.sourceImageId === imageId
|
||||||
? session.characterSpec
|
? session.characterSpec
|
||||||
: await buildCharacterSpec(session, sourceImage);
|
: await buildCharacterSpec(session, sourceImage);
|
||||||
const cleaned = await cleanupCharacterAnchor({ session, sourceImage, characterSpec, force, preserveLevel });
|
const cleaned = await cleanupCharacterAnchor({ session, sourceImage, characterSpec, force, preserveLevel });
|
||||||
session.characterSpec = cleaned.characterSpec;
|
session.characterSpec = cleaned.characterSpec;
|
||||||
await saveSession(session);
|
await saveSession(session);
|
||||||
|
recordEvent({
|
||||||
|
action: 'character.cleanup_completed',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'image',
|
||||||
|
targetId: imageId,
|
||||||
|
status: 'ok',
|
||||||
|
provider: cleaned.provider,
|
||||||
|
metadata: { preserveLevel, cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl },
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
characterSpec: cleaned.characterSpec,
|
characterSpec: cleaned.characterSpec,
|
||||||
@@ -33,6 +44,7 @@ export async function POST(req: Request) {
|
|||||||
provider: cleaned.provider,
|
provider: cleaned.provider,
|
||||||
} satisfies CleanupCharacterResponse);
|
} satisfies CleanupCharacterResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
recordEvent({ action: 'character.cleanup_failed', sessionId, targetType: 'image', targetId: imageId, status: 'error', message: String(error) });
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
||||||
import { detectProvider } from '@/lib/providers';
|
import { detectProvider } from '@/lib/providers';
|
||||||
import { loadSession, saveSession } from '@/lib/storage';
|
import { loadSession, saveSession } from '@/lib/storage';
|
||||||
@@ -23,6 +24,7 @@ export async function POST(req: Request) {
|
|||||||
if (!sourceImage) return NextResponse.json({ error: 'subject image not found' }, { status: 404 });
|
if (!sourceImage) return NextResponse.json({ error: 'subject image not found' }, { status: 404 });
|
||||||
|
|
||||||
if (!force && session.characterSpec?.sourceImageId === sourceImage.id && session.characterSpec.cleanReferenceImageUrl) {
|
if (!force && session.characterSpec?.sourceImageId === sourceImage.id && session.characterSpec.cleanReferenceImageUrl) {
|
||||||
|
recordEvent({ action: 'character.lock_from_upload_cached', sessionId, targetType: 'upload', targetId: subjectImageId, status: 'ok', provider: detectProvider() });
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
characterSpec: session.characterSpec,
|
characterSpec: session.characterSpec,
|
||||||
provider: detectProvider(),
|
provider: detectProvider(),
|
||||||
@@ -30,6 +32,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
recordEvent({ action: 'character.lock_from_upload_started', sessionId, targetType: 'upload', targetId: subjectImageId, status: 'started', provider: detectProvider(), metadata: { force, userHint: Boolean(userHint?.trim()) } });
|
||||||
if (userHint?.trim()) session.prompt = userHint.trim();
|
if (userHint?.trim()) session.prompt = userHint.trim();
|
||||||
const characterSpec = await buildCharacterSpec(session, sourceImage);
|
const characterSpec = await buildCharacterSpec(session, sourceImage);
|
||||||
const cleaned = await cleanupCharacterAnchor({
|
const cleaned = await cleanupCharacterAnchor({
|
||||||
@@ -41,12 +44,22 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
session.characterSpec = cleaned.characterSpec;
|
session.characterSpec = cleaned.characterSpec;
|
||||||
await saveSession(session);
|
await saveSession(session);
|
||||||
|
recordEvent({
|
||||||
|
action: 'character.lock_from_upload_completed',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'upload',
|
||||||
|
targetId: subjectImageId,
|
||||||
|
status: 'ok',
|
||||||
|
provider: cleaned.provider,
|
||||||
|
metadata: { name: cleaned.characterSpec.name, cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl },
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
characterSpec: cleaned.characterSpec,
|
characterSpec: cleaned.characterSpec,
|
||||||
provider: cleaned.provider,
|
provider: cleaned.provider,
|
||||||
} satisfies LockCharacterResponse);
|
} satisfies LockCharacterResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
recordEvent({ action: 'character.lock_from_upload_failed', sessionId, targetType: 'upload', targetId: subjectImageId, status: 'error', provider: detectProvider(), message: String(error) });
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
||||||
import { detectProvider } from '@/lib/providers';
|
import { detectProvider } from '@/lib/providers';
|
||||||
import { loadSession, saveSession } from '@/lib/storage';
|
import { loadSession, saveSession } from '@/lib/storage';
|
||||||
@@ -25,10 +26,12 @@ export async function POST(req: Request) {
|
|||||||
characterSpec: session.characterSpec,
|
characterSpec: session.characterSpec,
|
||||||
provider: detectProvider(),
|
provider: detectProvider(),
|
||||||
};
|
};
|
||||||
|
recordEvent({ action: 'character.lock_cached', sessionId, targetType: 'image', targetId: imageId, status: 'ok', provider: response.provider });
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
recordEvent({ action: 'character.lock_started', sessionId, targetType: 'image', targetId: imageId, status: 'started', provider: detectProvider(), metadata: { force } });
|
||||||
const characterSpec = await buildCharacterSpec(session, sourceImage);
|
const characterSpec = await buildCharacterSpec(session, sourceImage);
|
||||||
const cleaned = await cleanupCharacterAnchor({
|
const cleaned = await cleanupCharacterAnchor({
|
||||||
session,
|
session,
|
||||||
@@ -43,8 +46,18 @@ export async function POST(req: Request) {
|
|||||||
characterSpec: cleaned.characterSpec,
|
characterSpec: cleaned.characterSpec,
|
||||||
provider: detectProvider(),
|
provider: detectProvider(),
|
||||||
};
|
};
|
||||||
|
recordEvent({
|
||||||
|
action: 'character.lock_completed',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'image',
|
||||||
|
targetId: imageId,
|
||||||
|
status: 'ok',
|
||||||
|
provider: response.provider,
|
||||||
|
metadata: { name: cleaned.characterSpec.name, cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl },
|
||||||
|
});
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
recordEvent({ action: 'character.lock_failed', sessionId, targetType: 'image', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error) });
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { imageFileInfo, recordEvent, upsertImageAsset } from '@/lib/auditDb';
|
||||||
import { loadSession } from '@/lib/storage';
|
import { loadSession } from '@/lib/storage';
|
||||||
|
import type { AssetPack, ToyAsset } from '@/lib/types';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -14,6 +16,11 @@ type GalleryItem = {
|
|||||||
sizeKb: number;
|
sizeKb: number;
|
||||||
mtime: number;
|
mtime: number;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
aspectRatio?: string;
|
||||||
|
packId?: string;
|
||||||
|
templateId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(input: string): string {
|
function escapeHtml(input: string): string {
|
||||||
@@ -25,6 +32,11 @@ function escapeHtml(input: string): string {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CurrentAssetMeta = {
|
||||||
|
pack: AssetPack;
|
||||||
|
asset: ToyAsset;
|
||||||
|
};
|
||||||
|
|
||||||
function packGroup(filename: string): string {
|
function packGroup(filename: string): string {
|
||||||
const match = filename.match(/^pack_([^_]+)_/);
|
const match = filename.match(/^pack_([^_]+)_/);
|
||||||
return match?.[1] ?? 'unknown';
|
return match?.[1] ?? 'unknown';
|
||||||
@@ -37,7 +49,14 @@ function fileLabel(filename: string): string {
|
|||||||
.replace(/_/g, ' ');
|
.replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listPackItems(currentFilenames: Set<string>): Promise<GalleryItem[]> {
|
function cssRatio(item: GalleryItem): string {
|
||||||
|
if (item.width && item.height) return `${item.width} / ${item.height}`;
|
||||||
|
if (item.aspectRatio && item.aspectRatio !== 'long') return item.aspectRatio.replace(':', ' / ');
|
||||||
|
if (item.aspectRatio === 'long') return '1 / 3';
|
||||||
|
return '1 / 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPackItems(currentAssets: Map<string, CurrentAssetMeta>, sessionId: string): Promise<GalleryItem[]> {
|
||||||
const dir = path.join(process.cwd(), 'data', 'packs');
|
const dir = path.join(process.cwd(), 'data', 'packs');
|
||||||
let files: string[] = [];
|
let files: string[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -50,16 +69,43 @@ async function listPackItems(currentFilenames: Set<string>): Promise<GalleryItem
|
|||||||
files
|
files
|
||||||
.filter(filename => /\.(png|jpe?g|webp)$/i.test(filename))
|
.filter(filename => /\.(png|jpe?g|webp)$/i.test(filename))
|
||||||
.map(async filename => {
|
.map(async filename => {
|
||||||
const stat = await fs.stat(path.join(dir, filename));
|
const filePath = path.join(dir, filename);
|
||||||
return {
|
const stat = await fs.stat(filePath);
|
||||||
|
const info = imageFileInfo(filePath);
|
||||||
|
const meta = currentAssets.get(filename);
|
||||||
|
const item = {
|
||||||
filename,
|
filename,
|
||||||
url: `/api/img/packs/${encodeURIComponent(filename)}`,
|
url: `/api/img/packs/${encodeURIComponent(filename)}`,
|
||||||
group: packGroup(filename),
|
group: meta?.pack.kind ?? packGroup(filename),
|
||||||
label: fileLabel(filename),
|
label: meta?.asset.title ?? fileLabel(filename),
|
||||||
sizeKb: Math.round(stat.size / 1024),
|
sizeKb: Math.round(stat.size / 1024),
|
||||||
mtime: stat.mtimeMs,
|
mtime: stat.mtimeMs,
|
||||||
current: currentFilenames.has(filename),
|
current: Boolean(meta),
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
aspectRatio: meta?.asset.aspectRatio,
|
||||||
|
packId: meta?.pack.id,
|
||||||
|
templateId: meta?.asset.templateId,
|
||||||
} satisfies GalleryItem;
|
} satisfies GalleryItem;
|
||||||
|
upsertImageAsset({
|
||||||
|
filename,
|
||||||
|
url: item.url,
|
||||||
|
bucket: 'packs',
|
||||||
|
sessionId,
|
||||||
|
packId: meta?.pack.id,
|
||||||
|
targetId: meta?.asset.id,
|
||||||
|
kind: meta?.pack.kind ?? packGroup(filename),
|
||||||
|
templateId: meta?.asset.templateId,
|
||||||
|
title: item.label,
|
||||||
|
aspectRatio: item.aspectRatio,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
current: Boolean(meta),
|
||||||
|
origin: meta ? 'current' : 'archived',
|
||||||
|
status: meta?.asset.status ?? 'archived',
|
||||||
|
});
|
||||||
|
return item;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,14 +118,15 @@ async function listPackItems(currentFilenames: Set<string>): Promise<GalleryItem
|
|||||||
|
|
||||||
function renderItems(title: string, items: GalleryItem[]): string {
|
function renderItems(title: string, items: GalleryItem[]): string {
|
||||||
const cards = items.map(item => `
|
const cards = items.map(item => `
|
||||||
<article class="card">
|
<article class="card" style="--ratio: ${cssRatio(item)}">
|
||||||
<a href="${item.url}" target="_blank" rel="noreferrer">
|
<a href="${item.url}" target="_blank" rel="noreferrer">
|
||||||
<img src="${item.url}" loading="lazy" alt="${escapeHtml(item.filename)}" />
|
<img class="thumb" src="${item.url}" loading="lazy" alt="${escapeHtml(item.filename)}" />
|
||||||
|
<img class="zoom" src="${item.url}" loading="lazy" alt="" />
|
||||||
</a>
|
</a>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<strong>${escapeHtml(item.group)} · ${escapeHtml(item.label)}</strong>
|
<strong>${escapeHtml(item.group)} · ${escapeHtml(item.label)}</strong>
|
||||||
<span>${escapeHtml(item.filename)}</span>
|
<span>${escapeHtml(item.filename)}</span>
|
||||||
<span>${item.sizeKb} KB</span>
|
<span>${item.width && item.height ? `${item.width}×${item.height} · ` : ''}${item.sizeKb} KB</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -98,6 +145,7 @@ function renderPage(opts: {
|
|||||||
archived: GalleryItem[];
|
archived: GalleryItem[];
|
||||||
packsSummary: string;
|
packsSummary: string;
|
||||||
sourceHtml: string;
|
sourceHtml: string;
|
||||||
|
auditUrl: string;
|
||||||
}): string {
|
}): string {
|
||||||
const total = opts.current.length + opts.archived.length;
|
const total = opts.current.length + opts.archived.length;
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
@@ -117,9 +165,15 @@ function renderPage(opts: {
|
|||||||
.pill { border: 1px solid #2d2f36; border-radius: 999px; padding: 6px 10px; background: #15161b; color: #d1d5db; font-size: 12px; }
|
.pill { border: 1px solid #2d2f36; border-radius: 999px; padding: 6px 10px; background: #15161b; color: #d1d5db; font-size: 12px; }
|
||||||
.source { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; }
|
.source { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
.source a { color: #f5f5f5; text-decoration: none; border: 1px solid #2d2f36; border-radius: 10px; padding: 8px 10px; background: #15161b; }
|
.source a { color: #f5f5f5; text-decoration: none; border: 1px solid #2d2f36; border-radius: 10px; padding: 8px 10px; background: #15161b; }
|
||||||
|
.toplinks { display: flex; gap: 10px; align-items: center; justify-content: flex-end; margin-top: 10px; }
|
||||||
|
.toplinks a { color: #f5f5f5; text-decoration: none; border: 1px solid #3b3e47; border-radius: 10px; padding: 8px 10px; background: #1e2027; font-size: 12px; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 14px; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 14px; }
|
||||||
.card { background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: hidden; }
|
.card { position: relative; background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: visible; }
|
||||||
.card img { width: 100%; aspect-ratio: 1 / 1; object-fit: contain; display: block; background: #fff; }
|
.card a { position: relative; display: block; border-radius: 12px 12px 0 0; background: #fff; overflow: hidden; }
|
||||||
|
.thumb { width: 100%; aspect-ratio: var(--ratio, 1 / 1); object-fit: contain; display: block; background: #fff; }
|
||||||
|
.zoom { display: none; position: absolute; left: 50%; top: 10px; width: min(620px, 82vw); max-height: 82vh; object-fit: contain; transform: translate(-50%, -6%); z-index: 100; background: #fff; border: 1px solid #3b3e47; border-radius: 14px; box-shadow: 0 24px 90px rgba(0,0,0,.72); pointer-events: none; }
|
||||||
|
.card:hover { z-index: 20; border-color: #565b68; }
|
||||||
|
.card:hover .zoom { display: block; }
|
||||||
.meta { padding: 10px; display: grid; gap: 5px; font-size: 11px; color: #9ca3af; }
|
.meta { padding: 10px; display: grid; gap: 5px; font-size: 11px; color: #9ca3af; }
|
||||||
.meta strong { color: #f5f5f5; font-size: 12px; line-height: 1.25; }
|
.meta strong { color: #f5f5f5; font-size: 12px; line-height: 1.25; }
|
||||||
.empty { color: #9ca3af; }
|
.empty { color: #9ca3af; }
|
||||||
@@ -132,12 +186,15 @@ function renderPage(opts: {
|
|||||||
<div class="muted">Session: ${escapeHtml(opts.sessionId)}</div>
|
<div class="muted">Session: ${escapeHtml(opts.sessionId)}</div>
|
||||||
${opts.sourceHtml}
|
${opts.sourceHtml}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div class="pillrow">
|
<div class="pillrow">
|
||||||
<span class="pill">总图 ${total}</span>
|
<span class="pill">总图 ${total}</span>
|
||||||
<span class="pill">当前有效 ${opts.current.length}</span>
|
<span class="pill">当前有效 ${opts.current.length}</span>
|
||||||
<span class="pill">历史未挂载 ${opts.archived.length}</span>
|
<span class="pill">历史未挂载 ${opts.archived.length}</span>
|
||||||
${opts.packsSummary}
|
${opts.packsSummary}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toplinks"><a href="${opts.auditUrl}">操作记录 / 数据库</a></div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
${renderItems('当前 session 有效图', opts.current)}
|
${renderItems('当前 session 有效图', opts.current)}
|
||||||
${renderItems('历史生成图 / 未挂载但已保存', opts.archived)}
|
${renderItems('历史生成图 / 未挂载但已保存', opts.archived)}
|
||||||
@@ -154,14 +211,15 @@ export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: str
|
|||||||
const session = await loadSession(sessionId);
|
const session = await loadSession(sessionId);
|
||||||
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
||||||
|
|
||||||
const currentFilenames = new Set(
|
recordEvent({ action: 'gallery.view', sessionId, targetType: 'session', targetId: sessionId, status: 'ok' });
|
||||||
(session.packs ?? [])
|
const currentAssets = new Map<string, CurrentAssetMeta>();
|
||||||
.flatMap(pack => pack.assets ?? [])
|
for (const pack of session.packs ?? []) {
|
||||||
.map(asset => asset.url?.match(/\/api\/img\/packs\/([^/?#]+)$/)?.[1])
|
for (const asset of pack.assets ?? []) {
|
||||||
.filter((filename): filename is string => Boolean(filename))
|
const filename = asset.url?.match(/\/api\/img\/packs\/([^/?#]+)$/)?.[1];
|
||||||
.map(filename => decodeURIComponent(filename)),
|
if (filename) currentAssets.set(decodeURIComponent(filename), { pack, asset });
|
||||||
);
|
}
|
||||||
const allItems = await listPackItems(currentFilenames);
|
}
|
||||||
|
const allItems = await listPackItems(currentAssets, sessionId);
|
||||||
const sourceLinks = [
|
const sourceLinks = [
|
||||||
session.characterSpec?.cleanReferenceImageUrl ? `<a href="${session.characterSpec.cleanReferenceImageUrl}" target="_blank" rel="noreferrer">L1 白底锚图</a>` : '',
|
session.characterSpec?.cleanReferenceImageUrl ? `<a href="${session.characterSpec.cleanReferenceImageUrl}" target="_blank" rel="noreferrer">L1 白底锚图</a>` : '',
|
||||||
...(session.uploadedImages ?? []).map(upload => `<a href="${upload.url}" target="_blank" rel="noreferrer">上传图 ${escapeHtml(upload.originalFilename ?? upload.filename)}</a>`),
|
...(session.uploadedImages ?? []).map(upload => `<a href="${upload.url}" target="_blank" rel="noreferrer">上传图 ${escapeHtml(upload.originalFilename ?? upload.filename)}</a>`),
|
||||||
@@ -176,6 +234,7 @@ export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: str
|
|||||||
archived: allItems.filter(item => !item.current),
|
archived: allItems.filter(item => !item.current),
|
||||||
packsSummary,
|
packsSummary,
|
||||||
sourceHtml: sourceLinks ? `<div class="source">${sourceLinks}</div>` : '',
|
sourceHtml: sourceLinks ? `<div class="source">${sourceLinks}</div>` : '',
|
||||||
|
auditUrl: `/api/audit/${encodeURIComponent(sessionId)}`,
|
||||||
}), {
|
}), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { detectProvider, generateGptImages, generateMock } from '@/lib/providers';
|
import { detectProvider, generateGptImages, generateMock } from '@/lib/providers';
|
||||||
import { saveSession, saveGeneratedImage, saveRefImage } from '@/lib/storage';
|
import { saveSession, saveGeneratedImage, saveRefImage } from '@/lib/storage';
|
||||||
import type { GenerateRequest, GenerateResponse, GenSession } from '@/lib/types';
|
import type { GenerateRequest, GenerateResponse, GenSession } from '@/lib/types';
|
||||||
@@ -18,6 +19,15 @@ export async function POST(req: Request) {
|
|||||||
const sessionId = `s_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
|
const sessionId = `s_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
|
||||||
const finalPrompt = style ? `${prompt}, style: ${style}` : prompt;
|
const finalPrompt = style ? `${prompt}, style: ${style}` : prompt;
|
||||||
const provider = detectProvider();
|
const provider = detectProvider();
|
||||||
|
recordEvent({
|
||||||
|
action: 'idea.generate_started',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'session',
|
||||||
|
targetId: sessionId,
|
||||||
|
status: 'started',
|
||||||
|
provider,
|
||||||
|
metadata: { count, refImages: refImages.length, style: style ?? null, promptLength: finalPrompt.length },
|
||||||
|
});
|
||||||
|
|
||||||
const savedRefUrls: string[] = [];
|
const savedRefUrls: string[] = [];
|
||||||
for (let i = 0; i < refImages.length; i++) {
|
for (let i = 0; i < refImages.length; i++) {
|
||||||
@@ -35,6 +45,16 @@ export async function POST(req: Request) {
|
|||||||
? await generateGptImages({ sessionId, prompt: finalPrompt, count, refImages: savedRefUrls })
|
? await generateGptImages({ sessionId, prompt: finalPrompt, count, refImages: savedRefUrls })
|
||||||
: await generateMock({ sessionId, prompt: finalPrompt, count });
|
: await generateMock({ sessionId, prompt: finalPrompt, count });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
recordEvent({
|
||||||
|
action: 'idea.generate_failed',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'session',
|
||||||
|
targetId: sessionId,
|
||||||
|
status: 'error',
|
||||||
|
provider,
|
||||||
|
message: String(e),
|
||||||
|
metadata: { count },
|
||||||
|
});
|
||||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +76,15 @@ export async function POST(req: Request) {
|
|||||||
images,
|
images,
|
||||||
};
|
};
|
||||||
await saveSession(session);
|
await saveSession(session);
|
||||||
|
recordEvent({
|
||||||
|
action: 'idea.generate_completed',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'session',
|
||||||
|
targetId: sessionId,
|
||||||
|
status: 'ok',
|
||||||
|
provider,
|
||||||
|
metadata: { images: images.length },
|
||||||
|
});
|
||||||
|
|
||||||
const resp: GenerateResponse = { sessionId, images, provider };
|
const resp: GenerateResponse = { sessionId, images, provider };
|
||||||
return NextResponse.json(resp);
|
return NextResponse.json(resp);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { startGenerationLock } from '@/lib/generationLocks';
|
import { startGenerationLock } from '@/lib/generationLocks';
|
||||||
import { generateAssetPack } from '@/lib/packGenerator';
|
import { generateAssetPack } from '@/lib/packGenerator';
|
||||||
import { detectProvider } from '@/lib/providers';
|
import { detectProvider } from '@/lib/providers';
|
||||||
@@ -44,6 +45,14 @@ export async function POST(req: Request) {
|
|||||||
const baseSourceImage = sourceImage;
|
const baseSourceImage = sourceImage;
|
||||||
const releaseAllLock = startGenerationLock(`packs:all:${sessionId}:${imageId}`);
|
const releaseAllLock = startGenerationLock(`packs:all:${sessionId}:${imageId}`);
|
||||||
if (!releaseAllLock) {
|
if (!releaseAllLock) {
|
||||||
|
recordEvent({
|
||||||
|
action: 'packs.generate_all_blocked_running',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'pack_all',
|
||||||
|
targetId: imageId,
|
||||||
|
status: 'blocked',
|
||||||
|
provider: detectProvider(),
|
||||||
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
background: true,
|
background: true,
|
||||||
@@ -59,9 +68,11 @@ export async function POST(req: Request) {
|
|||||||
let workingSession: GenSession = baseSession;
|
let workingSession: GenSession = baseSession;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
recordEvent({ action: 'packs.generate_all_started', sessionId, targetType: 'pack_all', targetId: imageId, status: 'started', provider: detectProvider(), metadata: { background } });
|
||||||
for (const kind of PACK_ORDER) {
|
for (const kind of PACK_ORDER) {
|
||||||
const existingPack = workingSession.packs?.find(pack => pack.kind === kind && isCompletePack(pack, imageId));
|
const existingPack = workingSession.packs?.find(pack => pack.kind === kind && isCompletePack(pack, imageId));
|
||||||
if (existingPack) {
|
if (existingPack) {
|
||||||
|
recordEvent({ action: 'pack.generate_skipped_existing', sessionId, targetType: 'pack', targetId: existingPack.id, status: 'ok', provider: detectProvider(), metadata: { kind, assets: existingPack.assets.length } });
|
||||||
const existingManifest = workingSession.exports?.find(manifest => (
|
const existingManifest = workingSession.exports?.find(manifest => (
|
||||||
manifest.packKind === kind &&
|
manifest.packKind === kind &&
|
||||||
manifest.source.sourceImageId === imageId &&
|
manifest.source.sourceImageId === imageId &&
|
||||||
@@ -73,15 +84,23 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const releasePackLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`);
|
const releasePackLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`);
|
||||||
if (!releasePackLock) continue;
|
if (!releasePackLock) {
|
||||||
|
recordEvent({ action: 'pack.generate_blocked_running', sessionId, targetType: 'pack', targetId: kind, status: 'blocked', provider: detectProvider(), metadata: { imageId, fromAll: true } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
recordEvent({ action: 'pack.generate_started', sessionId, targetType: 'pack', targetId: kind, status: 'started', provider: detectProvider(), metadata: { imageId, fromAll: true } });
|
||||||
const generated = await generateAssetPack({
|
const generated = await generateAssetPack({
|
||||||
session: workingSession,
|
session: workingSession,
|
||||||
sourceImage: baseSourceImage,
|
sourceImage: baseSourceImage,
|
||||||
kind,
|
kind,
|
||||||
onProgress: progressPack => persistPackProgress(workingSession, imageId, progressPack),
|
onProgress: async progressPack => {
|
||||||
|
await persistPackProgress(workingSession, imageId, progressPack);
|
||||||
|
recordEvent({ action: 'pack.generate_progress', sessionId, targetType: 'pack', targetId: progressPack.id, status: 'running', provider: detectProvider(), metadata: { kind, assets: progressPack.assets.length, packStatus: progressPack.status, fromAll: true } });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
recordEvent({ action: 'pack.generate_completed', sessionId, targetType: 'pack', targetId: generated.pack.id, status: 'ok', provider: generated.provider, metadata: { kind, assets: generated.pack.assets.length, fromAll: true } });
|
||||||
packs.push(generated.pack);
|
packs.push(generated.pack);
|
||||||
manifests.push(generated.manifest);
|
manifests.push(generated.manifest);
|
||||||
workingSession = {
|
workingSession = {
|
||||||
@@ -102,6 +121,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await saveSession(workingSession);
|
await saveSession(workingSession);
|
||||||
|
recordEvent({ action: 'packs.generate_all_completed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'ok', provider: detectProvider(), metadata: { packs: packs.length, manifests: manifests.length } });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
packs,
|
packs,
|
||||||
@@ -114,7 +134,10 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (background) {
|
if (background) {
|
||||||
void run().catch(error => console.error('[packs:all] background generation failed', error));
|
void run().catch(error => {
|
||||||
|
recordEvent({ action: 'packs.generate_all_failed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error), metadata: { background: true } });
|
||||||
|
console.error('[packs:all] background generation failed', error);
|
||||||
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
background: true,
|
background: true,
|
||||||
@@ -125,6 +148,7 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
return NextResponse.json(await run());
|
return NextResponse.json(await run());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
recordEvent({ action: 'packs.generate_all_failed', sessionId, targetType: 'pack_all', targetId: imageId, status: 'error', provider: detectProvider(), message: String(error), metadata: { background: false } });
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { startGenerationLock } from '@/lib/generationLocks';
|
import { startGenerationLock } from '@/lib/generationLocks';
|
||||||
import { generateAssetPack } from '@/lib/packGenerator';
|
import { generateAssetPack } from '@/lib/packGenerator';
|
||||||
import { detectProvider } from '@/lib/providers';
|
import { detectProvider } from '@/lib/providers';
|
||||||
@@ -38,6 +39,15 @@ export async function POST(req: Request) {
|
|||||||
const baseSourceImage = sourceImage;
|
const baseSourceImage = sourceImage;
|
||||||
const releaseLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`);
|
const releaseLock = startGenerationLock(`pack:${sessionId}:${imageId}:${kind}`);
|
||||||
if (!releaseLock) {
|
if (!releaseLock) {
|
||||||
|
recordEvent({
|
||||||
|
action: 'pack.generate_blocked_running',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'pack',
|
||||||
|
targetId: kind,
|
||||||
|
status: 'blocked',
|
||||||
|
provider: detectProvider(),
|
||||||
|
metadata: { imageId, kind },
|
||||||
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
background: true,
|
background: true,
|
||||||
@@ -50,11 +60,31 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
|
recordEvent({
|
||||||
|
action: 'pack.generate_started',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'pack',
|
||||||
|
targetId: kind,
|
||||||
|
status: 'started',
|
||||||
|
provider: detectProvider(),
|
||||||
|
metadata: { imageId, background },
|
||||||
|
});
|
||||||
const { pack, manifest, provider } = await generateAssetPack({
|
const { pack, manifest, provider } = await generateAssetPack({
|
||||||
session: baseSession,
|
session: baseSession,
|
||||||
sourceImage: baseSourceImage,
|
sourceImage: baseSourceImage,
|
||||||
kind,
|
kind,
|
||||||
onProgress: progressPack => persistPackProgress(baseSession, imageId, progressPack),
|
onProgress: async progressPack => {
|
||||||
|
await persistPackProgress(baseSession, imageId, progressPack);
|
||||||
|
recordEvent({
|
||||||
|
action: 'pack.generate_progress',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'pack',
|
||||||
|
targetId: progressPack.id,
|
||||||
|
status: 'running',
|
||||||
|
provider: detectProvider(),
|
||||||
|
metadata: { kind, assets: progressPack.assets.length, packStatus: progressPack.status },
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
baseSession.characterSpec = pack.characterSpec;
|
baseSession.characterSpec = pack.characterSpec;
|
||||||
baseSession.packs = [
|
baseSession.packs = [
|
||||||
@@ -66,6 +96,15 @@ export async function POST(req: Request) {
|
|||||||
manifest,
|
manifest,
|
||||||
];
|
];
|
||||||
await saveSession(baseSession);
|
await saveSession(baseSession);
|
||||||
|
recordEvent({
|
||||||
|
action: 'pack.generate_completed',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'pack',
|
||||||
|
targetId: pack.id,
|
||||||
|
status: 'ok',
|
||||||
|
provider,
|
||||||
|
metadata: { kind, assets: pack.assets.length, manifestId: manifest.id },
|
||||||
|
});
|
||||||
return { pack, manifest, provider } satisfies GeneratePackResponse;
|
return { pack, manifest, provider } satisfies GeneratePackResponse;
|
||||||
} finally {
|
} finally {
|
||||||
release();
|
release();
|
||||||
@@ -73,7 +112,10 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (background) {
|
if (background) {
|
||||||
void run().catch(error => console.error(`[pack:${kind}] background generation failed`, error));
|
void run().catch(error => {
|
||||||
|
recordEvent({ action: 'pack.generate_failed', sessionId, targetType: 'pack', targetId: kind, status: 'error', provider: detectProvider(), message: String(error), metadata: { imageId, background: true } });
|
||||||
|
console.error(`[pack:${kind}] background generation failed`, error);
|
||||||
|
});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
background: true,
|
background: true,
|
||||||
@@ -87,6 +129,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
recordEvent({ action: 'pack.generate_failed', sessionId, targetType: 'pack', targetId: kind, status: 'error', provider: detectProvider(), message: String(error), metadata: { imageId, background: false } });
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
||||||
import { detectProvider, generateGptImageEdit, generateMock } from '@/lib/providers';
|
import { detectProvider, generateGptImageEdit, generateMock } from '@/lib/providers';
|
||||||
import { saveGeneratedImage, saveSession } from '@/lib/storage';
|
import { saveGeneratedImage, saveSession } from '@/lib/storage';
|
||||||
@@ -155,12 +156,36 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = `s_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
|
const sessionId = `s_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
|
||||||
|
recordEvent({
|
||||||
|
action: 'upload_project.create_started',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'session',
|
||||||
|
targetId: sessionId,
|
||||||
|
status: 'started',
|
||||||
|
provider: detectProvider(),
|
||||||
|
metadata: { mode: body.mode, uploads: body.uploadedImages.length, count: body.count ?? null },
|
||||||
|
});
|
||||||
const response = body.mode === 'remix'
|
const response = body.mode === 'remix'
|
||||||
? await createRemixSession(body, sessionId)
|
? await createRemixSession(body, sessionId)
|
||||||
: await createReplicateOrExtendSession(body, sessionId);
|
: await createReplicateOrExtendSession(body, sessionId);
|
||||||
|
recordEvent({
|
||||||
|
action: 'upload_project.create_completed',
|
||||||
|
sessionId,
|
||||||
|
targetType: 'session',
|
||||||
|
targetId: sessionId,
|
||||||
|
status: 'ok',
|
||||||
|
provider: response.provider,
|
||||||
|
metadata: { mode: body.mode, images: response.images?.length ?? 1, preFilledSlots: response.preFilledSlots?.length ?? 0 },
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(response satisfies ProjectFromUploadResponse);
|
return NextResponse.json(response satisfies ProjectFromUploadResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
recordEvent({
|
||||||
|
action: 'upload_project.create_failed',
|
||||||
|
status: 'error',
|
||||||
|
provider: detectProvider(),
|
||||||
|
message: String(error),
|
||||||
|
});
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { copyToSelected, loadSession, saveSession } from '@/lib/storage';
|
import { copyToSelected, loadSession, saveSession } from '@/lib/storage';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -26,5 +27,13 @@ export async function POST(req: Request) {
|
|||||||
img.status = 'pending';
|
img.status = 'pending';
|
||||||
}
|
}
|
||||||
await saveSession(session);
|
await saveSession(session);
|
||||||
|
recordEvent({
|
||||||
|
action: `image.${action}`,
|
||||||
|
sessionId,
|
||||||
|
targetType: 'image',
|
||||||
|
targetId: imageId,
|
||||||
|
status: 'ok',
|
||||||
|
metadata: { selectedUrl: img.meta?.selectedUrl ?? null },
|
||||||
|
});
|
||||||
return NextResponse.json({ ok: true, image: img });
|
return NextResponse.json({ ok: true, image: img });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { saveUploadedImage } from '@/lib/storage';
|
import { saveUploadedImage } from '@/lib/storage';
|
||||||
import type { UploadImageResponse, UploadedImageRole } from '@/lib/types';
|
import type { UploadImageResponse, UploadedImageRole } from '@/lib/types';
|
||||||
|
|
||||||
@@ -48,6 +49,13 @@ export async function POST(req: Request) {
|
|||||||
accessoryName,
|
accessoryName,
|
||||||
needsCleanup,
|
needsCleanup,
|
||||||
});
|
});
|
||||||
|
recordEvent({
|
||||||
|
action: 'upload.completed',
|
||||||
|
targetType: 'upload',
|
||||||
|
targetId: uploadedImage.id,
|
||||||
|
status: 'ok',
|
||||||
|
metadata: { filename: uploadedImage.filename, originalFilename: uploadedImage.originalFilename, role, size: file.size },
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ uploadedImage } satisfies UploadImageResponse);
|
return NextResponse.json({ uploadedImage } satisfies UploadImageResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { generateSeedanceVideo } from '@/lib/videoProviders';
|
import { generateSeedanceVideo } from '@/lib/videoProviders';
|
||||||
import type { VideoGenerationRequest } from '@/lib/types';
|
import type { VideoGenerationRequest } from '@/lib/types';
|
||||||
|
|
||||||
@@ -8,9 +9,13 @@ export const dynamic = 'force-dynamic';
|
|||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const body = (await req.json()) as VideoGenerationRequest;
|
const body = (await req.json()) as VideoGenerationRequest;
|
||||||
try {
|
try {
|
||||||
return NextResponse.json(await generateSeedanceVideo(body));
|
recordEvent({ action: 'video.generate_started', targetType: 'video', status: 'started', provider: 'seedance', metadata: { ratio: body.ratio, duration: body.duration, hasImage: Boolean(body.imageUrl), refs: body.references?.length ?? 0 } });
|
||||||
|
const response = await generateSeedanceVideo(body);
|
||||||
|
recordEvent({ action: 'video.generate_submitted', targetType: 'video', targetId: response.taskId ?? response.status, status: 'queued', provider: 'seedance', metadata: { status: response.status } });
|
||||||
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = String(error);
|
const message = String(error);
|
||||||
|
recordEvent({ action: 'video.generate_failed', targetType: 'video', status: 'error', provider: 'seedance', message });
|
||||||
return NextResponse.json({ error: message }, {
|
return NextResponse.json({ error: message }, {
|
||||||
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
|
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { recordEvent } from '@/lib/auditDb';
|
||||||
import { getSeedanceVideoTask } from '@/lib/videoProviders';
|
import { getSeedanceVideoTask } from '@/lib/videoProviders';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -7,9 +8,12 @@ export const dynamic = 'force-dynamic';
|
|||||||
export async function GET(_req: Request, ctx: { params: Promise<{ taskId: string }> }) {
|
export async function GET(_req: Request, ctx: { params: Promise<{ taskId: string }> }) {
|
||||||
const { taskId } = await ctx.params;
|
const { taskId } = await ctx.params;
|
||||||
try {
|
try {
|
||||||
return NextResponse.json(await getSeedanceVideoTask(taskId));
|
const response = await getSeedanceVideoTask(taskId);
|
||||||
|
recordEvent({ action: 'video.status_checked', targetType: 'video', targetId: taskId, status: 'ok', provider: 'seedance', metadata: { status: response.status } });
|
||||||
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = String(error);
|
const message = String(error);
|
||||||
|
recordEvent({ action: 'video.status_failed', targetType: 'video', targetId: taskId, status: 'error', provider: 'seedance', message });
|
||||||
return NextResponse.json({ error: message }, {
|
return NextResponse.json({ error: message }, {
|
||||||
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
|
status: message.includes('SEEDANCE_API_KEY missing') ? 503 : 500,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export default function Home() {
|
|||||||
const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, {
|
const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionId: current.id, userRefinement }),
|
body: JSON.stringify({ sessionId: current.id, userRefinement, confirmCost: true }),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
alert('单张重做失败:' + (await r.text()));
|
alert('单张重做失败:' + (await r.text()));
|
||||||
@@ -299,6 +299,26 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{current && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={`/api/gallery/${encodeURIComponent(current.id)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="chip chip-neutral hover:border-violet-300/40 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
图库
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/api/audit/${encodeURIComponent(current.id)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="chip chip-neutral hover:border-violet-300/40 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
记录
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className={provider === 'gpt' ? 'chip chip-live' : provider === '?' ? 'chip chip-neutral' : 'chip chip-mock'}>
|
<span className={provider === 'gpt' ? 'chip chip-live' : provider === '?' ? 'chip chip-neutral' : 'chip chip-mock'}>
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'gpt' ? 'bg-emerald-400' : provider === '?' ? 'bg-white/40' : 'bg-amber-400'}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'gpt' ? 'bg-emerald-400' : provider === '?' ? 'bg-white/40' : 'bg-amber-400'}`} />
|
||||||
{provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
|
{provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ const ASPECT_PX: Record<AssetTemplate['aspectRatio'], string> = {
|
|||||||
'long': '1024×3200',
|
'long': '1024×3200',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function aspectCss(aspectRatio: AssetTemplate['aspectRatio'] | string | undefined): string {
|
||||||
|
if (!aspectRatio) return '1 / 1';
|
||||||
|
if (aspectRatio === 'long') return '1 / 3';
|
||||||
|
return aspectRatio.replace(':', ' / ');
|
||||||
|
}
|
||||||
|
|
||||||
function manifestUrl(sessionId: string, kind: PackKind, version: string) {
|
function manifestUrl(sessionId: string, kind: PackKind, version: string) {
|
||||||
return `/api/export/${sessionId}_${kind}_${version}_manifest.json`;
|
return `/api/export/${sessionId}_${kind}_${version}_manifest.json`;
|
||||||
}
|
}
|
||||||
@@ -45,6 +51,8 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
|
|||||||
const ready = !!asset;
|
const ready = !!asset;
|
||||||
async function handleRedo() {
|
async function handleRedo() {
|
||||||
if (!asset || !onRegenerate || regenerating) return;
|
if (!asset || !onRegenerate || regenerating) return;
|
||||||
|
const ok = window.confirm('重做这 1 张会重新调用图片模型并产生费用。确认重做?');
|
||||||
|
if (!ok) return;
|
||||||
setRegenerating(true);
|
setRegenerating(true);
|
||||||
try {
|
try {
|
||||||
await onRegenerate(asset.id, refinement);
|
await onRegenerate(asset.id, refinement);
|
||||||
@@ -55,13 +63,18 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[72px_1fr_auto] gap-3 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
|
<div className="grid grid-cols-[76px_1fr_auto] gap-3 p-3 rounded-2xl bg-white/[0.025] ring-1 ring-white/[0.05] hover:ring-white/[0.1] transition-all">
|
||||||
{/* thumbnail */}
|
{/* thumbnail */}
|
||||||
<div className="relative aspect-square rounded-xl overflow-hidden bg-white/[0.04] ring-1 ring-white/[0.07] flex items-center justify-center">
|
<div className="group/thumb relative w-[72px] h-[72px] rounded-xl overflow-visible bg-white/[0.04] ring-1 ring-white/[0.07] flex items-center justify-center">
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<img src={asset!.url} alt={template.title} className="w-full h-full object-cover" />
|
<>
|
||||||
|
<img src={asset!.url} alt={template.title} className="max-w-full max-h-full object-contain rounded-lg bg-white" />
|
||||||
|
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[80] hidden -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-white p-2 shadow-2xl ring-1 ring-white/20 group-hover/thumb:block" style={{ width: 'min(640px, 86vw)' }}>
|
||||||
|
<img src={asset!.url} alt="" className="max-h-[82vh] w-full object-contain" style={{ aspectRatio: aspectCss(asset!.aspectRatio) }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1 rounded-lg bg-black/25 px-2 py-1" style={{ aspectRatio: aspectCss(template.aspectRatio) }}>
|
||||||
<span className="text-[9px] text-white/30 font-mono">{template.aspectRatio}</span>
|
<span className="text-[9px] text-white/30 font-mono">{template.aspectRatio}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -99,6 +112,17 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
|
|||||||
anchor: {asset.anchorAssetId ?? asset.anchorImageUrl}
|
anchor: {asset.anchorAssetId ?? asset.anchorImageUrl}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{ready && onRegenerate && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-amber-400/10 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-[10px] text-amber-200/45">成本操作</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRedo(value => !value)}
|
||||||
|
className="text-[10px] text-amber-200/70 hover:text-amber-100 rounded-lg px-2 py-1 ring-1 ring-amber-300/15 bg-amber-300/[0.04]"
|
||||||
|
>
|
||||||
|
{showRedo ? '收起重做' : '打开重做'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showRedo && ready && (
|
{showRedo && ready && (
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<input
|
<input
|
||||||
@@ -110,9 +134,9 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
|
|||||||
<button
|
<button
|
||||||
onClick={handleRedo}
|
onClick={handleRedo}
|
||||||
disabled={regenerating}
|
disabled={regenerating}
|
||||||
className="btn btn-primary text-[10px] px-2 py-1 disabled:opacity-40"
|
className="rounded-lg bg-amber-500/80 hover:bg-amber-400 text-black text-[10px] px-2 py-1 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{regenerating ? '...' : '确认'}
|
{regenerating ? '...' : '确认付费重做'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -125,14 +149,6 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
|
|||||||
<span className={`text-[10px] ${ready ? 'text-emerald-300' : 'text-white/25'}`}>
|
<span className={`text-[10px] ${ready ? 'text-emerald-300' : 'text-white/25'}`}>
|
||||||
{ready ? `L${asset!.derivationLevel}` : '待生成'}
|
{ready ? `L${asset!.derivationLevel}` : '待生成'}
|
||||||
</span>
|
</span>
|
||||||
{ready && onRegenerate && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowRedo(value => !value)}
|
|
||||||
className="text-[10px] text-violet-300 hover:text-violet-200"
|
|
||||||
>
|
|
||||||
重做
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,8 +172,16 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
|
|||||||
const total = templates.length;
|
const total = templates.length;
|
||||||
const progressPct = Math.round((generatedCount / total) * 100);
|
const progressPct = Math.round((generatedCount / total) * 100);
|
||||||
|
|
||||||
|
function handleGenerateClick() {
|
||||||
|
if (pack) {
|
||||||
|
const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${generatedCount} 张图,重跑会重新调用图片模型并产生费用。确认继续?`);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
onGenerate();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card overflow-hidden" id={`pack-${kind}`}>
|
<section className="card" id={`pack-${kind}`}>
|
||||||
{/* header — always visible */}
|
{/* header — always visible */}
|
||||||
<div className="p-4 flex items-center gap-3">
|
<div className="p-4 flex items-center gap-3">
|
||||||
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${accent.bar} flex items-center justify-center text-white text-[11px] font-bold shrink-0`}>
|
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${accent.bar} flex items-center justify-center text-white text-[11px] font-bold shrink-0`}>
|
||||||
@@ -191,7 +215,7 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onGenerate}
|
onClick={handleGenerateClick}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`${pack ? 'btn btn-outline' : 'btn btn-primary'} text-xs px-3 py-1.5 disabled:opacity-40`}
|
className={`${pack ? 'btn btn-outline' : 'btn btn-primary'} text-xs px-3 py-1.5 disabled:opacity-40`}
|
||||||
>
|
>
|
||||||
@@ -199,7 +223,7 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate,
|
|||||||
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<svg width="12" height="12" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
) : pack ? '重做' : `生成 ${total} 张`}
|
) : pack ? '危险重跑' : `生成 ${total} 张`}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(v => !v)}
|
onClick={() => setOpen(v => !v)}
|
||||||
@@ -496,7 +520,7 @@ export default function PackPanel({
|
|||||||
<div className="text-[10px]">图片位</div>
|
<div className="text-[10px]">图片位</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-16 h-16 rounded-2xl overflow-hidden ring-1 ring-white/15 shadow-glow-violet shrink-0">
|
<div className="relative w-16 h-16 rounded-2xl overflow-hidden ring-1 ring-white/15 shadow-glow-violet shrink-0">
|
||||||
<img src={primaryImage.url} alt="selected" className="w-full h-full object-cover" />
|
<img src={primaryImage.url} alt="selected" className="w-full h-full object-contain bg-white" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||||
<div className="absolute bottom-1 inset-x-0 text-center text-[8px] font-semibold text-white/80 uppercase tracking-wider">主方案</div>
|
<div className="absolute bottom-1 inset-x-0 text-center text-[8px] font-semibold text-white/80 uppercase tracking-wider">主方案</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,7 +547,10 @@ export default function PackPanel({
|
|||||||
{characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}
|
{characterLoading ? '锁定中' : session.characterSpec ? '刷新角色设定' : '锁定角色设定'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onGenerateAll(primaryImage)}
|
onClick={() => {
|
||||||
|
const ok = window.confirm(`一键全包最多会生成 ${totalImageSlots} 张图片,费用会明显高于单张。确认启动?`);
|
||||||
|
if (ok) onGenerateAll(primaryImage);
|
||||||
|
}}
|
||||||
disabled={allLoading || !!loadingKind || characterLoading}
|
disabled={allLoading || !!loadingKind || characterLoading}
|
||||||
className="btn btn-primary text-xs disabled:opacity-40"
|
className="btn btn-primary text-xs disabled:opacity-40"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ export default function ResultGrid({ images, onAction }: ResultGridProps) {
|
|||||||
key={img.id}
|
key={img.id}
|
||||||
className={`tile group ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`}
|
className={`tile group ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`}
|
||||||
>
|
>
|
||||||
<img src={img.url} alt={`gen ${i + 1}`} className="w-full h-full object-cover" />
|
<img src={img.url} alt={`gen ${i + 1}`} className="w-full h-full object-contain bg-white" />
|
||||||
|
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[80] hidden -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-white p-2 shadow-2xl ring-1 ring-white/20 group-hover:block" style={{ width: 'min(640px, 86vw)' }}>
|
||||||
|
<img src={img.url} alt="" className="max-h-[82vh] w-full object-contain" />
|
||||||
|
</div>
|
||||||
<div className="tile-keynum">{i + 1}</div>
|
<div className="tile-keynum">{i + 1}</div>
|
||||||
|
|
||||||
{img.status === 'selected' && (
|
{img.status === 'selected' && (
|
||||||
|
|||||||
308
src/lib/auditDb.ts
Normal file
308
src/lib/auditDb.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||||
|
const DB_PATH = path.join(DATA_DIR, 'app.db');
|
||||||
|
const FALLBACK_PATH = path.join(DATA_DIR, 'audit-fallback.jsonl');
|
||||||
|
|
||||||
|
type SqlValue = string | number | boolean | null | undefined;
|
||||||
|
|
||||||
|
export type AuditEventInput = {
|
||||||
|
action: string;
|
||||||
|
sessionId?: string | null;
|
||||||
|
targetType?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
status?: 'ok' | 'started' | 'queued' | 'running' | 'error' | 'blocked';
|
||||||
|
provider?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
metadata?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditEventRow = {
|
||||||
|
id: number;
|
||||||
|
ts: number;
|
||||||
|
action: string;
|
||||||
|
session_id: string | null;
|
||||||
|
target_type: string | null;
|
||||||
|
target_id: string | null;
|
||||||
|
status: string;
|
||||||
|
provider: string | null;
|
||||||
|
message: string | null;
|
||||||
|
metadata: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageAssetInput = {
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
bucket?: string;
|
||||||
|
sessionId?: string | null;
|
||||||
|
packId?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
kind?: string | null;
|
||||||
|
templateId?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
aspectRatio?: string | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
sizeBytes?: number | null;
|
||||||
|
current?: boolean;
|
||||||
|
origin?: 'current' | 'archived' | 'generated' | 'selected' | 'upload' | 'anchor' | 'ref' | 'pack';
|
||||||
|
status?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageAssetRow = {
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
bucket: string;
|
||||||
|
session_id: string | null;
|
||||||
|
pack_id: string | null;
|
||||||
|
target_id: string | null;
|
||||||
|
kind: string | null;
|
||||||
|
template_id: string | null;
|
||||||
|
title: string | null;
|
||||||
|
aspect_ratio: string | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
size_bytes: number | null;
|
||||||
|
current: number;
|
||||||
|
origin: string | null;
|
||||||
|
status: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureDataDir() {
|
||||||
|
mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function q(value: SqlValue): string {
|
||||||
|
if (value === null || value === undefined) return 'NULL';
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
|
||||||
|
if (typeof value === 'boolean') return value ? '1' : '0';
|
||||||
|
return `'${value.replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeSql(sql: string, readonly = false): string {
|
||||||
|
ensureDataDir();
|
||||||
|
try {
|
||||||
|
const args = readonly ? ['-readonly', DB_PATH] : [DB_PATH];
|
||||||
|
return execFileSync('sqlite3', args, {
|
||||||
|
input: `${sql}\n`,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
ts: Date.now(),
|
||||||
|
action: 'sqlite_unavailable',
|
||||||
|
message: String(error),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
writeFileSync(FALLBACK_PATH, `${line}\n`, { flag: 'a' });
|
||||||
|
} catch {
|
||||||
|
// Ignore audit fallback failures; product flow should not fail because logging failed.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnExists(table: string, column: string): boolean {
|
||||||
|
try {
|
||||||
|
const output = execFileSync('sqlite3', ['-readonly', '-json', DB_PATH, `PRAGMA table_info(${table});`], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
const rows = output ? JSON.parse(output) as Array<{ name?: string }> : [];
|
||||||
|
return rows.some(row => row.name === column);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDb() {
|
||||||
|
executeSql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
session_id TEXT,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'ok',
|
||||||
|
provider TEXT,
|
||||||
|
message TEXT,
|
||||||
|
metadata TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_events_session_ts ON audit_events(session_id, ts DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_events_action_ts ON audit_events(action, ts DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS image_assets (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
bucket TEXT NOT NULL DEFAULT 'packs',
|
||||||
|
session_id TEXT,
|
||||||
|
pack_id TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
kind TEXT,
|
||||||
|
template_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
aspect_ratio TEXT,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
size_bytes INTEGER,
|
||||||
|
current INTEGER NOT NULL DEFAULT 0,
|
||||||
|
origin TEXT,
|
||||||
|
status TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_assets_session ON image_assets(session_id, current, kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_assets_pack ON image_assets(pack_id);
|
||||||
|
`);
|
||||||
|
if (!columnExists('image_assets', 'target_id')) {
|
||||||
|
executeSql('ALTER TABLE image_assets ADD COLUMN target_id TEXT;');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordEvent(input: AuditEventInput) {
|
||||||
|
try {
|
||||||
|
ensureDb();
|
||||||
|
executeSql(`
|
||||||
|
INSERT INTO audit_events (ts, action, session_id, target_type, target_id, status, provider, message, metadata)
|
||||||
|
VALUES (
|
||||||
|
${Date.now()},
|
||||||
|
${q(input.action)},
|
||||||
|
${q(input.sessionId)},
|
||||||
|
${q(input.targetType)},
|
||||||
|
${q(input.targetId)},
|
||||||
|
${q(input.status ?? 'ok')},
|
||||||
|
${q(input.provider)},
|
||||||
|
${q(input.message)},
|
||||||
|
${q(input.metadata === undefined ? null : JSON.stringify(input.metadata))}
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
ensureDataDir();
|
||||||
|
writeFileSync(FALLBACK_PATH, `${JSON.stringify({ ts: Date.now(), ...input })}\n`, { flag: 'a' });
|
||||||
|
} catch {
|
||||||
|
// Logging must never break product actions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertImageAsset(input: ImageAssetInput) {
|
||||||
|
try {
|
||||||
|
ensureDb();
|
||||||
|
const now = Date.now();
|
||||||
|
executeSql(`
|
||||||
|
INSERT INTO image_assets (
|
||||||
|
filename, url, bucket, session_id, pack_id, kind, template_id, title, aspect_ratio,
|
||||||
|
target_id, width, height, size_bytes, current, origin, status, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${q(input.filename)}, ${q(input.url)}, ${q(input.bucket ?? 'packs')}, ${q(input.sessionId)},
|
||||||
|
${q(input.packId)}, ${q(input.kind)}, ${q(input.templateId)}, ${q(input.title)},
|
||||||
|
${q(input.aspectRatio)}, ${q(input.targetId)}, ${q(input.width)}, ${q(input.height)}, ${q(input.sizeBytes)},
|
||||||
|
${input.current ? 1 : 0}, ${q(input.origin)}, ${q(input.status)}, ${now}, ${now}
|
||||||
|
)
|
||||||
|
ON CONFLICT(filename) DO UPDATE SET
|
||||||
|
url = excluded.url,
|
||||||
|
bucket = excluded.bucket,
|
||||||
|
session_id = COALESCE(excluded.session_id, image_assets.session_id),
|
||||||
|
pack_id = COALESCE(excluded.pack_id, image_assets.pack_id),
|
||||||
|
target_id = COALESCE(excluded.target_id, image_assets.target_id),
|
||||||
|
kind = COALESCE(excluded.kind, image_assets.kind),
|
||||||
|
template_id = COALESCE(excluded.template_id, image_assets.template_id),
|
||||||
|
title = COALESCE(excluded.title, image_assets.title),
|
||||||
|
aspect_ratio = COALESCE(excluded.aspect_ratio, image_assets.aspect_ratio),
|
||||||
|
width = COALESCE(excluded.width, image_assets.width),
|
||||||
|
height = COALESCE(excluded.height, image_assets.height),
|
||||||
|
size_bytes = COALESCE(excluded.size_bytes, image_assets.size_bytes),
|
||||||
|
current = excluded.current,
|
||||||
|
origin = COALESCE(excluded.origin, image_assets.origin),
|
||||||
|
status = COALESCE(excluded.status, image_assets.status),
|
||||||
|
updated_at = excluded.updated_at;
|
||||||
|
`);
|
||||||
|
} catch {
|
||||||
|
// Keep gallery and generation usable even if sqlite is temporarily unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJsonRows<T>(sql: string): T[] {
|
||||||
|
try {
|
||||||
|
ensureDb();
|
||||||
|
const output = execFileSync('sqlite3', ['-readonly', '-json', DB_PATH, sql], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
return output ? JSON.parse(output) as T[] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAuditEvents(sessionId?: string, limit = 200): AuditEventRow[] {
|
||||||
|
const where = sessionId ? `WHERE session_id = ${q(sessionId)}` : '';
|
||||||
|
return readJsonRows<AuditEventRow>(`
|
||||||
|
SELECT id, ts, action, session_id, target_type, target_id, status, provider, message, metadata
|
||||||
|
FROM audit_events
|
||||||
|
${where}
|
||||||
|
ORDER BY ts DESC
|
||||||
|
LIMIT ${Math.max(1, Math.min(limit, 1000))}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listImageAssets(sessionId?: string, limit = 500): ImageAssetRow[] {
|
||||||
|
const where = sessionId ? `WHERE session_id = ${q(sessionId)}` : '';
|
||||||
|
return readJsonRows<ImageAssetRow>(`
|
||||||
|
SELECT filename, url, bucket, session_id, pack_id, target_id, kind, template_id, title, aspect_ratio,
|
||||||
|
width, height, size_bytes, current, origin, status, created_at, updated_at
|
||||||
|
FROM image_assets
|
||||||
|
${where}
|
||||||
|
ORDER BY current DESC, updated_at DESC
|
||||||
|
LIMIT ${Math.max(1, Math.min(limit, 2000))}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectImageDimensions(buf: Buffer, filename: string): { width: number | null; height: number | null } {
|
||||||
|
if (buf.length >= 24 && buf.toString('ascii', 1, 4) === 'PNG') {
|
||||||
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buf.length >= 10 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
|
||||||
|
const chunk = buf.toString('ascii', 12, 16);
|
||||||
|
if (chunk === 'VP8X' && buf.length >= 30) {
|
||||||
|
const width = 1 + buf.readUIntLE(24, 3);
|
||||||
|
const height = 1 + buf.readUIntLE(27, 3);
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\.(jpe?g)$/i.test(filename) && buf[0] === 0xff && buf[1] === 0xd8) {
|
||||||
|
let offset = 2;
|
||||||
|
while (offset < buf.length) {
|
||||||
|
if (buf[offset] !== 0xff) break;
|
||||||
|
const marker = buf[offset + 1];
|
||||||
|
const length = buf.readUInt16BE(offset + 2);
|
||||||
|
if (marker >= 0xc0 && marker <= 0xc3 && offset + 8 < buf.length) {
|
||||||
|
return { height: buf.readUInt16BE(offset + 5), width: buf.readUInt16BE(offset + 7) };
|
||||||
|
}
|
||||||
|
offset += 2 + length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: null, height: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageFileInfo(filePath: string): { width: number | null; height: number | null; sizeBytes: number | null } {
|
||||||
|
try {
|
||||||
|
if (!existsSync(filePath)) return { width: null, height: null, sizeBytes: null };
|
||||||
|
const stat = statSync(filePath);
|
||||||
|
const buf = readFileSync(filePath);
|
||||||
|
return { ...detectImageDimensions(buf, filePath), sizeBytes: stat.size };
|
||||||
|
} catch {
|
||||||
|
return { width: null, height: null, sizeBytes: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { imageFileInfo, recordEvent, upsertImageAsset } from './auditDb';
|
||||||
import type { ExportManifest, GenSession, UploadedImage, UploadedImageRole } from './types';
|
import type { ExportManifest, GenSession, UploadedImage, UploadedImageRole } from './types';
|
||||||
|
|
||||||
const ROOT = path.join(process.cwd(), 'data');
|
const ROOT = path.join(process.cwd(), 'data');
|
||||||
@@ -13,6 +14,15 @@ const ANCHOR_DIR = path.join(ROOT, 'anchors');
|
|||||||
const UPLOAD_DIR = path.join(ROOT, 'uploads');
|
const UPLOAD_DIR = path.join(ROOT, 'uploads');
|
||||||
const EXPORT_DIR = path.join(ROOT, 'exports');
|
const EXPORT_DIR = path.join(ROOT, 'exports');
|
||||||
|
|
||||||
|
const BUCKET_DIRS = {
|
||||||
|
generated: GEN_DIR,
|
||||||
|
selected: SEL_DIR,
|
||||||
|
refs: REF_DIR,
|
||||||
|
packs: PACK_DIR,
|
||||||
|
anchors: ANCHOR_DIR,
|
||||||
|
uploads: UPLOAD_DIR,
|
||||||
|
} as const;
|
||||||
|
|
||||||
async function ensureDirs() {
|
async function ensureDirs() {
|
||||||
await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_DIR, ANCHOR_DIR, UPLOAD_DIR, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true })));
|
await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_DIR, ANCHOR_DIR, UPLOAD_DIR, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true })));
|
||||||
}
|
}
|
||||||
@@ -20,6 +30,20 @@ async function ensureDirs() {
|
|||||||
export async function saveSession(s: GenSession) {
|
export async function saveSession(s: GenSession) {
|
||||||
await ensureDirs();
|
await ensureDirs();
|
||||||
await fs.writeFile(path.join(SESS_DIR, `${s.id}.json`), JSON.stringify(s, null, 2), 'utf-8');
|
await fs.writeFile(path.join(SESS_DIR, `${s.id}.json`), JSON.stringify(s, null, 2), 'utf-8');
|
||||||
|
indexSessionImages(s);
|
||||||
|
recordEvent({
|
||||||
|
action: 'session.saved',
|
||||||
|
sessionId: s.id,
|
||||||
|
targetType: 'session',
|
||||||
|
targetId: s.id,
|
||||||
|
status: 'ok',
|
||||||
|
metadata: {
|
||||||
|
images: s.images.length,
|
||||||
|
packs: s.packs?.length ?? 0,
|
||||||
|
packAssets: (s.packs ?? []).reduce((sum, pack) => sum + pack.assets.length, 0),
|
||||||
|
inputMode: s.inputMode ?? 'idea',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSession(id: string): Promise<GenSession | null> {
|
export async function loadSession(id: string): Promise<GenSession | null> {
|
||||||
@@ -59,14 +83,131 @@ function safePart(input: string): string {
|
|||||||
.slice(0, 60) || 'image';
|
.slice(0, 60) || 'image';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localFileFromUrl(url: string): { bucket: keyof typeof BUCKET_DIRS; filename: string; filePath: string } | null {
|
||||||
|
const match = url.match(/^\/api\/img\/(generated|selected|refs|packs|anchors|uploads)\/([^/?#]+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const bucket = match[1] as keyof typeof BUCKET_DIRS;
|
||||||
|
const filename = decodeURIComponent(match[2]);
|
||||||
|
return { bucket, filename, filePath: path.join(BUCKET_DIRS[bucket], filename) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function packKindFromPackId(packId: string): string | null {
|
||||||
|
return packId.match(/^pack_([^_]+)_/)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexSessionImages(session: GenSession) {
|
||||||
|
for (const image of session.images) {
|
||||||
|
const local = localFileFromUrl(image.url);
|
||||||
|
if (!local) continue;
|
||||||
|
const info = imageFileInfo(local.filePath);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename: local.filename,
|
||||||
|
url: image.url,
|
||||||
|
bucket: local.bucket,
|
||||||
|
sessionId: session.id,
|
||||||
|
targetId: image.id,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: image.status === 'selected' || image.status === 'pending',
|
||||||
|
origin: local.bucket === 'selected' ? 'selected' : local.bucket === 'uploads' ? 'upload' : 'generated',
|
||||||
|
status: image.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const upload of session.uploadedImages ?? []) {
|
||||||
|
const local = localFileFromUrl(upload.url);
|
||||||
|
if (!local) continue;
|
||||||
|
const info = imageFileInfo(local.filePath);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename: local.filename,
|
||||||
|
url: upload.url,
|
||||||
|
bucket: local.bucket,
|
||||||
|
sessionId: session.id,
|
||||||
|
targetId: upload.id,
|
||||||
|
title: upload.originalFilename ?? upload.filename,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: true,
|
||||||
|
origin: 'upload',
|
||||||
|
status: upload.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.characterSpec?.cleanReferenceImageUrl) {
|
||||||
|
const local = localFileFromUrl(session.characterSpec.cleanReferenceImageUrl);
|
||||||
|
if (local) {
|
||||||
|
const info = imageFileInfo(local.filePath);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename: local.filename,
|
||||||
|
url: session.characterSpec.cleanReferenceImageUrl,
|
||||||
|
bucket: local.bucket,
|
||||||
|
sessionId: session.id,
|
||||||
|
targetId: session.characterSpec.sourceImageId,
|
||||||
|
title: `${session.characterSpec.name} L1 anchor`,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: true,
|
||||||
|
origin: 'anchor',
|
||||||
|
status: 'clean_reference',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pack of session.packs ?? []) {
|
||||||
|
for (const asset of pack.assets) {
|
||||||
|
const local = localFileFromUrl(asset.url);
|
||||||
|
if (!local) continue;
|
||||||
|
const info = imageFileInfo(local.filePath);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename: local.filename,
|
||||||
|
url: asset.url,
|
||||||
|
bucket: local.bucket,
|
||||||
|
sessionId: session.id,
|
||||||
|
packId: pack.id,
|
||||||
|
kind: pack.kind,
|
||||||
|
templateId: asset.templateId,
|
||||||
|
title: asset.title,
|
||||||
|
aspectRatio: asset.aspectRatio,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: true,
|
||||||
|
origin: local.bucket === 'uploads' ? 'upload' : 'pack',
|
||||||
|
status: asset.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveGeneratedImage(sessionId: string, imageId: string, dataUrl: string): Promise<string> {
|
export async function saveGeneratedImage(sessionId: string, imageId: string, dataUrl: string): Promise<string> {
|
||||||
await ensureDirs();
|
await ensureDirs();
|
||||||
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
if (!m) throw new Error('Invalid data URL');
|
if (!m) throw new Error('Invalid data URL');
|
||||||
const ext = extFromMime(m[1]);
|
const ext = extFromMime(m[1]);
|
||||||
const file = path.join(GEN_DIR, `${sessionId}_${imageId}.${ext}`);
|
const file = path.join(GEN_DIR, `${sessionId}_${imageId}.${ext}`);
|
||||||
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
|
const buffer = Buffer.from(m[2], 'base64');
|
||||||
return `/api/img/generated/${sessionId}_${imageId}.${ext}`;
|
await fs.writeFile(file, buffer);
|
||||||
|
const filename = `${sessionId}_${imageId}.${ext}`;
|
||||||
|
const url = `/api/img/generated/${filename}`;
|
||||||
|
const info = imageFileInfo(file);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename,
|
||||||
|
url,
|
||||||
|
bucket: 'generated',
|
||||||
|
sessionId,
|
||||||
|
targetId: imageId,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: false,
|
||||||
|
origin: 'generated',
|
||||||
|
status: 'saved',
|
||||||
|
});
|
||||||
|
recordEvent({ action: 'image.saved', sessionId, targetType: 'image', targetId: imageId, status: 'ok', metadata: { bucket: 'generated', filename, bytes: buffer.length } });
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function savePackImage(packId: string, assetId: string, dataUrl: string): Promise<string> {
|
export async function savePackImage(packId: string, assetId: string, dataUrl: string): Promise<string> {
|
||||||
@@ -75,8 +216,27 @@ export async function savePackImage(packId: string, assetId: string, dataUrl: st
|
|||||||
if (!m) throw new Error('Invalid data URL');
|
if (!m) throw new Error('Invalid data URL');
|
||||||
const ext = extFromMime(m[1]);
|
const ext = extFromMime(m[1]);
|
||||||
const file = path.join(PACK_DIR, `${packId}_${assetId}.${ext}`);
|
const file = path.join(PACK_DIR, `${packId}_${assetId}.${ext}`);
|
||||||
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
|
const buffer = Buffer.from(m[2], 'base64');
|
||||||
return `/api/img/packs/${packId}_${assetId}.${ext}`;
|
await fs.writeFile(file, buffer);
|
||||||
|
const filename = `${packId}_${assetId}.${ext}`;
|
||||||
|
const url = `/api/img/packs/${filename}`;
|
||||||
|
const info = imageFileInfo(file);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename,
|
||||||
|
url,
|
||||||
|
bucket: 'packs',
|
||||||
|
packId,
|
||||||
|
kind: packKindFromPackId(packId),
|
||||||
|
targetId: assetId,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: false,
|
||||||
|
origin: 'pack',
|
||||||
|
status: 'saved',
|
||||||
|
});
|
||||||
|
recordEvent({ action: 'image.saved', targetType: 'pack_asset', targetId: assetId, status: 'ok', metadata: { bucket: 'packs', packId, filename, bytes: buffer.length } });
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveAnchorImage(sessionId: string, imageId: string, dataUrl: string): Promise<string> {
|
export async function saveAnchorImage(sessionId: string, imageId: string, dataUrl: string): Promise<string> {
|
||||||
@@ -86,8 +246,26 @@ export async function saveAnchorImage(sessionId: string, imageId: string, dataUr
|
|||||||
const ext = extFromMime(m[1]);
|
const ext = extFromMime(m[1]);
|
||||||
const safeImageId = imageId.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
const safeImageId = imageId.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||||||
const filename = `${sessionId}_${safeImageId}_clean.${ext}`;
|
const filename = `${sessionId}_${safeImageId}_clean.${ext}`;
|
||||||
await fs.writeFile(path.join(ANCHOR_DIR, filename), Buffer.from(m[2], 'base64'));
|
const file = path.join(ANCHOR_DIR, filename);
|
||||||
return `/api/img/anchors/${filename}`;
|
const buffer = Buffer.from(m[2], 'base64');
|
||||||
|
await fs.writeFile(file, buffer);
|
||||||
|
const url = `/api/img/anchors/${filename}`;
|
||||||
|
const info = imageFileInfo(file);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename,
|
||||||
|
url,
|
||||||
|
bucket: 'anchors',
|
||||||
|
sessionId,
|
||||||
|
targetId: imageId,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: true,
|
||||||
|
origin: 'anchor',
|
||||||
|
status: 'clean_reference',
|
||||||
|
});
|
||||||
|
recordEvent({ action: 'image.saved', sessionId, targetType: 'anchor', targetId: imageId, status: 'ok', metadata: { bucket: 'anchors', filename, bytes: buffer.length } });
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveUploadedImage(opts: {
|
export async function saveUploadedImage(opts: {
|
||||||
@@ -104,8 +282,9 @@ export async function saveUploadedImage(opts: {
|
|||||||
const ext = extFromMime(opts.mimeType);
|
const ext = extFromMime(opts.mimeType);
|
||||||
const baseName = opts.originalFilename ? safePart(path.parse(opts.originalFilename).name) : 'upload';
|
const baseName = opts.originalFilename ? safePart(path.parse(opts.originalFilename).name) : 'upload';
|
||||||
const filename = `${id}_${baseName}.${ext}`;
|
const filename = `${id}_${baseName}.${ext}`;
|
||||||
await fs.writeFile(path.join(UPLOAD_DIR, filename), opts.buffer);
|
const file = path.join(UPLOAD_DIR, filename);
|
||||||
return {
|
await fs.writeFile(file, opts.buffer);
|
||||||
|
const uploaded = {
|
||||||
id,
|
id,
|
||||||
url: `/api/img/uploads/${filename}`,
|
url: `/api/img/uploads/${filename}`,
|
||||||
filename,
|
filename,
|
||||||
@@ -116,6 +295,22 @@ export async function saveUploadedImage(opts: {
|
|||||||
accessoryName: opts.accessoryName,
|
accessoryName: opts.accessoryName,
|
||||||
needsCleanup: opts.needsCleanup ?? true,
|
needsCleanup: opts.needsCleanup ?? true,
|
||||||
};
|
};
|
||||||
|
const info = imageFileInfo(file);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename,
|
||||||
|
url: uploaded.url,
|
||||||
|
bucket: 'uploads',
|
||||||
|
targetId: id,
|
||||||
|
title: uploaded.originalFilename ?? filename,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: true,
|
||||||
|
origin: 'upload',
|
||||||
|
status: opts.role,
|
||||||
|
});
|
||||||
|
recordEvent({ action: 'upload.saved', targetType: 'upload', targetId: id, status: 'ok', metadata: { filename, originalFilename: opts.originalFilename, role: opts.role, bytes: opts.buffer.length } });
|
||||||
|
return uploaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyToSelected(sessionId: string, imageId: string, srcUrl: string): Promise<string> {
|
export async function copyToSelected(sessionId: string, imageId: string, srcUrl: string): Promise<string> {
|
||||||
@@ -127,7 +322,23 @@ export async function copyToSelected(sessionId: string, imageId: string, srcUrl:
|
|||||||
const src = path.join(GEN_DIR, filename);
|
const src = path.join(GEN_DIR, filename);
|
||||||
const dst = path.join(SEL_DIR, filename);
|
const dst = path.join(SEL_DIR, filename);
|
||||||
await fs.copyFile(src, dst);
|
await fs.copyFile(src, dst);
|
||||||
return `/api/img/selected/${filename}`;
|
const url = `/api/img/selected/${filename}`;
|
||||||
|
const info = imageFileInfo(dst);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename,
|
||||||
|
url,
|
||||||
|
bucket: 'selected',
|
||||||
|
sessionId,
|
||||||
|
targetId: imageId,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: true,
|
||||||
|
origin: 'selected',
|
||||||
|
status: 'selected',
|
||||||
|
});
|
||||||
|
recordEvent({ action: 'image.selected_copy', sessionId, targetType: 'image', targetId: imageId, status: 'ok', metadata: { filename } });
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageBucket = 'generated' | 'selected' | 'refs' | 'packs' | 'anchors' | 'uploads';
|
export type ImageBucket = 'generated' | 'selected' | 'refs' | 'packs' | 'anchors' | 'uploads';
|
||||||
@@ -187,13 +398,39 @@ export async function saveRefImage(sessionId: string, idx: number, dataUrl: stri
|
|||||||
if (!m) throw new Error('Invalid ref data URL');
|
if (!m) throw new Error('Invalid ref data URL');
|
||||||
const ext = extFromMime(m[1]);
|
const ext = extFromMime(m[1]);
|
||||||
const file = path.join(REF_DIR, `${sessionId}_ref${idx}.${ext}`);
|
const file = path.join(REF_DIR, `${sessionId}_ref${idx}.${ext}`);
|
||||||
await fs.writeFile(file, Buffer.from(m[2], 'base64'));
|
const buffer = Buffer.from(m[2], 'base64');
|
||||||
return `/api/img/refs/${sessionId}_ref${idx}.${ext}`;
|
await fs.writeFile(file, buffer);
|
||||||
|
const filename = `${sessionId}_ref${idx}.${ext}`;
|
||||||
|
const url = `/api/img/refs/${filename}`;
|
||||||
|
const info = imageFileInfo(file);
|
||||||
|
upsertImageAsset({
|
||||||
|
filename,
|
||||||
|
url,
|
||||||
|
bucket: 'refs',
|
||||||
|
sessionId,
|
||||||
|
targetId: `ref${idx}`,
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
sizeBytes: info.sizeBytes,
|
||||||
|
current: true,
|
||||||
|
origin: 'ref',
|
||||||
|
status: 'reference',
|
||||||
|
});
|
||||||
|
recordEvent({ action: 'image.saved', sessionId, targetType: 'ref', targetId: `ref${idx}`, status: 'ok', metadata: { bucket: 'refs', filename, bytes: buffer.length } });
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveExportManifest(manifest: ExportManifest): Promise<string> {
|
export async function saveExportManifest(manifest: ExportManifest): Promise<string> {
|
||||||
await ensureDirs();
|
await ensureDirs();
|
||||||
const filename = `${manifest.sessionId}_${manifest.packKind}_${manifest.version}_manifest.json`;
|
const filename = `${manifest.sessionId}_${manifest.packKind}_${manifest.version}_manifest.json`;
|
||||||
await fs.writeFile(path.join(EXPORT_DIR, filename), JSON.stringify(manifest, null, 2), 'utf-8');
|
await fs.writeFile(path.join(EXPORT_DIR, filename), JSON.stringify(manifest, null, 2), 'utf-8');
|
||||||
|
recordEvent({
|
||||||
|
action: 'export.manifest_saved',
|
||||||
|
sessionId: manifest.sessionId,
|
||||||
|
targetType: 'manifest',
|
||||||
|
targetId: manifest.id,
|
||||||
|
status: 'ok',
|
||||||
|
metadata: { filename, packId: manifest.packId, packKind: manifest.packKind, files: manifest.files.length },
|
||||||
|
});
|
||||||
return `/api/export/${filename}`;
|
return `/api/export/${filename}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export type LockCharacterFromUploadRequest = {
|
|||||||
export type RegenerateAssetRequest = {
|
export type RegenerateAssetRequest = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
userRefinement?: string;
|
userRefinement?: string;
|
||||||
|
confirmCost?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RegenerateAssetResponse = {
|
export type RegenerateAssetResponse = {
|
||||||
|
|||||||
Reference in New Issue
Block a user