auto-save 2026-05-19 09:46 (~6)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,(.+)$/);
|
||||
|
||||
@@ -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(';');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user