auto-save 2026-05-19 09:51 (+2, ~11)
This commit is contained in:
@@ -8,3 +8,6 @@ GPT_API_BASE=https://api.openai.com/v1
|
||||
SEEDANCE_API_KEY=
|
||||
SEEDANCE_MODEL=doubao-seedance-2-0-260128
|
||||
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
# 生产环境填写公网入口,用于把 /api/img/... 补成 Seedance 可访问的绝对 URL。
|
||||
PUBLIC_APP_URL=
|
||||
|
||||
@@ -392,6 +392,19 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:chore: deploy ai toy patent to vps",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-19T09:46:08+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-19 09:46 (~6)",
|
||||
"hash": "98690b4",
|
||||
"files_changed": 6
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-19T01:49:59Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 10 项未提交变更 · 最近提交:auto-save 2026-05-19 09:46 (~6)",
|
||||
"files_changed": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
RULES.md
1
RULES.md
@@ -33,6 +33,7 @@
|
||||
- `SEEDANCE_API_KEY` — Seedance 视频生成 Key;未配置时视频接口返回 503
|
||||
- `SEEDANCE_MODEL` — 默认 `doubao-seedance-2-0-260128`
|
||||
- `SEEDANCE_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3`
|
||||
- `PUBLIC_APP_URL` — 生产填公网入口,用于把 `/api/img/...` 补成 Seedance 可访问的绝对 URL
|
||||
- 配置位置:`.env.local`(gitignored),参考 `.env.local.example`
|
||||
- 图片生成未配置 GPT Key 时回退 mock(SVG 占位图),视频生成不 mock,必须配置 Seedance Key
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "4560"
|
||||
PUBLIC_APP_URL: https://ai-toy.kang-kang.com
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
|
||||
31
src/app/api/assets/[assetId]/regenerate/route.ts
Normal file
31
src/app/api/assets/[assetId]/regenerate/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { regeneratePackAsset } from '@/lib/packGenerator';
|
||||
import { loadSession, saveSession } from '@/lib/storage';
|
||||
import type { RegenerateAssetRequest, RegenerateAssetResponse } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(req: Request, ctx: { params: Promise<{ assetId: string }> }) {
|
||||
const { assetId } = await ctx.params;
|
||||
const { sessionId, userRefinement } = (await req.json()) as RegenerateAssetRequest;
|
||||
|
||||
if (!assetId || !sessionId) {
|
||||
return NextResponse.json({ error: 'assetId and sessionId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
||||
|
||||
try {
|
||||
const regenerated = await regeneratePackAsset({ session, assetId, userRefinement });
|
||||
await saveSession(session);
|
||||
return NextResponse.json({
|
||||
asset: regenerated.asset,
|
||||
pack: regenerated.pack,
|
||||
provider: regenerated.provider,
|
||||
} satisfies RegenerateAssetResponse);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/character/cleanup/route.ts
Normal file
38
src/app/api/character/cleanup/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
||||
import { loadSession, saveSession } from '@/lib/storage';
|
||||
import type { CleanupCharacterRequest, CleanupCharacterResponse } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { sessionId, imageId, force = false } = (await req.json()) as CleanupCharacterRequest;
|
||||
|
||||
if (!sessionId || !imageId) {
|
||||
return NextResponse.json({ error: 'sessionId and imageId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await loadSession(sessionId);
|
||||
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
||||
|
||||
const sourceImage = session.images.find(image => image.id === imageId);
|
||||
if (!sourceImage) return NextResponse.json({ error: 'image not found' }, { status: 404 });
|
||||
|
||||
try {
|
||||
const characterSpec = session.characterSpec?.sourceImageId === imageId
|
||||
? session.characterSpec
|
||||
: await buildCharacterSpec(session, sourceImage);
|
||||
const cleaned = await cleanupCharacterAnchor({ session, sourceImage, characterSpec, force });
|
||||
session.characterSpec = cleaned.characterSpec;
|
||||
await saveSession(session);
|
||||
|
||||
return NextResponse.json({
|
||||
characterSpec: cleaned.characterSpec,
|
||||
cleanReferenceImageUrl: cleaned.cleanReferenceImageUrl,
|
||||
provider: cleaned.provider,
|
||||
} satisfies CleanupCharacterResponse);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { buildCharacterSpec } from '@/lib/packGenerator';
|
||||
import { buildCharacterSpec, cleanupCharacterAnchor } from '@/lib/packGenerator';
|
||||
import { detectProvider } from '@/lib/providers';
|
||||
import { loadSession, saveSession } from '@/lib/storage';
|
||||
import type { LockCharacterRequest, LockCharacterResponse } from '@/lib/types';
|
||||
@@ -30,11 +30,17 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
const characterSpec = await buildCharacterSpec(session, sourceImage);
|
||||
session.characterSpec = characterSpec;
|
||||
const cleaned = await cleanupCharacterAnchor({
|
||||
session,
|
||||
sourceImage,
|
||||
characterSpec,
|
||||
force,
|
||||
});
|
||||
session.characterSpec = cleaned.characterSpec;
|
||||
await saveSession(session);
|
||||
|
||||
const response: LockCharacterResponse = {
|
||||
characterSpec,
|
||||
characterSpec: cleaned.characterSpec,
|
||||
provider: detectProvider(),
|
||||
};
|
||||
return NextResponse.json(response);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
GenerateResponse,
|
||||
LockCharacterResponse,
|
||||
PackKind,
|
||||
RegenerateAssetResponse,
|
||||
VideoGenerationResponse,
|
||||
} from '@/lib/types';
|
||||
import type { VIDEO_TEMPLATES } from '@/lib/templates';
|
||||
@@ -147,6 +148,33 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerateAsset(assetId: string, userRefinement?: string) {
|
||||
if (!current) return;
|
||||
const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: current.id, userRefinement }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
alert('单张重做失败:' + (await r.text()));
|
||||
return;
|
||||
}
|
||||
const d: RegenerateAssetResponse = await r.json();
|
||||
setProvider(d.provider);
|
||||
await reloadCurrent(current.id);
|
||||
}
|
||||
|
||||
function resolveVideoAnchor(image: GenImage) {
|
||||
const packs = current?.packs ?? [];
|
||||
const mktFront = packs.find(pack => pack.kind === 'marketing')?.assets.find(asset => asset.templateId === 'mkt_white_front');
|
||||
const patentFront = packs.find(pack => pack.kind === 'patent')?.assets.find(asset => asset.templateId === 'patent_front');
|
||||
const cleanReference = current?.characterSpec?.cleanReferenceImageUrl;
|
||||
if (mktFront) return { url: mktFront.url, label: '宣发白底图' };
|
||||
if (patentFront) return { url: patentFront.url, label: '专利主图' };
|
||||
if (cleanReference) return { url: cleanReference, label: 'L1 白底锚图' };
|
||||
return { url: image.url, label: '意向图' };
|
||||
}
|
||||
|
||||
async function handleGenerateVideo(image: GenImage, template: typeof VIDEO_TEMPLATES[number]) {
|
||||
if (!current || videoLoading) return;
|
||||
setVideoLoading(true);
|
||||
@@ -155,12 +183,13 @@ export default function Home() {
|
||||
? `${current.characterSpec.name},${current.characterSpec.oneLiner}`
|
||||
: current.prompt;
|
||||
const prompt = template.promptTemplate.replace('{character}', character);
|
||||
const anchor = resolveVideoAnchor(image);
|
||||
const r = await fetch('/api/video/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
imageUrl: image.url,
|
||||
imageUrl: anchor.url,
|
||||
duration: template.duration,
|
||||
ratio: template.ratio,
|
||||
generateAudio: true,
|
||||
@@ -172,7 +201,7 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
const d: VideoGenerationResponse = await r.json();
|
||||
alert(`Seedance 任务已提交:${d.taskId ?? d.status}`);
|
||||
alert(`Seedance 任务已提交:${d.taskId ?? d.status};参考:${anchor.label}`);
|
||||
} finally {
|
||||
setVideoLoading(false);
|
||||
}
|
||||
@@ -208,7 +237,7 @@ export default function Home() {
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<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'}`} />
|
||||
{provider === 'gpt' ? 'GPT · image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
|
||||
{provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -237,6 +266,7 @@ export default function Home() {
|
||||
onGenerate={handleGeneratePack}
|
||||
onGenerateAll={handleGenerateAll}
|
||||
onLockCharacter={handleLockCharacter}
|
||||
onRegenerateAsset={handleRegenerateAsset}
|
||||
onGenerateVideo={handleGenerateVideo}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -32,13 +32,28 @@ function manifestUrl(sessionId: string, kind: PackKind, version: string) {
|
||||
}
|
||||
|
||||
/* ── Asset Row ────────────────────────────────── */
|
||||
function AssetRow({ template, asset, accent }: {
|
||||
function AssetRow({ template, asset, accent, onRegenerate }: {
|
||||
template: AssetTemplate;
|
||||
asset: ToyAsset | undefined;
|
||||
accent: typeof PACK_ACCENT[PackKind];
|
||||
onRegenerate?: (assetId: string, userRefinement?: string) => Promise<void>;
|
||||
}) {
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const [showRedo, setShowRedo] = useState(false);
|
||||
const [refinement, setRefinement] = useState('');
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const ready = !!asset;
|
||||
async function handleRedo() {
|
||||
if (!asset || !onRegenerate || regenerating) return;
|
||||
setRegenerating(true);
|
||||
try {
|
||||
await onRegenerate(asset.id, refinement);
|
||||
setShowRedo(false);
|
||||
setRefinement('');
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
}
|
||||
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">
|
||||
{/* thumbnail */}
|
||||
@@ -76,30 +91,63 @@ function AssetRow({ template, asset, accent }: {
|
||||
</button>
|
||||
{showPrompt && (
|
||||
<pre className="p-2 text-[10px] text-white/60 bg-black/40 rounded-lg ring-1 ring-white/[0.07] font-mono whitespace-pre-wrap break-all max-h-28 overflow-y-auto">
|
||||
{template.promptTemplate}
|
||||
{asset?.prompt ?? template.promptTemplate}
|
||||
</pre>
|
||||
)}
|
||||
{ready && asset.anchorImageUrl && (
|
||||
<div className="text-[10px] text-white/35 font-mono truncate">
|
||||
anchor: {asset.anchorAssetId ?? asset.anchorImageUrl}
|
||||
</div>
|
||||
)}
|
||||
{showRedo && ready && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<input
|
||||
value={refinement}
|
||||
onChange={event => setRefinement(event.target.value)}
|
||||
placeholder="重做要求"
|
||||
className="min-w-0 flex-1 rounded-lg bg-black/30 ring-1 ring-white/[0.08] px-2 py-1 text-[11px] text-white/80 outline-none focus:ring-violet-400/40"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRedo}
|
||||
disabled={regenerating}
|
||||
className="btn btn-primary text-[10px] px-2 py-1 disabled:opacity-40"
|
||||
>
|
||||
{regenerating ? '...' : '确认'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* meta */}
|
||||
<div className="flex flex-col items-end justify-between text-right shrink-0">
|
||||
<span className="text-[10px] font-mono text-white/40">{ASPECT_PX[template.aspectRatio]}</span>
|
||||
<span className={`text-[10px] ${ready ? 'text-emerald-300' : 'text-white/25'}`}>
|
||||
{ready ? 'Ready' : '待生成'}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`text-[10px] ${ready ? 'text-emerald-300' : 'text-white/25'}`}>
|
||||
{ready ? `L${asset!.derivationLevel}` : '待生成'}
|
||||
</span>
|
||||
{ready && onRegenerate && (
|
||||
<button
|
||||
onClick={() => setShowRedo(value => !value)}
|
||||
className="text-[10px] text-violet-300 hover:text-violet-200"
|
||||
>
|
||||
重做
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Pack Section (collapsible) ───────────────── */
|
||||
function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate }: {
|
||||
function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate, onRegenerateAsset }: {
|
||||
kind: PackKind;
|
||||
session: GenSession;
|
||||
primaryImage: GenImage;
|
||||
pack: AssetPack | undefined;
|
||||
isLoading: boolean;
|
||||
onGenerate: () => void;
|
||||
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const accent = PACK_ACCENT[kind];
|
||||
@@ -172,7 +220,7 @@ function PackSection({ kind, session, primaryImage, pack, isLoading, onGenerate
|
||||
{templates.map(template => {
|
||||
const asset = pack?.assets.find(a => a.templateId === template.id);
|
||||
return (
|
||||
<AssetRow key={template.id} template={template} asset={asset} accent={accent} />
|
||||
<AssetRow key={template.id} template={template} asset={asset} accent={accent} onRegenerate={onRegenerateAsset} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -391,6 +439,7 @@ export default function PackPanel({
|
||||
onGenerate,
|
||||
onGenerateAll,
|
||||
onLockCharacter,
|
||||
onRegenerateAsset,
|
||||
onGenerateVideo,
|
||||
}: {
|
||||
session: GenSession;
|
||||
@@ -401,6 +450,7 @@ export default function PackPanel({
|
||||
onGenerate: (image: GenImage, kind: PackKind) => void;
|
||||
onGenerateAll: (image: GenImage) => void;
|
||||
onLockCharacter: (image: GenImage) => void;
|
||||
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
||||
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
||||
}) {
|
||||
const [activeNav, setActiveNav] = useState('pack-patent');
|
||||
@@ -507,6 +557,7 @@ export default function PackPanel({
|
||||
['比例', session.characterSpec.bodyRatio],
|
||||
['配色', session.characterSpec.colorPalette.join('、')],
|
||||
['材料', session.characterSpec.materials.join('、')],
|
||||
['L1', session.characterSpec.cleanReferenceImageUrl ? '白底锚图已生成' : '未生成'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="flex gap-2 min-w-0">
|
||||
<span className="text-white/35 w-10 shrink-0">{label}</span>
|
||||
@@ -533,6 +584,7 @@ export default function PackPanel({
|
||||
pack={pack}
|
||||
isLoading={loadingKind === kind}
|
||||
onGenerate={() => onGenerate(primaryImage, kind)}
|
||||
onRegenerateAsset={onRegenerateAsset}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
PackKind,
|
||||
ToyAsset,
|
||||
} from './types';
|
||||
import { detectProvider, generateGptImages, generateGptJson, generateMock } from './providers';
|
||||
import { saveExportManifest, savePackImage } from './storage';
|
||||
import { detectProvider, generateGptImageEdit, generateGptJson, generateMock } from './providers';
|
||||
import { saveAnchorImage, saveExportManifest, savePackImage } from './storage';
|
||||
import { FILENAME_SCHEMA, getPackTemplates, PACK_LABELS, renderCharacterSummary, TEMPLATE_FREEZE_VERSION } from './templates';
|
||||
|
||||
function slugify(input: string): string {
|
||||
@@ -83,20 +83,98 @@ function renderPrompt(template: string, spec: CharacterSpec, sourceImageUrl: str
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sizeForAspect(aspectRatio: '1:1' | '3:4' | '4:5' | '9:16' | '16:9' | 'long'): '1024x1024' | '1024x1536' | '1536x1024' {
|
||||
if (aspectRatio === '16:9') return '1536x1024';
|
||||
if (aspectRatio === '1:1') return '1024x1024';
|
||||
return '1024x1536';
|
||||
}
|
||||
|
||||
function resolveRootAnchor(characterSpec: CharacterSpec, sourceImage: GenImage): string {
|
||||
return characterSpec.cleanReferenceImageUrl || characterSpec.sourceImageUrl || sourceImage.url;
|
||||
}
|
||||
|
||||
function sortTemplatesByAnchor<T extends { id: string; anchorTemplateId?: string }>(templates: T[]): T[] {
|
||||
const remaining = [...templates];
|
||||
const done = new Set<string>();
|
||||
const sorted: T[] = [];
|
||||
while (remaining.length > 0) {
|
||||
const index = remaining.findIndex(template => !template.anchorTemplateId || done.has(template.anchorTemplateId));
|
||||
if (index === -1) {
|
||||
throw new Error(`template anchor cycle or missing root: ${remaining.map(template => template.id).join(', ')}`);
|
||||
}
|
||||
const [template] = remaining.splice(index, 1);
|
||||
sorted.push(template);
|
||||
done.add(template.id);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export async function cleanupCharacterAnchor(opts: {
|
||||
session: GenSession;
|
||||
sourceImage: GenImage;
|
||||
characterSpec?: CharacterSpec;
|
||||
force?: boolean;
|
||||
}): Promise<{ characterSpec: CharacterSpec; cleanReferenceImageUrl: string; provider: 'mock' | 'gpt' }> {
|
||||
const provider = detectProvider();
|
||||
const characterSpec = opts.characterSpec ?? opts.session.characterSpec ?? await buildCharacterSpec(opts.session, opts.sourceImage);
|
||||
|
||||
if (!opts.force && characterSpec.cleanReferenceImageUrl) {
|
||||
return { characterSpec, cleanReferenceImageUrl: characterSpec.cleanReferenceImageUrl, provider };
|
||||
}
|
||||
|
||||
const prompt = [
|
||||
'保持参考图中的玩具角色完全一致,只做产品图净化。',
|
||||
'把背景换成纯白色,产品居中,正面或轻微正面视角,光线均匀。',
|
||||
'不要改变五官、主配色、身体比例、毛绒材质、核心配件和识别元素。',
|
||||
'不要文字、水印、logo、场景道具、价格、促销贴纸。',
|
||||
`角色设定:${renderCharacterSummary(characterSpec)}`,
|
||||
].join('\n');
|
||||
|
||||
const image = provider === 'gpt'
|
||||
? await generateGptImageEdit({
|
||||
sessionId: `${opts.session.id}_clean`,
|
||||
prompt,
|
||||
anchorImage: opts.sourceImage.url,
|
||||
size: '1024x1024',
|
||||
})
|
||||
: (await generateMock({
|
||||
sessionId: `${opts.session.id}_clean`,
|
||||
prompt,
|
||||
count: 1,
|
||||
}))[0];
|
||||
|
||||
if (!image) throw new Error('clean anchor generation failed');
|
||||
const cleanReferenceImageUrl = image.url.startsWith('data:')
|
||||
? await saveAnchorImage(opts.session.id, opts.sourceImage.id, image.url)
|
||||
: image.url;
|
||||
|
||||
return {
|
||||
characterSpec: {
|
||||
...characterSpec,
|
||||
sourceImageId: opts.sourceImage.id,
|
||||
sourceImageUrl: opts.sourceImage.url,
|
||||
cleanReferenceImageUrl,
|
||||
},
|
||||
cleanReferenceImageUrl,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
async function generateAssetImage(opts: {
|
||||
packId: string;
|
||||
assetId: string;
|
||||
prompt: string;
|
||||
sourceImageUrl: string;
|
||||
anchorImageUrl: string;
|
||||
aspectRatio: '1:1' | '3:4' | '4:5' | '9:16' | '16:9' | 'long';
|
||||
}): Promise<{ url: string; provider: 'mock' | 'gpt'; raw?: unknown }> {
|
||||
const provider = detectProvider();
|
||||
const images = provider === 'gpt'
|
||||
? await generateGptImages({
|
||||
? [await generateGptImageEdit({
|
||||
sessionId: `${opts.packId}_${opts.assetId}`,
|
||||
prompt: opts.prompt,
|
||||
count: 1,
|
||||
refImages: opts.sourceImageUrl.startsWith('http') ? [opts.sourceImageUrl] : [],
|
||||
})
|
||||
anchorImage: opts.anchorImageUrl,
|
||||
size: sizeForAspect(opts.aspectRatio),
|
||||
})]
|
||||
: await generateMock({
|
||||
sessionId: `${opts.packId}_${opts.assetId}`,
|
||||
prompt: opts.prompt,
|
||||
@@ -129,10 +207,16 @@ export async function generateAssetPack(opts: {
|
||||
sourceImage: GenImage;
|
||||
kind: PackKind;
|
||||
}): Promise<{ pack: AssetPack; manifest: ExportManifest; provider: 'mock' | 'gpt' }> {
|
||||
const templates = getPackTemplates(opts.kind);
|
||||
const characterSpec = opts.session.characterSpec?.sourceImageId === opts.sourceImage.id
|
||||
const templates = sortTemplatesByAnchor(getPackTemplates(opts.kind));
|
||||
const initialCharacterSpec = opts.session.characterSpec?.sourceImageId === opts.sourceImage.id
|
||||
? opts.session.characterSpec
|
||||
: await buildCharacterSpec(opts.session, opts.sourceImage);
|
||||
const cleaned = await cleanupCharacterAnchor({
|
||||
session: opts.session,
|
||||
sourceImage: opts.sourceImage,
|
||||
characterSpec: initialCharacterSpec,
|
||||
});
|
||||
const characterSpec = cleaned.characterSpec;
|
||||
const version = 'v01';
|
||||
const packId = `pack_${opts.kind}_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`;
|
||||
const createdAt = Date.now();
|
||||
@@ -141,12 +225,20 @@ export async function generateAssetPack(opts: {
|
||||
const assets: ToyAsset[] = [];
|
||||
for (const template of templates) {
|
||||
const assetId = `${opts.kind}_${template.filenamePart}_${randomBytes(3).toString('hex')}`;
|
||||
const prompt = renderPrompt(template.promptTemplate, characterSpec, opts.sourceImage.url);
|
||||
const anchorAsset = template.anchorTemplateId
|
||||
? assets.find(asset => asset.templateId === template.anchorTemplateId)
|
||||
: undefined;
|
||||
if (template.anchorTemplateId && !anchorAsset) {
|
||||
throw new Error(`anchor ${template.anchorTemplateId} not generated yet`);
|
||||
}
|
||||
const anchorImageUrl = anchorAsset?.url ?? resolveRootAnchor(characterSpec, opts.sourceImage);
|
||||
const prompt = renderPrompt(template.promptTemplate, characterSpec, anchorImageUrl);
|
||||
const generated = await generateAssetImage({
|
||||
packId,
|
||||
assetId,
|
||||
prompt,
|
||||
sourceImageUrl: opts.sourceImage.url,
|
||||
anchorImageUrl,
|
||||
aspectRatio: template.aspectRatio,
|
||||
});
|
||||
assets.push({
|
||||
id: assetId,
|
||||
@@ -162,10 +254,14 @@ export async function generateAssetPack(opts: {
|
||||
aspectRatio: template.aspectRatio,
|
||||
required: template.required,
|
||||
createdAt: Date.now(),
|
||||
anchorAssetId: anchorAsset?.id,
|
||||
anchorImageUrl,
|
||||
derivationLevel: anchorAsset ? 3 : 2,
|
||||
meta: {
|
||||
provider: generated.provider,
|
||||
packLabel: PACK_LABELS[opts.kind],
|
||||
templateFreezeVersion: TEMPLATE_FREEZE_VERSION,
|
||||
anchorTemplateId: template.anchorTemplateId,
|
||||
raw: generated.raw,
|
||||
},
|
||||
});
|
||||
@@ -221,6 +317,9 @@ export async function generateAssetPack(opts: {
|
||||
required: asset.required,
|
||||
aspectRatio: asset.aspectRatio,
|
||||
prompt: asset.prompt,
|
||||
anchorAssetId: asset.anchorAssetId,
|
||||
anchorImageUrl: asset.anchorImageUrl,
|
||||
derivationLevel: asset.derivationLevel,
|
||||
checklist: template?.checklist ?? [],
|
||||
};
|
||||
}),
|
||||
@@ -230,3 +329,64 @@ export async function generateAssetPack(opts: {
|
||||
await saveExportManifest(manifest);
|
||||
return { pack, manifest, provider };
|
||||
}
|
||||
|
||||
export async function regeneratePackAsset(opts: {
|
||||
session: GenSession;
|
||||
assetId: string;
|
||||
userRefinement?: string;
|
||||
}): Promise<{ pack: AssetPack; asset: ToyAsset; provider: 'mock' | 'gpt' }> {
|
||||
const pack = opts.session.packs?.find(candidate => candidate.assets.some(asset => asset.id === opts.assetId));
|
||||
if (!pack) throw new Error('asset pack not found');
|
||||
const assetIndex = pack.assets.findIndex(asset => asset.id === opts.assetId);
|
||||
const asset = pack.assets[assetIndex];
|
||||
if (!asset) throw new Error('asset not found');
|
||||
const template = getPackTemplates(pack.kind).find(candidate => candidate.id === asset.templateId);
|
||||
if (!template) throw new Error(`template not found: ${asset.templateId}`);
|
||||
|
||||
const anchorAsset = template.anchorTemplateId
|
||||
? pack.assets.find(candidate => candidate.templateId === template.anchorTemplateId)
|
||||
: undefined;
|
||||
if (template.anchorTemplateId && !anchorAsset) {
|
||||
throw new Error(`anchor ${template.anchorTemplateId} not generated yet`);
|
||||
}
|
||||
|
||||
const sourceImage = opts.session.images.find(image => image.id === pack.sourceImageId);
|
||||
const anchorImageUrl = anchorAsset?.url
|
||||
?? pack.characterSpec.cleanReferenceImageUrl
|
||||
?? pack.characterSpec.sourceImageUrl
|
||||
?? sourceImage?.url;
|
||||
if (!anchorImageUrl) throw new Error('anchor image not found');
|
||||
|
||||
const prompt = [
|
||||
renderPrompt(template.promptTemplate, pack.characterSpec, anchorImageUrl),
|
||||
opts.userRefinement?.trim() ? `用户重做要求:${opts.userRefinement.trim()}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const generated = await generateAssetImage({
|
||||
packId: pack.id,
|
||||
assetId: asset.id,
|
||||
prompt,
|
||||
anchorImageUrl,
|
||||
aspectRatio: template.aspectRatio,
|
||||
});
|
||||
|
||||
const updatedAsset: ToyAsset = {
|
||||
...asset,
|
||||
url: generated.url,
|
||||
prompt,
|
||||
status: 'draft',
|
||||
createdAt: Date.now(),
|
||||
anchorAssetId: anchorAsset?.id,
|
||||
anchorImageUrl,
|
||||
derivationLevel: anchorAsset ? 3 : 2,
|
||||
meta: {
|
||||
...(asset.meta ?? {}),
|
||||
provider: generated.provider,
|
||||
anchorTemplateId: template.anchorTemplateId,
|
||||
regeneratedAt: Date.now(),
|
||||
raw: generated.raw,
|
||||
},
|
||||
};
|
||||
pack.assets[assetIndex] = updatedAsset;
|
||||
return { pack, asset: updatedAsset, provider: detectProvider() };
|
||||
}
|
||||
|
||||
@@ -130,7 +130,8 @@ export async function generateGptImageEdit(opts: {
|
||||
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 imageBytes = source.buf.buffer.slice(source.buf.byteOffset, source.buf.byteOffset + source.buf.byteLength) as ArrayBuffer;
|
||||
form.set('image', new Blob([imageBytes], { type: source.type }), source.filename);
|
||||
|
||||
const res = await fetch(`${GPT_API_BASE}/images/edits`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -142,6 +142,9 @@ export type ExportManifest = {
|
||||
required: boolean;
|
||||
aspectRatio: AssetTemplate['aspectRatio'];
|
||||
prompt: string;
|
||||
anchorAssetId?: string;
|
||||
anchorImageUrl?: string;
|
||||
derivationLevel?: 0 | 1 | 2 | 3;
|
||||
checklist: string[];
|
||||
}>;
|
||||
exportTargets: Array<'zip' | 'pdf' | 'manifest-json'>;
|
||||
|
||||
@@ -16,6 +16,11 @@ function normalizeStatus(status?: string): VideoGenerationResponse['status'] {
|
||||
|
||||
function publicUrlOrUndefined(url?: string): string | undefined {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith('/')) {
|
||||
const base = process.env.PUBLIC_APP_URL || process.env.NEXT_PUBLIC_APP_URL;
|
||||
if (!base || /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])/i.test(base)) return undefined;
|
||||
return new URL(url, base).toString();
|
||||
}
|
||||
if (!/^https?:\/\//i.test(url)) return undefined;
|
||||
if (/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])/i.test(url)) return undefined;
|
||||
return url;
|
||||
|
||||
Reference in New Issue
Block a user