From 3dc5995a3a88592249a4bad531fcd5ec46b6ca72 Mon Sep 17 00:00:00 2001 From: "similai.tw" Date: Mon, 22 Jun 2026 19:32:52 +0800 Subject: [PATCH] Fix photo-only generation manifest --- package-lock.json | 4 +-- package.json | 2 +- src/components/TaskPanel.tsx | 14 ++++++++-- src/domain/project.test.ts | 1 + src/domain/project.ts | 9 ++++-- src/domain/subjectDescription.test.ts | 1 + src/domain/subjectDescription.ts | 13 +++++++-- src/providers/tasks.test.ts | 13 +++++++-- src/providers/tasks.ts | 40 ++++++++++++++++++++++----- tests/e2e/smoke.spec.ts | 8 +++++- 10 files changed, 85 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddcf8e4..d16856a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "line-sticker-studio", - "version": "5.0.0", + "version": "5.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "line-sticker-studio", - "version": "5.0.0", + "version": "5.0.1", "license": "MIT", "dependencies": { "@vitejs/plugin-react": "latest", diff --git a/package.json b/package.json index 5fb7bdc..f36cd4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "line-sticker-studio", - "version": "5.0.0", + "version": "5.0.1", "private": true, "description": "ChatGPT and Gemini assisted LINE sticker creation studio with local compliance validation", "license": "MIT", diff --git a/src/components/TaskPanel.tsx b/src/components/TaskPanel.tsx index 0841f89..2ebbe67 100644 --- a/src/components/TaskPanel.tsx +++ b/src/components/TaskPanel.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from 'react'; import { downloadBlob } from '../export/exportZip'; import { buildTaskMarkdown, createGenerationTask, parseTaskMarkdown, providerUrl } from '../providers/tasks'; -import type { GenerationTask } from '../domain/types'; +import type { TaskManifest } from '../providers/tasks'; +import type { GenerationTask, StickerProject } from '../domain/types'; import { fillCaptionSlots } from '../domain/project'; import { getReferencePhoto } from '../storage/referencePhotos'; import { validateSubjectProfile } from '../domain/subjectDescription'; @@ -28,7 +29,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 }, + subjectProfile: mergeImportedSubject(current.subjectProfile, manifest), 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 匯入失敗'); } @@ -42,3 +43,12 @@ export function TaskPanel() { {project.generationAttempts.length > 0 &&
匯入紀錄{project.generationAttempts.slice(-4).reverse().map((attempt) => {attempt.provider} · {attempt.sourceHash.slice(0, 10)} · {attempt.provenanceMark})}
} ; } + +export function mergeImportedSubject(current: StickerProject['subjectProfile'], manifest: TaskManifest): StickerProject['subjectProfile'] { + const generated = manifest.generationSubject; + if (!generated) return manifest.subjectProfile ?? { ...current, baseMode: 'custom', customSubject: '', roleId: 'none', personalityIds: [], propIds: [], extraDetails: manifest.character }; + const shared = { roleId: generated.roleId, personalityIds: generated.personalityIds, propIds: generated.propIds, extraDetails: generated.extraDetails }; + if (generated.source === 'photo') return { ...current, ...shared }; + if (generated.source === 'custom') return { ...current, ...shared, baseMode: 'custom', customSubject: generated.customSubject }; + return { ...current, ...shared, baseMode: 'catalog', categoryId: generated.categoryId, itemId: generated.itemId }; +} diff --git a/src/domain/project.test.ts b/src/domain/project.test.ts index 7d6598d..ee75a59 100644 --- a/src/domain/project.test.ts +++ b/src/domain/project.test.ts @@ -17,4 +17,5 @@ describe('貼圖專案', () => { }); it('可以把 v3 網格與候選狀態遷移到 v5',()=>{const current=createProject();const legacy={...current,version:3,settings:{character:'舊角色',count:8,columns:4,padding:10,fontSize:42,loops:1}};delete (legacy as Partial).referencePhotos;delete (legacy as Partial).photoRightsConfirmed;delete (legacy as Partial).subjectProfile;const project=parseProject(JSON.stringify(legacy));expect(project.version).toBe(5);expect(project.settings.rows).toBe(2);expect(project.referencePhotos).toEqual([]);expect(project.subjectProfile.extraDetails).toBe('舊角色');}); it('可以把 v4 角色描述遷移到 v5',()=>{const current=createProject();const legacy={...current,version:4,settings:{...current.settings,character:'戴眼鏡的舊角色'}};delete (legacy as Partial).subjectProfile;const project=parseProject(JSON.stringify(legacy));expect(project.version).toBe(5);expect(project.subjectProfile.extraDetails).toBe('戴眼鏡的舊角色');}); + it('載入 v2-v5 時清除已知舊版黑熊預設,保留自訂描述',()=>{const defaultText='一隻圓滾滾的台灣黑熊,上班族襯衫,表情誇張可愛';for(const version of [2,3,4,5] as const){let raw:unknown;if(version===2)raw={version:2,name:'舊專案',type:'static',settings:{character:defaultText,phrases:'收到',count:8,columns:3,padding:10,fontSize:42,loops:1},sourceDataUrl:'',stickers:[],updatedAt:1};else {const current=createProject();if(version===3){raw={...current,version:3,settings:{character:defaultText,count:8,columns:3,padding:10,fontSize:42,loops:1}};delete (raw as Partial).subjectProfile;}else if(version===4){raw={...current,version:4,settings:{...current.settings,character:defaultText}};delete (raw as Partial).subjectProfile;}else raw={...current,subjectProfile:{...current.subjectProfile,extraDetails:defaultText}};}expect(parseProject(JSON.stringify(raw)).subjectProfile.extraDetails).toBe('');}const custom=createProject();custom.subjectProfile.extraDetails='請保留我的紅色眼鏡';expect(parseProject(serializeProject(custom)).subjectProfile.extraDetails).toBe('請保留我的紅色眼鏡');}); }); diff --git a/src/domain/project.ts b/src/domain/project.ts index 12a92d9..f56b67d 100644 --- a/src/domain/project.ts +++ b/src/domain/project.ts @@ -1,5 +1,6 @@ import { getSpec } from './specs'; import { SUBJECT_CATALOG_VERSION } from './subjectCatalog'; +import { cleanLegacyDefaultCharacter } from './subjectDescription'; import type { CaptionSlot, StickerProject, StickerType, StyleRecipe, SubjectProfile } from './types'; const DEFAULT_CAPTIONS = ['收到', 'OK', '謝謝', '加油', '等一下', '太棒了', '哭哭', '晚安', '讚啦']; @@ -79,7 +80,11 @@ export function migrateV4(value: V4Project): StickerProject { } function legacySubject(character: string): SubjectProfile { - return { ...DEFAULT_SUBJECT_PROFILE, baseMode: 'custom', customSubject: '', personalityIds: [], propIds: [], roleId: 'none', extraDetails: character || '' }; + return { ...DEFAULT_SUBJECT_PROFILE, baseMode: 'custom', customSubject: '', personalityIds: [], propIds: [], roleId: 'none', extraDetails: cleanLegacyDefaultCharacter(character || '') }; +} + +function normalizeV5(project: StickerProject): StickerProject { + return { ...project, subjectProfile: { ...project.subjectProfile, extraDetails: cleanLegacyDefaultCharacter(project.subjectProfile.extraDetails || '') } }; } export function fillCaptionSlots(items: CaptionSlot[], count: number): CaptionSlot[] { @@ -99,5 +104,5 @@ export function parseProject(raw: string): StickerProject { if (value.version === 3) return migrateV3(value); if (value.version === 4) return migrateV4(value); if (value.version !== 5 || !value.type || !value.settings || !value.subjectProfile) throw new Error('不支援的專案格式'); - return value; + return normalizeV5(value); } diff --git a/src/domain/subjectDescription.test.ts b/src/domain/subjectDescription.test.ts index 2606c15..81c47da 100644 --- a/src/domain/subjectDescription.test.ts +++ b/src/domain/subjectDescription.test.ts @@ -5,5 +5,6 @@ describe('角色敘述產生器',()=>{ it('無照片時使用內建題材',()=>{const project=createProject();const text=buildSubjectDescription(project.subjectProfile,[]);expect(text).toContain('black bear');expect(text).toContain('office work');}); it('自訂模式使用自訂主體',()=>{const project=createProject();project.subjectProfile={...project.subjectProfile,baseMode:'custom',customSubject:'戴圓眼鏡的雲朵郵差',roleId:'none',personalityIds:[],propIds:[]};expect(buildSubjectDescription(project.subjectProfile,[])).toContain('戴圓眼鏡的雲朵郵差');}); it('照片模式只使用照片外觀,不混入內建主體',()=>{const project=createProject();const photo={id:'p',name:'me.jpg',type:'image/jpeg' as const,width:100,height:100,bytes:10,hash:'x',order:0,primary:true};const text=buildSubjectDescription(project.subjectProfile,[photo]);expect(text).toContain('me.jpg');expect(text).toContain('non-sensitive');expect(text).not.toContain('black bear');}); + it('照片模式清除已知舊版黑熊預設但保留自訂補充',()=>{const project=createProject();const photo={id:'p',name:'me.jpg',type:'image/jpeg' as const,width:100,height:100,bytes:10,hash:'x',order:0,primary:true};project.subjectProfile.extraDetails='一隻圓滾滾的台灣黑熊,上班族襯衫,表情誇張可愛';expect(buildSubjectDescription(project.subjectProfile,[photo])).not.toContain('台灣黑熊');project.subjectProfile.extraDetails='請保留我的紅色眼鏡';expect(buildSubjectDescription(project.subjectProfile,[photo])).toContain('請保留我的紅色眼鏡');}); it('限制個性與道具最多兩個',()=>{const project=createProject();const profile={...project.subjectProfile,personalityIds:['lazy','funny','sweet'],propIds:['phone','book','coffee']};expect(validateSubjectProfile(profile,false)).toEqual(expect.arrayContaining(['個性最多選擇 2 個','道具最多選擇 2 個']));}); }); diff --git a/src/domain/subjectDescription.ts b/src/domain/subjectDescription.ts index 76cdd1f..6d95a0d 100644 --- a/src/domain/subjectDescription.ts +++ b/src/domain/subjectDescription.ts @@ -1,12 +1,18 @@ import { getSubjectCatalog } from './subjectCatalog'; import type { ReferencePhoto, SubjectProfile } from './types'; +export const LEGACY_DEFAULT_CHARACTER = '一隻圓滾滾的台灣黑熊,上班族襯衫,表情誇張可愛'; + +export function cleanLegacyDefaultCharacter(value: string): string { + return value.trim() === LEGACY_DEFAULT_CHARACTER ? '' : value; +} + export function buildSubjectDescription(profile: SubjectProfile, photos: ReferencePhoto[]): string { const catalog = getSubjectCatalog(); const parts: string[] = []; if (photos.length) { const ordered = [...photos].sort((a,b)=>a.order-b.order); const primary = ordered.find((photo)=>photo.primary) ?? ordered[0]; - parts.push(`Use the uploaded reference photos as the appearance source, with ${primary.name} as the primary reference.`); - parts.push('Analyze only visible, non-sensitive appearance: facial structure, hairstyle or fur pattern, colors, clothing and body proportions. Preserve recognizable visual traits consistently while converting the subject into an original LINE sticker character. Ignore photo backgrounds. Do not infer identity, ethnicity, health, religion or other sensitive attributes.'); + parts.push(`Use the uploaded reference photos as the only appearance source, with ${primary.name} as the primary reference.`); + parts.push('Do not use a default character, catalog subject or prior conversation character. If a photo cannot be read, stop and ask for it to be re-uploaded instead of generating a substitute. Analyze only visible, non-sensitive appearance: facial structure, hairstyle or fur pattern, colors, clothing and body proportions. Preserve recognizable visual traits consistently while converting the subject into an original LINE sticker character. Never turn a person into an animal unless explicitly requested. Ignore photo backgrounds. Do not infer identity, ethnicity, health, religion or other sensitive attributes.'); } else if (profile.baseMode === 'custom') { const subject = profile.customSubject.trim(); parts.push(`Create an original LINE sticker subject based on this description: ${subject || 'a friendly original character'}.`); } else { @@ -16,7 +22,8 @@ export function buildSubjectDescription(profile: SubjectProfile, photos: Referen const role = catalog.roles.find((entry)=>entry.id===profile.roleId); if(role?.prompt)parts.push(`Role and daily context: ${role.prompt}.`); const personalities = profile.personalityIds.slice(0,2).map((id)=>catalog.personalities.find((entry)=>entry.id===id)?.prompt).filter(Boolean); if(personalities.length)parts.push(`Personality: ${personalities.join('; ')}.`); const props = profile.propIds.slice(0,2).map((id)=>catalog.props.find((entry)=>entry.id===id)?.prompt).filter(Boolean); if(props.length)parts.push(`Optional recurring props: ${props.join('; ')}.`); - if(profile.extraDetails.trim())parts.push(`Additional creator direction: ${profile.extraDetails.trim()}.`); + const extraDetails = photos.length ? cleanLegacyDefaultCharacter(profile.extraDetails) : profile.extraDetails; + if(extraDetails.trim())parts.push(`Additional creator direction: ${extraDetails.trim()}.`); parts.push('Do not imitate named artists, brands, logos, trademarks or protected characters.'); return parts.join(' '); } diff --git a/src/providers/tasks.test.ts b/src/providers/tasks.test.ts index b4a0b08..ea0c9c8 100644 --- a/src/providers/tasks.test.ts +++ b/src/providers/tasks.test.ts @@ -6,9 +6,18 @@ describe('雙平台 MD 任務', () => { const project = createProject(); project.generationProvider = provider; project.referencePhotos=[{id:'photo',name:'me.jpg',type:'image/jpeg',width:800,height:1000,bytes:100,hash:'abc',order:0,primary:true}]; const task = createGenerationTask(project); const markdown = buildTaskMarkdown(project, task); - expect(parseTaskMarkdown(markdown)).toMatchObject({ schema:'line-sticker-task/v3',provider,taskId:task.id,targetCount:8,count:9,rows:3,columns:3,subjectProfile:project.subjectProfile }); + expect(parseTaskMarkdown(markdown)).toMatchObject({ schema:'line-sticker-task/v4',provider,taskId:task.id,targetCount:8,count:9,rows:3,columns:3,generationSubject:{source:'photo',photoNames:['me.jpg']} }); + expect(parseTaskMarkdown(markdown).subjectProfile).toBeUndefined(); expect(markdown).toContain('me.jpg'); + expect(markdown).toContain('ONLY appearance and identity source'); + expect(markdown).toContain('STOP and ask me to re-upload'); expect(markdown).toContain('Do not include logos, brands, watermarks'); + expect(markdown.toLowerCase()).not.toContain('black bear'); + expect(markdown).not.toContain('台灣黑熊'); + expect(markdown).not.toContain('taiwan-black-bear'); + expect(markdown).not.toContain('"categoryId": "animals"'); }); - it('仍可解析 v1 任務',()=>{const project=createProject();const task=createGenerationTask(project);const markdown=buildTaskMarkdown(project,task).replace('line-sticker-task/v3','line-sticker-task/v1');expect(parseTaskMarkdown(markdown).schema).toBe('line-sticker-task/v1');}); + it('照片模式會清除舊版預設黑熊描述',()=>{const project=createProject();project.referencePhotos=[{id:'photo',name:'family.webp',type:'image/webp',width:800,height:1000,bytes:100,hash:'abc',order:0,primary:true}];project.subjectProfile.extraDetails='一隻圓滾滾的台灣黑熊,上班族襯衫,表情誇張可愛';const markdown=buildTaskMarkdown(project,createGenerationTask(project));expect(markdown).not.toContain('台灣黑熊');expect(markdown.toLowerCase()).not.toContain('black bear');}); + it('無照片仍可使用內建黑熊題材',()=>{const project=createProject();const manifest=parseTaskMarkdown(buildTaskMarkdown(project,createGenerationTask(project)));expect(manifest.generationSubject).toMatchObject({source:'catalog',categoryId:'animals',itemId:'taiwan-black-bear'});expect(manifest.character).toContain('black bear');}); + it('仍可解析 v1 任務',()=>{const project=createProject();const task=createGenerationTask(project);const markdown=buildTaskMarkdown(project,task).replace('line-sticker-task/v4','line-sticker-task/v1');expect(parseTaskMarkdown(markdown).schema).toBe('line-sticker-task/v1');}); }); diff --git a/src/providers/tasks.ts b/src/providers/tasks.ts index af2c017..41c3e6b 100644 --- a/src/providers/tasks.ts +++ b/src/providers/tasks.ts @@ -1,13 +1,21 @@ import { getSpec } from '../domain/specs'; import { stylePrompt } from '../domain/styles'; -import { buildSubjectDescription } from '../domain/subjectDescription'; +import { buildSubjectDescription, cleanLegacyDefaultCharacter } from '../domain/subjectDescription'; import type { GenerationProvider, GenerationTask, ReferencePhoto, StickerProject, SubjectProfile, TaskKind } from '../domain/types'; +interface SubjectOptions { roleId: string; personalityIds: string[]; propIds: string[]; extraDetails: string } +export type GenerationSubject = + | ({ source: 'photo'; photoNames: string[] } & SubjectOptions) + | ({ source: 'catalog'; categoryId: string; itemId: string } & SubjectOptions) + | ({ source: 'custom'; customSubject: string } & SubjectOptions); + export interface TaskManifest { - schema: 'line-sticker-task/v1' | 'line-sticker-task/v2' | 'line-sticker-task/v3'; taskId: string; provider: GenerationProvider; kind: TaskKind; + schema: 'line-sticker-task/v1' | 'line-sticker-task/v2' | 'line-sticker-task/v3' | 'line-sticker-task/v4'; taskId: string; provider: GenerationProvider; kind: TaskKind; stickerType: StickerProject['type']; count: number; targetCount?: number; columns: number; rows: number; cellCount?: number; character: string; style: string; captions: { index: number; text: string; intent: string; visible: boolean }[]; referencePhotos?: Pick[]; + generationSubject?: GenerationSubject; + /** Legacy v1-v3 import only. New manifests never serialize inactive subject data. */ subjectProfile?: SubjectProfile; characterDescription?: string; catalogVersion?: string; deliverables?: { kind: 'sticker-sheet' | 'animation-frames' | 'popup-frames' | 'effect-frames'; description: string }[]; output: { format: 'PNG'; transparent: true; width: number; height: number; frames?: { min: number; max: number; maxDurationMs: number } }; @@ -22,15 +30,31 @@ export function createGenerationTask(project: StickerProject, kind: TaskKind = ' return { id: crypto.randomUUID(), kind, provider: project.generationProvider, status: 'ready', createdAt: Date.now() }; } +export function buildGenerationSubject(project: StickerProject): GenerationSubject { + const profile = project.subjectProfile; + const shared: SubjectOptions = { + roleId: profile.roleId, + personalityIds: profile.personalityIds.slice(0, 2), + propIds: profile.propIds.slice(0, 2), + extraDetails: project.referencePhotos.length ? cleanLegacyDefaultCharacter(profile.extraDetails) : profile.extraDetails, + }; + if (project.referencePhotos.length) { + const photoNames = [...project.referencePhotos].sort((a, b) => a.order - b.order).map((photo) => photo.name); + return { source: 'photo', photoNames, ...shared }; + } + if (profile.baseMode === 'custom') return { source: 'custom', customSubject: profile.customSubject, ...shared }; + return { source: 'catalog', categoryId: profile.categoryId, itemId: profile.itemId, ...shared }; +} + export function buildTaskManifest(project: StickerProject, task: GenerationTask): TaskManifest { const spec = getSpec(project.type); const cellCount = project.settings.rows * project.settings.columns; - const characterDescription=buildSubjectDescription(project.subjectProfile,project.referencePhotos); + const characterDescription = buildSubjectDescription(project.subjectProfile, project.referencePhotos); const animationKind = project.type === 'animated' ? 'animation-frames' : project.type === 'popup' ? 'popup-frames' : project.type === 'effect' ? 'effect-frames' : null; const deliverables: NonNullable = [{ kind: 'sticker-sheet', description: `Create ${cellCount} candidate illustrations in an exact ${project.settings.columns} column × ${project.settings.rows} row grid.` }]; if (animationKind) deliverables.push({ kind: animationKind, description: `After the sticker sheet is approved, create coherent frame sheets for each selected sticker in this same conversation (${spec.minFrames}–${spec.maxFrames} frames, maximum ${spec.maxDurationMs} ms).` }); - return { schema: 'line-sticker-task/v3', taskId: task.id, provider: task.provider, kind: 'project-generation', stickerType: project.type, + return { schema: 'line-sticker-task/v4', taskId: task.id, provider: task.provider, kind: 'project-generation', stickerType: project.type, count: cellCount, targetCount: project.settings.count, columns: project.settings.columns, rows: project.settings.rows, cellCount, - character: characterDescription, characterDescription, subjectProfile: project.subjectProfile, catalogVersion: project.subjectProfile.catalogVersion, style: stylePrompt(project.styleRecipe), + character: characterDescription, characterDescription, generationSubject: buildGenerationSubject(project), catalogVersion: project.subjectProfile.catalogVersion, style: stylePrompt(project.styleRecipe), captions: project.captionSlots.slice(0, cellCount).map((item, index) => ({ index: index + 1, text: item.text, intent: item.intent, visible: item.visible })), referencePhotos: project.referencePhotos.map(({ id, name, type, width, height, hash, primary }) => ({ id, name, type, width, height, hash, primary })), deliverables, output: { format: 'PNG', transparent: true, width: 1024, height: 1024, @@ -40,16 +64,18 @@ export function buildTaskManifest(project: StickerProject, task: GenerationTask) export function buildTaskMarkdown(project: StickerProject, task: GenerationTask): string { const manifest = buildTaskManifest(project, task); const provider = PROVIDERS[task.provider]; const captions = manifest.captions.map((item) => `| ${String(item.index).padStart(2, '0')} | ${item.visible ? item.text : '無文字'} | ${item.intent} |`).join('\n'); + const hasPhotos = manifest.generationSubject?.source === 'photo'; const photos = manifest.referencePhotos?.length ? manifest.referencePhotos.map((photo, index) => `- ${index + 1}. ${photo.name}${photo.primary ? '(主要參考)' : ''} — ${photo.width}×${photo.height}`).join('\n') : '- 無參考照片;依 Character 描述創作。'; const deliverables = manifest.deliverables?.map((item, index) => `${index + 1}. ${item.description}`).join('\n') ?? ''; - return `# LINE Sticker Studio — ${provider.label} 完整產圖任務\n\n${provider.steps}\n\n\n\n## Reference photos\n\n${photos}\n\nTreat the uploaded photos only as identity and appearance references. Preserve recognizable facial features, hair or fur color, clothing and proportions while transforming them into the requested illustration style. Ignore photo backgrounds. Do not infer identity or sensitive attributes.\n\n## Character and style\n\n- Character: ${manifest.character}\n- Style: ${manifest.style}\n- Keep face, colors, clothing and proportions consistent across every cell and frame.\n- Do not imitate a named artist or protected character. Do not include logos, brands, watermarks, signatures or advertisements.\n- Do not draw caption text; LINE Sticker Studio will typeset Traditional Chinese locally.\n\n## Deliverables\n\n${deliverables}\n\nStart with deliverable 1 only. Return one transparent PNG sticker sheet with exactly ${manifest.columns} columns and ${manifest.rows} rows (${manifest.cellCount} separate candidates). Keep subjects centered, fully visible and separated. After I approve it, continue with later deliverables in this same conversation.\n\n## Candidate intents\n\n| # | Local caption | Pose / emotion intent |\n|---|---|---|\n${captions}\n\n## Return protocol\n\nReturn only the requested image. Do not add a mockup, frame, caption, checkerboard background or explanatory text inside the image.\n`; + const photoRules = hasPhotos ? `\n\n## Mandatory photo-source rules\n\n- The uploaded photos are the ONLY appearance and identity source. Do not use any default character, subject catalog entry, previous character, or fallback subject.\n- If any required photo cannot be read, STOP and ask me to re-upload it. Do not generate an image and do not substitute another subject.\n- Do not turn a person into an animal. Only depict an animal when the uploaded subject is an animal or the creator explicitly says so in Additional creator direction.\n- Preserve the photographed subject's visible facial features, hair or fur, colors, clothing and proportions. Ignore photo backgrounds and do not infer sensitive attributes.` : ''; + return `# LINE Sticker Studio — ${provider.label} 完整產圖任務\n\n${provider.steps}\n\n\n\n## Reference photos\n\n${photos}${photoRules}\n\n## Character and style\n\n- Character: ${manifest.character}\n- Style: ${manifest.style}\n- Keep face, colors, clothing and proportions consistent across every cell and frame.\n- Do not imitate a named artist or protected character. Do not include logos, brands, watermarks, signatures or advertisements.\n- Do not draw caption text; LINE Sticker Studio will typeset Traditional Chinese locally.\n\n## Deliverables\n\n${deliverables}\n\nStart with deliverable 1 only. Return one transparent PNG sticker sheet with exactly ${manifest.columns} columns and ${manifest.rows} rows (${manifest.cellCount} separate candidates). Keep subjects centered, fully visible and separated. After I approve it, continue with later deliverables in this same conversation.\n\n## Candidate intents\n\n| # | Local caption | Pose / emotion intent |\n|---|---|---|\n${captions}\n\n## Return protocol\n\nReturn only the requested image. Do not add a mockup, frame, caption, checkerboard background or explanatory text inside the image.\n`; } export function parseTaskMarkdown(markdown: string): TaskManifest { const match = markdown.match(//); if (!match) throw new Error('找不到 LINE Sticker 任務 manifest'); const value = JSON.parse(match[1]) as TaskManifest; - if (!['line-sticker-task/v1', 'line-sticker-task/v2', 'line-sticker-task/v3'].includes(value.schema) || !value.taskId) throw new Error('不支援的任務格式'); + if (!['line-sticker-task/v1', 'line-sticker-task/v2', 'line-sticker-task/v3', 'line-sticker-task/v4'].includes(value.schema) || !value.taskId) throw new Error('不支援的任務格式'); return value; } diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 1f0ad85..b681b3f 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -23,8 +23,14 @@ test('雙平台、照片、候選網格與本機範例流程', async ({ page }) const mdPath = await (await mdDownload).path(); expect(mdPath).toBeTruthy(); const markdown = await readFile(mdPath!, 'utf8'); - expect(markdown).toContain('line-sticker-task/v3'); + expect(markdown).toContain('line-sticker-task/v4'); expect(markdown).toContain('me.png'); + expect(markdown).toContain('ONLY appearance and identity source'); + expect(markdown).toContain('STOP and ask me to re-upload'); + expect(markdown).not.toContain('taiwan-black-bear'); + expect(markdown).not.toContain('台灣黑熊'); + expect(markdown.toLowerCase()).not.toContain('black bear'); + expect(markdown).not.toContain('"categoryId": "plants"'); expect(markdown).toContain('3 columns and 3 rows'); const backupDownload = page.waitForEvent('download'); await page.getByRole('button', { name: '儲存專案' }).click();