diff --git a/.memory/worklog.json b/.memory/worklog.json index 3cb2c98..b16f9df 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -464,6 +464,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-19 10:24 (~2)", "files_changed": 1 + }, + { + "ts": "2026-05-19T10:35:04+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 10:35 (+3, ~8, -1)", + "hash": "a3481e7", + "files_changed": 12 + }, + { + "ts": "2026-05-19T02:40:00Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 3 项未提交变更 · 最近提交:auto-save 2026-05-19 10:35 (+3, ~8, -1)", + "files_changed": 3 } ] } diff --git a/HANDOFF_IMAGE_PIPELINE.md b/HANDOFF_IMAGE_PIPELINE.md index 8b3ece8..a43f1cd 100644 --- a/HANDOFF_IMAGE_PIPELINE.md +++ b/HANDOFF_IMAGE_PIPELINE.md @@ -1101,4 +1101,242 @@ Agent 跑到中途失败(API 超时、Key 限流)的处理: **建议**:先做完第 1+2 期,能覆盖 80% 场景;第 3+4 期是质量优化和体验升级,可以按用户反馈再迭代。 +--- + +## 11. 真人模特互动包(Talent Pack) + +### 11.1 需求场景 + +潮玩宣发离不开"真人模特 × 玩具"的内容: + +- 帅气男孩/漂亮女孩手持玩具的合影 +- 模特把玩、拥抱、肩扛玩具的生活方式图 +- 短视频:开箱、日常陪伴、Vlog 把玩、自拍展示 +- 类似 LABUBU / MOLLY / Sonny Angel 那种小红书爆款图 + +要求:**尽可能真实**——真人皮肤纹理、自然光、自然表情、玩具材质对、手指抓握自然。 + +当前系统的 `mkt_scene_*` 只有"玩具放在场景里",**完全没有真人模特出现**。需要把"真人互动"独立成 **Talent Pack(真人互动包)** —— 既不是单纯的宣发图,也不是社媒文案图,而是**模特 + 玩具同框**这一类特殊产物。 + +### 11.2 为什么独立成包 + +| 维度 | 普通宣发图 | 真人互动图 | +|---|---|---| +| 主体 | 玩具 | 模特 + 玩具 | +| 锚图 | 1 张(玩具 L1) | 2 张(模特参考 + 玩具 L1) | +| 一致性挑战 | 单角色一致 | 同一模特跨多图一致 + 玩具一致 | +| 合规风险 | 低 | 高(肖像权 / 儿童保护) | +| 视频生成难度 | 中(玩具旋转/特写) | 高(模特动作 + 玩具互动) | + +要素完全不同,必须作为独立 `PackKind: 'talent'`。 + +### 11.3 数据模型扩展 + +```typescript +// types.ts +export type PackKind = 'patent' | 'production' | 'marketing' | 'accessories' | 'talent'; + +export type ModelPersona = { + id: string; + label: string; // "帅气男孩 · 嘻哈风" + category: 'male' | 'female' | 'kid' | 'couple' | 'group'; + ageRange: '5-12' | '13-18' | '18-25' | '25-35' | '35-50'; + styleTags: string[]; // ['街头', '嘻哈', '复古'] + referenceImageUrl: string; // 预生成的合成模特参考图 + characterPrompt: string; // 完整 persona prompt block + negativePrompt: string; +}; + +export type TalentAsset = ToyAsset & { + modelPersonaId: string; // 用了哪个模特 + modelAnchorUrl: string; // 该模特的参考图 URL + scenarioId: string; // 互动场景模板 ID +}; +``` + +### 11.4 模特库 ModelPersona + +类似风格库,做一组可视化的模特预设。建议初版 10-12 个: + +| ID | 类型 | 风格定位 | +|---|---|---| +| `male-street-cool` | 男 / 18-25 | 帅气男孩 · 街头嘻哈 | +| `male-creative-warm` | 男 / 25-35 | 温柔创意人 · 治愈 | +| `male-business-clean` | 男 / 25-35 | 都市精英 · 简洁 | +| `female-cute-soft` | 女 / 18-25 | 治愈系女孩 · 软妹 | +| `female-trendy-cool` | 女 / 18-25 | 潮酷女孩 · 街头 | +| `female-elegant-fashion` | 女 / 25-35 | 时尚白领 · 优雅 | +| `kid-boy-playful` | 童 / 5-12 | 活泼男孩 | +| `kid-girl-curious` | 童 / 5-12 | 好奇女孩 | +| `couple-young` | 情侣 / 18-25 | 青春情侣 | +| `collector-male` | 男 / 25-35 | 潮玩藏家 | +| `collector-female` | 女 / 25-35 | 潮玩藏家 | +| `office-worker` | 中性 / 25-35 | 办公室桌搭 | + +每个 persona 准备: +1. **参考图**(256×256 缩略 + 1024×1024 高清):合法合成肖像(**不能用真实明星**),存 `public/personas/` +2. **`characterPrompt` block**:完整描述脸型 / 发型 / 体型 / 服装 / 气质,给 GPT image-2 用 +3. **`negativePrompt`**:明确禁止"识别为某真实明星"、"具体已知 IP 长相" + +### 11.5 Talent Pack 模板槽位 + +新增模板(templates.ts 里加 `TALENT_TEMPLATES`): + +#### A 单人互动图(每个 persona 一组 6 张) + +| Template ID | 内容 | 画幅 | +|---|---|---| +| `talent_portrait_handheld` | 模特正面手持玩具,眼神对镜头 | 4:5 | +| `talent_portrait_hug` | 模特怀抱玩具,温柔表情 | 4:5 | +| `talent_lifestyle_desk` | 模特在书桌/办公桌前,玩具桌搭 | 4:5 | +| `talent_lifestyle_outdoor` | 模特户外手持玩具(街头/咖啡店) | 4:5 | +| `talent_selfie_phone` | 自拍式构图,玩具在画面前景 | 9:16 | +| `talent_action_play` | 模特正在把玩玩具(互动动作) | 1:1 | + +#### B 多人/情侣互动图 + +| Template ID | 内容 | +|---|---| +| `talent_couple_share` | 情侣共享玩具 | +| `talent_group_gift` | 朋友间赠送场景 | +| `talent_parent_child` | 亲子陪伴 | + +#### C 视频脚本 + +视频走 Seedance,新增视频模板: + +| Template ID | 内容 | 时长 | +|---|---|---| +| `video_talent_unbox` | 模特开箱第一视角 | 6-8s | +| `video_talent_play` | 模特把玩玩具 | 6s | +| `video_talent_daily` | 模特日常带玩具出门 | 8s | +| `video_talent_selfie` | 模特自拍 Vlog 展示 | 6s | + +### 11.6 生成链路(关键) + +真人互动图需要 **双锚图**: + +``` +模特参考图(modelPersonaReferenceUrl) + + +玩具锚图(L1 净化锚图) + ↓ + 合成 prompt + 调 GPT image-2 /images/edits + ↓ + talent_portrait_handheld + ↓ (作为后续 talent 图的人物锚) + talent_portrait_hug / talent_lifestyle_* (都参考此图保证模特一致) + ↓ + 视频任务(Seedance)参考 talent_portrait_handheld 当锚帧 +``` + +技术实现: + +1. **双图输入**:`/images/edits` 端点不原生支持多图,**用 image+mask 方式**或拼成"参考板"再生成 +2. **更可靠方案**:先用 GPT-vision 看模特参考图 → 输出详细描述(脸型/发型/眼睛/嘴型),把这段描述拼进 prompt,再加玩具 L1 作为 image input +3. **第二选**:用 OpenAI 的 image-2 multi-image 入参(如果支持),分别标注 `[reference: model]` 和 `[reference: toy]` + +### 11.7 模特一致性问题 + +最棘手:**同一个 persona 在多张图里要长得像同一个人**。 + +策略: +1. 第一张 `talent_portrait_handheld` 生成后,**它成为该 persona 在本项目的"人物锚图"** +2. 后续所有 talent_* 都把这张作为人物 anchor,玩具仍用 L1 锚 +3. 添加更细致的 `characterPrompt`:脸型 / 发型 / 眼距 / 嘴角 / 标志性服饰 +4. 用 Quality Checker 跨张比对模特相似度,相似度 < 0.6 标红重做 + +### 11.8 真实感提升要点 + +prompt 模板必须包含的真实感要素: + +``` +photorealistic, shot on Sony A7IV with 50mm f/1.8 lens, +natural daylight, soft shadows, skin texture visible, +shallow depth of field, real environment, candid moment, +authentic emotion, no AI-art artifacts, no plastic skin, +hands holding toy naturally with realistic finger curvature +``` + +negative: + +``` +cartoon, anime, 3D render, plastic skin, perfect symmetry, +glowing eyes, anime eyes, AI artifacts, deformed hands, +extra fingers, distorted toy proportions +``` + +### 11.9 合规与肖像权 + +**严格要求**: + +1. **不允许使用真实明星 / 公众人物的样貌** + - prompt 不允许包含明星名("长得像 XXX") + - Vision Agent 检测生成图,如果识别为已知明星 → 自动拒绝并重做 +2. **儿童 persona 需要额外标记** + - UI 上显示 "⚠ 儿童形象,请确认有合法授权或仅用于内部参考" + - 不允许儿童单独出现的不合适场景 +3. **合成模特身份** + - 所有生成图导出时 manifest 必须包含 `"talent_disclaimer": "本图模特为 AI 合成,非真实人物"` + - 用户使用时需自行决定是否标注 +4. **真实模特照片导入** + - 如果用户上传自己拍的模特照片作为 reference,UI 要求确认 "我拥有/已获得该模特肖像使用授权" + +### 11.10 UI 设计 + +`PackPanel` 加新的 Talent Pack section,结构和其它包类似但有特殊控件: + +``` +┌────────────────────────────────────────────────────┐ +│ 👤 真人互动包 · 模特 × 玩具 │ +├────────────────────────────────────────────────────┤ +│ Step 1: 选模特类型(可多选) │ +│ [🧑‍🎤帅气男孩-嘻哈] [👧治愈系女孩] [👨‍💼都市精英] │ +│ [👶活泼男孩] [👩‍🎤潮酷女孩] [...] 共 12 款 │ +│ │ +│ Step 2: 选互动场景(默认 6 个,可勾选) │ +│ [✓] 手持正面 [✓] 怀抱 [ ] 桌搭 │ +│ [✓] 户外 [ ] 自拍 [ ] 把玩 │ +│ │ +│ Step 3: 视频任务(可选) │ +│ [ ] 开箱 6s [ ] 把玩 6s [ ] 日常 8s │ +│ │ +│ [生成 talent pack(4 张 × 选中模特数 + 视频任务)] │ +└────────────────────────────────────────────────────┘ +``` + +每张生成图显示「模特:xxx」「场景:xxx」「自检评分:0.85 ✓」。 + +### 11.11 实施 Checklist 增量 + +- [ ] 11.A `PackKind` 加 `'talent'`,类型扩展 `ModelPersona` / `TalentAsset` +- [ ] 11.B 准备 12 个 ModelPersona + 缩略图(放 `public/personas/`) +- [ ] 11.C `TALENT_TEMPLATES` 模板定义(单人 6 + 多人 3 + 视频 4) +- [ ] 11.D 双锚图生成函数:`generateTalentImage({ modelPersona, toyAnchorUrl, scenario })` +- [ ] 11.E 模特一致性自检(Quality Checker 扩展) +- [ ] 11.F UI:模特选择卡片 + 场景勾选 + 进度 +- [ ] 11.G 合规模块:明星人脸检测拒绝、儿童形象提示、肖像授权确认 +- [ ] 11.H Seedance 视频任务支持 talent 锚图 + +### 11.12 优先级建议 + +`talent` 包属于**第 2 阶段**功能(在主链路打通之后)。原因: + +- 主体(玩具)一致性都还没解决前,加真人会进一步放大漂移问题 +- 必须先有稳定的 L1 锚图 + 真图生图链路(§1) +- 视频部分必须有稳定的静态 talent 图作为锚帧 + +建议实施顺序: +1. 完成 §1(真图生图)+ §8 Mode B(单图复刻) +2. 实现 §11.B(模特库准备)+ §11.D(双锚图生成) +3. 再做视频 talent 任务 + +如果用户特别强调真人互动,可以**优先做最有传播力的 3 个 slot**: +- `talent_portrait_handheld`(小红书爆款基础图) +- `talent_lifestyle_outdoor`(生活方式种草) +- `video_talent_play`(短视频把玩) + +3 个 slot 跑通了,宣发素材就够发一波。 + + diff --git a/src/app/page.tsx b/src/app/page.tsx index e48a448..9badae2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,7 +13,10 @@ import type { GenerateResponse, LockCharacterResponse, PackKind, + ProjectFromUploadResponse, RegenerateAssetResponse, + UploadImageResponse, + UploadedImageRole, VideoGenerationResponse, } from '@/lib/types'; import type { VIDEO_TEMPLATES } from '@/lib/templates'; @@ -26,6 +29,7 @@ export default function Home() { const [allLoading, setAllLoading] = useState(false); const [characterLoading, setCharacterLoading] = useState(false); const [videoLoading, setVideoLoading] = useState(false); + const [uploadLoading, setUploadLoading] = useState(false); const [provider, setProvider] = useState('?'); const [sidebarOpen, setSidebarOpen] = useState(true); @@ -60,6 +64,55 @@ export default function Home() { } } + async function uploadImage(file: File, role: UploadedImageRole) { + const form = new FormData(); + form.set('image', file); + form.set('role', role); + const r = await fetch('/api/uploads', { method: 'POST', body: form }); + if (!r.ok) throw new Error(await r.text()); + const d: UploadImageResponse = await r.json(); + return d.uploadedImage; + } + + async function handleUploadProject(opts: { + mode: 'remix' | 'replicate'; + files: Array<{ file: File; role: 'reference' | 'subject' }>; + prompt?: string; + styleId?: string; + count?: number; + }) { + if (uploadLoading) return; + setUploadLoading(true); + try { + const uploadedImages = await Promise.all(opts.files.map(item => uploadImage(item.file, item.role))); + const r = await fetch('/api/projects/from-upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uploadedImages, + mode: opts.mode, + remixPrompt: opts.mode === 'remix' ? opts.prompt : undefined, + userHint: opts.mode === 'replicate' ? opts.prompt : undefined, + styleId: opts.styleId, + count: opts.count, + }), + }); + if (!r.ok) { + alert('上传项目创建失败:' + (await r.text())); + return; + } + const d: ProjectFromUploadResponse = await r.json(); + setProvider(d.provider); + const all = await refreshSessions(); + const s = all.find(x => x.id === d.sessionId) ?? null; + setCurrent(s); + } catch (error) { + alert('上传失败:' + String(error)); + } finally { + setUploadLoading(false); + } + } + async function handleAction(imageId: string, action: 'select' | 'reject' | 'reset') { if (!current) return; const r = await fetch('/api/select', { @@ -243,7 +296,12 @@ export default function Home() {
- + {current && (
diff --git a/src/components/PromptPanel.tsx b/src/components/PromptPanel.tsx new file mode 100644 index 0000000..a747fb7 --- /dev/null +++ b/src/components/PromptPanel.tsx @@ -0,0 +1,326 @@ +'use client'; + +import { useRef, useState } from 'react'; + +const PRESET_STYLES = [ + { id: 'plush', label: '毛绒玩偶' }, + { id: 'mecha', label: '机甲风' }, + { id: 'kawaii', label: '可爱萌系' }, + { id: 'blueprint', label: '专利蓝图' }, + { id: 'cyber', label: '赛博朋克' }, + { id: 'minimal', label: '极简' }, +]; + +type UploadProjectFile = { + file: File; + role: 'reference' | 'subject'; +}; + +export type PromptPanelProps = { + onGenerate: (opts: { prompt: string; refImages: string[]; count: number; style?: string }) => void; + onUploadProject: (opts: { + mode: 'remix' | 'replicate'; + files: UploadProjectFile[]; + prompt?: string; + styleId?: string; + count?: number; + }) => void; + loading: boolean; + uploadLoading: boolean; +}; + +type Tab = 'idea' | 'remix' | 'replicate'; + +function filePreview(file: File | null) { + return file ? URL.createObjectURL(file) : ''; +} + +export default function PromptPanel({ onGenerate, onUploadProject, loading, uploadLoading }: PromptPanelProps) { + const [tab, setTab] = useState('idea'); + const [prompt, setPrompt] = useState('AI 毛绒陪伴玩具,机甲头盔,胸前挂 M logo,橙白配色,圆胖体型'); + const [refs, setRefs] = useState([]); + const [count, setCount] = useState(8); + const [style, setStyle] = useState(''); + const [remixFiles, setRemixFiles] = useState([]); + const [remixPrompt, setRemixPrompt] = useState('保留原角色轮廓和五官,改成原创毛绒玩具设计'); + const [replicateFile, setReplicateFile] = useState(null); + const [replicateHint, setReplicateHint] = useState(''); + const fileInput = useRef(null); + const remixInput = useRef(null); + const replicateInput = useRef(null); + + function handleFiles(files: FileList | null) { + if (!files) return; + Array.from(files).slice(0, 4 - refs.length).forEach(f => { + const r = new FileReader(); + r.onload = () => setRefs(prev => [...prev, r.result as string]); + r.readAsDataURL(f); + }); + } + + function submitIdea() { + if (!prompt.trim() || loading) return; + onGenerate({ prompt: prompt.trim(), refImages: refs, count, style: style || undefined }); + } + + function submitRemix() { + if (!remixFiles.length || uploadLoading) return; + onUploadProject({ + mode: 'remix', + files: remixFiles.map(file => ({ file, role: 'reference' })), + prompt: remixPrompt.trim(), + styleId: style || undefined, + count, + }); + } + + function submitReplicate() { + if (!replicateFile || uploadLoading) return; + onUploadProject({ + mode: 'replicate', + files: [{ file: replicateFile, role: 'subject' }], + prompt: replicateHint.trim(), + count: 1, + }); + } + + const busy = loading || uploadLoading; + const replicatePreview = filePreview(replicateFile); + + return ( +
+
+
+ Step · 01 · Input + · 想法 / 二创 / 复刻 +
+
+ {[ + ['idea', '想法'], + ['remix', '二创'], + ['replicate', '复刻'], + ].map(([id, label]) => ( + + ))} +
+
+ + {tab === 'idea' && ( + <> +
+ +