auto-save 2026-05-19 09:51 (+2, ~11)

This commit is contained in:
2026-05-19 09:51:34 +08:00
parent 98690b4a0a
commit 7b4b5f7450
13 changed files with 369 additions and 25 deletions

View File

@@ -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=

View File

@@ -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
}
]
}

View File

@@ -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 时回退 mockSVG 占位图),视频生成不 mock必须配置 Seedance Key

View File

@@ -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:

View 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 });
}
}

View 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 });
}
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>
<div className="flex flex-col items-end gap-1">
<span className={`text-[10px] ${ready ? 'text-emerald-300' : 'text-white/25'}`}>
{ready ? 'Ready' : '待生成'}
{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}
/>
);
})}

View File

@@ -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() };
}

View File

@@ -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',

View File

@@ -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'>;

View File

@@ -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;