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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ playwright-report/
.env.*
!.env.example
.vercel
firebase-debug.log*
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
- 靜態、動態、自訂文字、訊息、大貼圖、彈出式與特效背景七種類型。
- 每個專案只需一份 ChatGPT/Gemini 完整 Markdown 任務,可下載、重新匯入及保存產圖紀錄。
- 最多 5 張人物、寵物或角色參考照片,本機 IndexedDB 保存並隨專案 ZIP 備份。
- v5 靜態角色題材設計:6 大類、110 個原創子項目、角色設定、個性與道具組合。
- 有照片時自動建立安全的外觀分析指令;無照片時可選熱門趨勢題材或完全自訂主體。
- 2–8 行列候選網格,預設 3×3 生成 9 張候選並選出 8 張 LINE 貼圖。
- 40 個分類、400 組台灣繁中常用語,以及自訂詞庫匯入/匯出。
- 16 種主風格與配色、描邊、上色、造型組合。
Expand Down
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": "4.0.0",
"version": "5.0.0",
"private": true,
"description": "ChatGPT and Gemini assisted LINE sticker creation studio with local compliance validation",
"license": "MIT",
Expand Down
3 changes: 2 additions & 1 deletion src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getSpec } from '../domain/specs';
import { fillCaptionSlots, RECOMMENDED_GRIDS } from '../domain/project';
import { useProject } from '../state/ProjectContext';
import { SubjectDesigner } from './SubjectDesigner';

export function SettingsPanel() {
const { project, updateSettings, dispatch } = useProject(); const { settings } = project; const spec = getSpec(project.type); const cellCount = settings.rows * settings.columns;
Expand All @@ -11,7 +12,7 @@ export function SettingsPanel() {
dispatch({ type: 'update', patch: { settings: { ...settings, rows, columns }, captionSlots: fillCaptionSlots(project.captionSlots, cells), stickers: [] } }); }
return <aside className="settings-panel panel">
<div className="section-heading compact"><span>2</span><div><h2>設計設定</h2><p>{spec.label} · 最大 {spec.width}×{spec.height}</p></div></div>
<label className="field"><span>角色描述</span><textarea rows={5} value={settings.character} onChange={(e) => updateSettings({ character: e.target.value })} /></label>
<SubjectDesigner />
<div className="field-grid"><label className="field"><span>LINE 入選張數</span><select value={settings.count} onChange={(e) => setCount(Number(e.target.value) as typeof settings.count)}>
{spec.counts.map((count) => <option key={count} value={count}>{count} 張</option>)}</select></label>
<label className="field"><span>生成格數</span><strong className="grid-total">{settings.rows}×{settings.columns}={cellCount} 格</strong></label></div>
Expand Down
23 changes: 23 additions & 0 deletions src/components/SubjectDesigner.tsx
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>;
}
3 changes: 3 additions & 0 deletions src/components/TaskPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand All @@ -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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve legacy task character as the custom subject

Importing an older v1/v2 task with no subjectProfile creates the same invalid state for projects without reference photos: the imported character text is stored only in extraDetails, while customSubject stays empty and baseMode is custom, so the next MD download is blocked by validateSubjectProfile with 請輸入自訂主體 despite the imported manifest containing a valid character.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve photo task character when importing v3 MD

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 subjectProfile but leaves referencePhotos empty; the next download then rebuilds manifest.character from the catalog/custom profile instead of the imported characterDescription that mentioned the photo subject, silently turning a photo-based task back into the default/catalog subject. In that no-photo import context, either the imported description needs to become the custom subject or the missing photos need to be required before regenerating the task.

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 匯入失敗'); }
Expand Down
7 changes: 4 additions & 3 deletions src/domain/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ describe('貼圖專案', () => {
const project = createProject('big');
expect(parseProject(serializeProject(project)).type).toBe('big');
});
it('可以把 v2 文字與共用影格遷移到 v4', () => {
it('可以把 v2 文字與共用影格遷移到 v5', () => {
const legacy = { version: 2, name: '舊專案', type: 'static', settings: { character: '熊', phrases: '收到,謝謝', count: 8, columns: 4, padding: 10, fontSize: 42, loops: 1 }, sourceDataUrl: '', stickers: [], frames: [], updatedAt: 1 };
const project = parseProject(JSON.stringify(legacy));
expect(project.version).toBe(4);
expect(project.version).toBe(5);
expect(project.captionSlots.map((item) => item.text)).toEqual(['收到', '謝謝']);
});
it('可以把 v3 網格與候選狀態遷移到 v4',()=>{const current=createProject();const legacy={...current,version:3,settings:{character:current.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;const project=parseProject(JSON.stringify(legacy));expect(project.version).toBe(4);expect(project.settings.rows).toBe(2);expect(project.referencePhotos).toEqual([]);});
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('戴眼鏡的舊角色');});
});
34 changes: 25 additions & 9 deletions src/domain/project.ts
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 };
Expand All @@ -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(),
Expand All @@ -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 };
}
Expand All @@ -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 || '' };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Populate custom subject during legacy migration

When a v2/v3/v4 project without reference photos is loaded, this migrated profile is immediately invalid because baseMode: 'custom' is paired with customSubject: ''; the new validateSubjectProfile(..., false) check then returns 請輸入自訂主體, so users cannot download a new MD task or export ZIPs from legacy projects until they manually re-enter a subject even though the old settings.character was preserved in extraDetails.

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));
Expand All @@ -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;
}
8 changes: 8 additions & 0 deletions src/domain/subjectCatalog.test.ts
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);});
});
Loading
Loading