Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
14 changes: 12 additions & 2 deletions src/components/TaskPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 匯入失敗'); }
Expand All @@ -42,3 +43,12 @@ export function TaskPanel() {
{project.generationAttempts.length > 0 && <div className="attempt-list"><strong>匯入紀錄</strong>{project.generationAttempts.slice(-4).reverse().map((attempt) => <span key={attempt.id}>{attempt.provider} · {attempt.sourceHash.slice(0, 10)} · {attempt.provenanceMark}</span>)}</div>}
</section>;
}

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 };
}
1 change: 1 addition & 0 deletions src/domain/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof current>).referencePhotos;delete (legacy as Partial<typeof current>).photoRightsConfirmed;delete (legacy as Partial<typeof current>).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<typeof current>).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<typeof current>).subjectProfile;}else if(version===4){raw={...current,version:4,settings:{...current.settings,character:defaultText}};delete (raw as Partial<typeof current>).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('請保留我的紅色眼鏡');});
});
9 changes: 7 additions & 2 deletions src/domain/project.ts
Original file line number Diff line number Diff line change
@@ -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', '謝謝', '加油', '等一下', '太棒了', '哭哭', '晚安', '讚啦'];
Expand Down Expand Up @@ -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[] {
Expand All @@ -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);
}
1 change: 1 addition & 0 deletions src/domain/subjectDescription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 個']));});
});
13 changes: 10 additions & 3 deletions src/domain/subjectDescription.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(' ');
}
Expand Down
13 changes: 11 additions & 2 deletions src/providers/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');});
});
Loading
Loading