-
Notifications
You must be signed in to change notification settings - Fork 0
[codex] Add static subject designer catalog #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,3 +8,4 @@ playwright-report/ | |
| .env.* | ||
| !.env.example | ||
| .vercel | ||
| firebase-debug.log* | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { buildSubjectDescription } from '../domain/subjectDescription'; | ||
| import { getSubjectCatalog } from '../domain/subjectCatalog'; | ||
| import type { SubjectProfile } from '../domain/types'; | ||
| import { useProject } from '../state/ProjectContext'; | ||
|
|
||
| export function SubjectDesigner(){ | ||
| const {project,dispatch}=useProject(),catalog=getSubjectCatalog(),profile=project.subjectProfile,photoMode=project.referencePhotos.length>0; | ||
| const items=catalog.items.filter((item)=>item.categoryId===profile.categoryId).sort((a,b)=>Number(b.trend)-Number(a.trend)||a.sortOrder-b.sortOrder); | ||
| const update=(patch:Partial<SubjectProfile>)=>dispatch({type:'update',patch:{subjectProfile:{...profile,...patch,catalogVersion:catalog.version}}}); | ||
| const toggle=(key:'personalityIds'|'propIds',id:string)=>{const current=profile[key];if(current.includes(id))return update({[key]:current.filter((item)=>item!==id)});if(current.length>=2)return;update({[key]:[...current,id]});}; | ||
| const description=buildSubjectDescription(profile,project.referencePhotos); | ||
| return <div className="subject-designer"><div className="subject-heading"><div><strong>角色題材</strong><small>{photoMode?'照片決定外觀,選項補充角色設定':`內建題材 · ${catalog.items.length} 個子項目`}</small></div><span>{photoMode?'照片模式':profile.baseMode==='catalog'?'內建題材':'自訂主體'}</span></div> | ||
| {!photoMode&&<div className="mode-switch"><button className={profile.baseMode==='catalog'?'active':''} onClick={()=>update({baseMode:'catalog'})}>內建題材</button><button className={profile.baseMode==='custom'?'active':''} onClick={()=>update({baseMode:'custom'})}>自訂主體</button></div>} | ||
| {!photoMode&&profile.baseMode==='catalog'&&<div className="field-grid"><label className="field"><span>類別</span><select value={profile.categoryId} onChange={(event)=>{const categoryId=event.target.value;const first=catalog.items.filter((item)=>item.categoryId===categoryId).sort((a,b)=>Number(b.trend)-Number(a.trend)||a.sortOrder-b.sortOrder)[0];update({categoryId,itemId:first.id});}}>{catalog.categories.sort((a,b)=>a.sortOrder-b.sortOrder).map((item)=><option key={item.id} value={item.id}>{item.label}</option>)}</select></label> | ||
| <label className="field"><span>子項目</span><select value={profile.itemId} onChange={(event)=>update({itemId:event.target.value})}>{items.map((item)=><option key={item.id} value={item.id}>{item.trend?'🔥 ':''}{item.label}</option>)}</select></label></div>} | ||
| {!photoMode&&profile.baseMode==='custom'&&<label className="field"><span>自訂主體</span><input value={profile.customSubject} placeholder="例如:戴圓眼鏡的原創雲朵郵差" onChange={(event)=>update({customSubject:event.target.value})}/></label>} | ||
| <label className="field"><span>角色設定</span><select value={profile.roleId} onChange={(event)=>update({roleId:event.target.value})}>{catalog.roles.map((item)=><option key={item.id} value={item.id}>{item.trend?'🔥 ':''}{item.label}</option>)}</select></label> | ||
| <fieldset className="choice-group"><legend>個性(最多 2 個)</legend><div>{catalog.personalities.map((item)=><label className={profile.personalityIds.includes(item.id)?'selected':''} key={item.id}><input type="checkbox" checked={profile.personalityIds.includes(item.id)} disabled={!profile.personalityIds.includes(item.id)&&profile.personalityIds.length>=2} onChange={()=>toggle('personalityIds',item.id)}/>{item.trend?'🔥 ':''}{item.label}</label>)}</div></fieldset> | ||
| <fieldset className="choice-group"><legend>道具(最多 2 個)</legend><div>{catalog.props.filter((item)=>item.id!=='none').map((item)=><label className={profile.propIds.includes(item.id)?'selected':''} key={item.id}><input type="checkbox" checked={profile.propIds.includes(item.id)} disabled={!profile.propIds.includes(item.id)&&profile.propIds.length>=2} onChange={()=>toggle('propIds',item.id)}/>{item.label}</label>)}</div></fieldset> | ||
| <label className="field"><span>補充描述</span><textarea rows={3} value={profile.extraDetails} placeholder="例如:穿著淺藍襯衫、動作幅度大" onChange={(event)=>update({extraDetails:event.target.value})}/></label> | ||
| <div className="subject-preview"><div><strong>產圖敘述預覽</strong><button onClick={()=>void navigator.clipboard.writeText(description)}>複製</button></div><p>{description}</p><small>題材資料:{catalog.sourceLabel} · {catalog.version}</small></div> | ||
| </div>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { buildTaskMarkdown, createGenerationTask, parseTaskMarkdown, providerUrl | |
| import type { GenerationTask } from '../domain/types'; | ||
| import { fillCaptionSlots } from '../domain/project'; | ||
| import { getReferencePhoto } from '../storage/referencePhotos'; | ||
| import { validateSubjectProfile } from '../domain/subjectDescription'; | ||
| import { useProject } from '../state/ProjectContext'; | ||
|
|
||
| export function TaskPanel() { | ||
|
|
@@ -12,6 +13,7 @@ export function TaskPanel() { | |
| async function create() { | ||
| setError(''); | ||
| if (project.captionSlots.length !== cellCount) return setError(`每個候選格都需要文字/動作,目前 ${project.captionSlots.length}/${cellCount}`); | ||
| const subjectIssues=validateSubjectProfile(project.subjectProfile,project.referencePhotos.length>0);if(subjectIssues.length)return setError(subjectIssues[0]); | ||
| if (project.referencePhotos.length && !project.photoRightsConfirmed) return setError('請先確認參考照片使用權與肖像同意'); | ||
| for (const photo of project.referencePhotos) if (!await getReferencePhoto(photo.id)) return setError(`找不到參考照片:${photo.name}`); | ||
| const task = createGenerationTask(project); const markdown = buildTaskMarkdown(project, task); | ||
|
|
@@ -26,6 +28,7 @@ export function TaskPanel() { | |
| try { const manifest = parseTaskMarkdown(await file.text()); const targetCount = (manifest.targetCount ?? manifest.count) as typeof project.settings.count; const cells = manifest.cellCount ?? manifest.count; | ||
| dispatch({ type: 'update', patch: (current) => ({ ...current, generationProvider: manifest.provider, | ||
| settings: { ...current.settings, character: manifest.character, count: targetCount, rows: manifest.rows, columns: manifest.columns }, | ||
| subjectProfile: manifest.subjectProfile ?? { ...current.subjectProfile, baseMode: 'custom', customSubject: '', roleId: 'none', personalityIds: [], propIds: [], extraDetails: manifest.character }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a v3 task MD was created in photo mode and is imported into a project that does not already have those reference-photo blobs, this assigns the saved Useful? React with 👍 / 👎. |
||
| captionSlots: fillCaptionSlots(manifest.captions.map((item) => ({ id: crypto.randomUUID(), phraseId: `md-${item.index}`, text: item.text, category: 'MD 匯入', intent: item.intent, visible: item.visible })), cells), | ||
| }) }); setError(''); | ||
| } catch (reason) { setError(reason instanceof Error ? reason.message : 'MD 匯入失敗'); } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,16 @@ | ||
| import { getSpec } from './specs'; | ||
| import type { CaptionSlot, StickerProject, StickerType, StyleRecipe } from './types'; | ||
| import { SUBJECT_CATALOG_VERSION } from './subjectCatalog'; | ||
| import type { CaptionSlot, StickerProject, StickerType, StyleRecipe, SubjectProfile } from './types'; | ||
|
|
||
| const DEFAULT_CAPTIONS = ['收到', 'OK', '謝謝', '加油', '等一下', '太棒了', '哭哭', '晚安', '讚啦']; | ||
|
|
||
| export const DEFAULT_STYLE: StyleRecipe = { | ||
| primary: 'mascot', palette: 'vivid', outline: 'bold', rendering: 'soft', shape: 'rounded', | ||
| }; | ||
| export const DEFAULT_SUBJECT_PROFILE: SubjectProfile = { | ||
| catalogVersion: SUBJECT_CATALOG_VERSION, baseMode: 'catalog', categoryId: 'animals', itemId: 'taiwan-black-bear', customSubject: '', | ||
| roleId: 'office-life', personalityIds: ['dramatic'], propIds: [], extraDetails: '', | ||
| }; | ||
|
|
||
| function slot(text: string, index: number): CaptionSlot { | ||
| return { id: crypto.randomUUID(), phraseId: `legacy-${index}`, text, category: '基本回應', intent: text, visible: true }; | ||
|
|
@@ -14,9 +19,10 @@ function slot(text: string, index: number): CaptionSlot { | |
| export function createProject(type: StickerType = 'static'): StickerProject { | ||
| const spec = getSpec(type); | ||
| return { | ||
| version: 4, name: '我的 LINE 貼圖', type, generationProvider: 'chatgpt', | ||
| settings: { character: '一隻圓滾滾的台灣黑熊,上班族襯衫,表情誇張可愛', count: spec.counts[0], rows: 3, columns: 3, padding: 10, fontSize: 42, loops: spec.minLoops ?? 1 }, | ||
| version: 5, name: '我的 LINE 貼圖', type, generationProvider: 'chatgpt', | ||
| settings: { character: '', count: spec.counts[0], rows: 3, columns: 3, padding: 10, fontSize: 42, loops: spec.minLoops ?? 1 }, | ||
| captionSlots: DEFAULT_CAPTIONS.map(slot), styleRecipe: DEFAULT_STYLE, | ||
| subjectProfile: { ...DEFAULT_SUBJECT_PROFILE, personalityIds: [...DEFAULT_SUBJECT_PROFILE.personalityIds], propIds: [] }, | ||
| referencePhotos: [], photoRightsConfirmed: false, | ||
| sourceDataUrl: '', stickers: [], animationSets: {}, generationTasks: [], generationAttempts: [], | ||
| rightsConfirmed: false, complianceReport: { checkedAt: 0, blockingCount: 0, warningCount: 0 }, updatedAt: Date.now(), | ||
|
|
@@ -40,17 +46,18 @@ interface V2Project { | |
| sourceDataUrl: string; stickers: StickerProject['stickers']; frames?: StickerProject['animationSets'][string]; updatedAt: number; | ||
| } | ||
|
|
||
| interface V3Project extends Omit<StickerProject, 'version' | 'settings' | 'referencePhotos' | 'photoRightsConfirmed'> { | ||
| interface V3Project extends Omit<StickerProject, 'version' | 'settings' | 'referencePhotos' | 'photoRightsConfirmed' | 'subjectProfile'> { | ||
| version: 3; settings: Omit<StickerProject['settings'], 'rows'>; | ||
| } | ||
| interface V4Project extends Omit<StickerProject, 'version' | 'subjectProfile'> { version: 4 } | ||
|
|
||
| export function migrateV2(value: V2Project): StickerProject { | ||
| const base = createProject(value.type); | ||
| const phrases = String(value.settings.phrases || '').split(',').map((item) => item.trim()).filter(Boolean); | ||
| const stickers = (value.stickers || []).map((asset, index) => ({ ...asset, provenanceMark: asset.provenanceMark ?? 'unknown' as const, | ||
| gridIndex: index, included: index < value.settings.count, selectedAt: index < value.settings.count ? index + 1 : undefined })); | ||
| const firstId = stickers[0]?.id; | ||
| return { ...base, name: value.name, settings: { ...base.settings, character: value.settings.character, count: value.settings.count, columns: value.settings.columns, padding: value.settings.padding, fontSize: value.settings.fontSize, loops: value.settings.loops }, | ||
| return { ...base, name: value.name, settings: { ...base.settings, character: value.settings.character, count: value.settings.count, columns: value.settings.columns, padding: value.settings.padding, fontSize: value.settings.fontSize, loops: value.settings.loops }, subjectProfile: legacySubject(value.settings.character), | ||
| captionSlots: phrases.map(slot), sourceDataUrl: value.sourceDataUrl, stickers, | ||
| animationSets: firstId && value.frames?.length ? { [firstId]: value.frames } : {}, updatedAt: value.updatedAt }; | ||
| } | ||
|
|
@@ -60,13 +67,21 @@ export function migrateV3(value: V3Project): StickerProject { | |
| const count = value.settings.count; | ||
| const columns = Math.min(8, Math.max(2, value.settings.columns || 3)); | ||
| const rows = Math.min(8, Math.max(2, Math.ceil(count / columns))); | ||
| return { ...value, version: 4, settings: { ...value.settings, rows, columns }, | ||
| return { ...value, version: 5, settings: { ...value.settings, rows, columns }, | ||
| captionSlots: fillCaptionSlots(value.captionSlots, rows * columns), | ||
| stickers: value.stickers.map((asset, index) => ({ ...asset, gridIndex: index, included: index < count, selectedAt: index < count ? index + 1 : undefined })), | ||
| referencePhotos: [], photoRightsConfirmed: false, | ||
| referencePhotos: [], photoRightsConfirmed: false, subjectProfile: legacySubject(value.settings.character), | ||
| complianceReport: value.complianceReport ?? base.complianceReport }; | ||
| } | ||
|
|
||
| export function migrateV4(value: V4Project): StickerProject { | ||
| return { ...value, version: 5, subjectProfile: legacySubject(value.settings.character) }; | ||
| } | ||
|
|
||
| function legacySubject(character: string): SubjectProfile { | ||
| return { ...DEFAULT_SUBJECT_PROFILE, baseMode: 'custom', customSubject: '', personalityIds: [], propIds: [], roleId: 'none', extraDetails: character || '' }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a v2/v3/v4 project without reference photos is loaded, this migrated profile is immediately invalid because Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| export function fillCaptionSlots(items: CaptionSlot[], count: number): CaptionSlot[] { | ||
| const result = items.slice(0, count); | ||
| for (let index = result.length; index < count; index += 1) result.push(slot(DEFAULT_CAPTIONS[index % DEFAULT_CAPTIONS.length], index)); | ||
|
|
@@ -79,9 +94,10 @@ export const RECOMMENDED_GRIDS: Record<number, { rows: number; columns: number } | |
| }; | ||
|
|
||
| export function parseProject(raw: string): StickerProject { | ||
| const value = JSON.parse(raw) as StickerProject | V3Project | V2Project; | ||
| const value = JSON.parse(raw) as StickerProject | V4Project | V3Project | V2Project; | ||
| if (value.version === 2) return migrateV2(value); | ||
| if (value.version === 3) return migrateV3(value); | ||
| if (value.version !== 4 || !value.type || !value.settings) throw new Error('不支援的專案格式'); | ||
| if (value.version === 4) return migrateV4(value); | ||
| if (value.version !== 5 || !value.type || !value.settings || !value.subjectProfile) throw new Error('不支援的專案格式'); | ||
| return value; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { getSubjectCatalog, SUBJECT_CATALOG_VERSION } from './subjectCatalog'; | ||
|
|
||
| describe('靜態角色題材庫',()=>{ | ||
| const catalog=getSubjectCatalog(); | ||
| it('提供版本化的 6 類與至少 100 個子項目',()=>{expect(catalog.version).toBe(SUBJECT_CATALOG_VERSION);expect(catalog.categories.length).toBeGreaterThanOrEqual(6);expect(catalog.items.length).toBeGreaterThanOrEqual(100);}); | ||
| it('類別與子項目 ID 唯一',()=>{expect(new Set(catalog.categories.map((item)=>item.id)).size).toBe(catalog.categories.length);expect(new Set(catalog.items.map((item)=>item.id)).size).toBe(catalog.items.length);}); | ||
| it('每類熱門題材優先排序且不含已知品牌角色名',()=>{for(const category of catalog.categories){const items=catalog.items.filter((item)=>item.categoryId===category.id).sort((a,b)=>Number(b.trend)-Number(a.trend)||a.sortOrder-b.sortOrder);const firstNonTrend=items.findIndex((item)=>!item.trend);expect(items.slice(Math.max(0,firstNonTrend)).some((item)=>item.trend)).toBe(false);}const content=JSON.stringify(catalog);for(const banned of ['Hello Kitty','Snoopy','海綿寶寶','小熊維尼','迪士尼','三麗鷗'])expect(content).not.toContain(banned);}); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Importing an older v1/v2 task with no
subjectProfilecreates the same invalid state for projects without reference photos: the imported character text is stored only inextraDetails, whilecustomSubjectstays empty andbaseModeiscustom, so the next MD download is blocked byvalidateSubjectProfilewith請輸入自訂主體despite the imported manifest containing a validcharacter.Useful? React with 👍 / 👎.