auto-save 2026-05-19 09:46 (~6)

This commit is contained in:
2026-05-19 09:46:08 +08:00
parent 781d29ced1
commit 98690b4a0a
6 changed files with 168 additions and 10 deletions

View File

@@ -366,6 +366,32 @@
"message": "auto-save 2026-05-19 09:24 (+1, ~1)",
"hash": "be0efc3",
"files_changed": 2
},
{
"ts": "2026-05-19T09:29:50+08:00",
"type": "commit",
"message": "auto-save 2026-05-19 09:29 (~3)",
"hash": "1224ddf",
"files_changed": 3
},
{
"ts": "2026-05-19T01:29:59Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 master · 1 项未提交变更 · 最近提交auto-save 2026-05-19 09:29 (~3)",
"files_changed": 1
},
{
"ts": "2026-05-19T09:32:28+08:00",
"type": "commit",
"message": "chore: deploy ai toy patent to vps",
"hash": "781d29c",
"files_changed": 2
},
{
"ts": "2026-05-19T01:39:59Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 master · 1 项未提交变更 · 最近提交chore: deploy ai toy patent to vps",
"files_changed": 1
}
]
}

View File

@@ -5,13 +5,13 @@ export const runtime = 'nodejs';
export async function GET(_req: Request, ctx: { params: Promise<{ bucket: string; filename: string }> }) {
const { bucket, filename } = await ctx.params;
if (!['generated', 'selected', 'refs', 'packs'].includes(bucket)) {
if (!['generated', 'selected', 'refs', 'packs', 'anchors'].includes(bucket)) {
return NextResponse.json({ error: 'bad bucket' }, { status: 400 });
}
if (filename.includes('..') || filename.includes('/')) {
return NextResponse.json({ error: 'bad filename' }, { status: 400 });
}
const r = await readImageFile(bucket as 'generated' | 'selected' | 'refs' | 'packs', filename);
const r = await readImageFile(bucket as 'generated' | 'selected' | 'refs' | 'packs' | 'anchors', filename);
if (!r) return NextResponse.json({ error: 'not found' }, { status: 404 });
return new NextResponse(new Uint8Array(r.buf), {
headers: { 'Content-Type': r.type, 'Cache-Control': 'public, max-age=31536000, immutable' },

View File

@@ -1,4 +1,5 @@
import type { GenImage } from './types';
import { readImageUrl } from './storage';
export type Provider = 'mock' | 'gpt';
@@ -93,6 +94,60 @@ export async function generateGptImages(opts: {
return Promise.all(calls);
}
function readEditImageBase64(payload: unknown): string {
return readImageBase64(payload);
}
function dataUrlFromImageResponse(payload: unknown): string {
return `data:image/png;base64,${readEditImageBase64(payload)}`;
}
export async function generateGptImageEdit(opts: {
sessionId: string;
prompt: string;
anchorImage: Buffer | string;
anchorMime?: string;
anchorFilename?: string;
size?: '1024x1024' | '1024x1536' | '1536x1024';
}): Promise<GenImage> {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY missing');
const source = typeof opts.anchorImage === 'string'
? await readImageUrl(opts.anchorImage)
: {
buf: opts.anchorImage,
type: opts.anchorMime || 'image/png',
filename: opts.anchorFilename || 'anchor.png',
};
if (source.type.includes('svg')) {
throw new Error('GPT image edits require a raster anchor image; regenerate the source image with GPT first');
}
const form = new FormData();
form.set('model', process.env.GPT_IMAGE_EDIT_MODEL || GPT_IMAGE_MODEL);
form.set('prompt', opts.prompt);
form.set('size', opts.size || '1024x1024');
form.set('response_format', 'b64_json');
form.set('image', new Blob([source.buf], { type: source.type }), source.filename);
const res = await fetch(`${GPT_API_BASE}/images/edits`, {
method: 'POST',
headers: { Authorization: `Bearer ${key}` },
body: form,
});
if (!res.ok) throw new Error(`GPT image edit ${res.status}: ${await res.text()}`);
const data = await res.json();
return {
id: `img_${opts.sessionId}_edit`,
url: dataUrlFromImageResponse(data),
prompt: opts.prompt,
status: 'pending',
meta: { provider: 'gpt', model: process.env.GPT_IMAGE_EDIT_MODEL || GPT_IMAGE_MODEL, edit: true },
};
}
export async function generateGptJson<T>(opts: {
prompt: string;
fallback: T;

View File

@@ -8,10 +8,11 @@ const SEL_DIR = path.join(ROOT, 'selected');
const REF_DIR = path.join(ROOT, 'refs');
const GEN_DIR = path.join(ROOT, 'generated');
const PACK_DIR = path.join(ROOT, 'packs');
const ANCHOR_DIR = path.join(ROOT, 'anchors');
const EXPORT_DIR = path.join(ROOT, 'exports');
async function ensureDirs() {
await Promise.all([SESS_DIR, SEL_DIR, REF_DIR, GEN_DIR, PACK_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, EXPORT_DIR].map(d => fs.mkdir(d, { recursive: true })));
}
export async function saveSession(s: GenSession) {
@@ -68,6 +69,17 @@ export async function savePackImage(packId: string, assetId: string, dataUrl: st
return `/api/img/packs/${packId}_${assetId}.${ext}`;
}
export async function saveAnchorImage(sessionId: string, imageId: string, dataUrl: string): Promise<string> {
await ensureDirs();
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!m) throw new Error('Invalid data URL');
const ext = extFromMime(m[1]);
const safeImageId = imageId.replace(/[^a-zA-Z0-9_-]+/g, '-');
const filename = `${sessionId}_${safeImageId}_clean.${ext}`;
await fs.writeFile(path.join(ANCHOR_DIR, filename), Buffer.from(m[2], 'base64'));
return `/api/img/anchors/${filename}`;
}
export async function copyToSelected(sessionId: string, imageId: string, srcUrl: string): Promise<string> {
await ensureDirs();
// srcUrl 形如 /api/img/generated/xxx.png
@@ -80,12 +92,13 @@ export async function copyToSelected(sessionId: string, imageId: string, srcUrl:
return `/api/img/selected/${filename}`;
}
export async function readImageFile(bucket: 'generated' | 'selected' | 'refs' | 'packs', filename: string): Promise<{ buf: Buffer; type: string } | null> {
export async function readImageFile(bucket: 'generated' | 'selected' | 'refs' | 'packs' | 'anchors', filename: string): Promise<{ buf: Buffer; type: string } | null> {
try {
const dir = bucket === 'generated' ? GEN_DIR
: bucket === 'selected' ? SEL_DIR
: bucket === 'refs' ? REF_DIR
: PACK_DIR;
: bucket === 'packs' ? PACK_DIR
: ANCHOR_DIR;
const buf = await fs.readFile(path.join(dir, filename));
const ext = path.extname(filename).slice(1).toLowerCase();
const type = ext === 'jpg' ? 'image/jpeg'
@@ -97,6 +110,36 @@ export async function readImageFile(bucket: 'generated' | 'selected' | 'refs' |
}
}
export async function readImageUrl(url: string): Promise<{ buf: Buffer; type: string; filename: string }> {
const dataMatch = url.match(/^data:([^;]+);base64,(.+)$/);
if (dataMatch) {
return {
buf: Buffer.from(dataMatch[2], 'base64'),
type: dataMatch[1],
filename: `anchor.${extFromMime(dataMatch[1])}`,
};
}
const localMatch = url.match(/^\/api\/img\/(generated|selected|refs|packs|anchors)\/([^/?#]+)$/);
if (localMatch) {
const bucket = localMatch[1] as 'generated' | 'selected' | 'refs' | 'packs' | 'anchors';
const filename = decodeURIComponent(localMatch[2]);
const image = await readImageFile(bucket, filename);
if (!image) throw new Error(`anchor image not found: ${url}`);
return { ...image, filename };
}
if (/^https?:\/\//i.test(url)) {
const res = await fetch(url);
if (!res.ok) throw new Error(`failed to fetch anchor image ${res.status}: ${url}`);
const type = res.headers.get('content-type')?.split(';')[0] || 'image/png';
const filename = new URL(url).pathname.split('/').pop() || `remote.${extFromMime(type)}`;
return { buf: Buffer.from(await res.arrayBuffer()), type, filename };
}
throw new Error(`unsupported anchor image URL: ${url}`);
}
export async function saveRefImage(sessionId: string, idx: number, dataUrl: string): Promise<string> {
await ensureDirs();
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);

View File

@@ -1084,11 +1084,18 @@ export const MARKETING_TEMPLATES: AssetTemplate[] = [
},
];
function withPackAnchor(templates: AssetTemplate[], rootTemplateId: string): AssetTemplate[] {
return templates.map(template => ({
...template,
anchorTemplateId: template.id === rootTemplateId ? undefined : rootTemplateId,
}));
}
export const PACK_TEMPLATES: Record<PackKind, AssetTemplate[]> = {
patent: PATENT_TEMPLATES,
accessories: ACCESSORY_TEMPLATES,
production: PRODUCTION_TEMPLATES,
marketing: MARKETING_TEMPLATES,
patent: withPackAnchor(PATENT_TEMPLATES, 'patent_front'),
accessories: withPackAnchor(ACCESSORY_TEMPLATES, 'acc_inventory_sheet'),
production: withPackAnchor(PRODUCTION_TEMPLATES, 'prod_front_spec'),
marketing: withPackAnchor(MARKETING_TEMPLATES, 'mkt_white_front'),
};
export const PACK_TEMPLATE_SUMMARY = PACK_ORDER.map(kind => ({
@@ -1133,4 +1140,3 @@ export function renderCharacterSummary(spec: CharacterSpec): string {
`不要出现:${spec.negativePrompt}`,
].join('');
}

View File

@@ -52,6 +52,7 @@ export type CharacterSpec = {
negativePrompt: string;
sourceImageId?: string;
sourceImageUrl?: string;
cleanReferenceImageUrl?: string;
lockedAt: number;
};
@@ -66,6 +67,7 @@ export type AssetTemplate = {
filenamePart: string;
promptTemplate: string;
checklist: string[];
anchorTemplateId?: string;
};
export type TextTemplate = {
@@ -94,6 +96,9 @@ export type ToyAsset = {
aspectRatio: AssetTemplate['aspectRatio'];
required: boolean;
createdAt: number;
anchorAssetId?: string;
anchorImageUrl?: string;
derivationLevel: 0 | 1 | 2 | 3;
meta?: Record<string, unknown>;
};
@@ -176,6 +181,29 @@ export type LockCharacterResponse = {
provider: 'mock' | 'gpt';
};
export type CleanupCharacterRequest = {
sessionId: string;
imageId: string;
force?: boolean;
};
export type CleanupCharacterResponse = {
characterSpec: CharacterSpec;
cleanReferenceImageUrl: string;
provider: 'mock' | 'gpt';
};
export type RegenerateAssetRequest = {
sessionId: string;
userRefinement?: string;
};
export type RegenerateAssetResponse = {
asset: ToyAsset;
pack: AssetPack;
provider: 'mock' | 'gpt';
};
export type VideoGenerationRequest = {
prompt: string;
imageUrl?: string;