init state

This commit is contained in:
echo 2026-05-18 19:58:01 +02:00
commit ae1cae967b
46 changed files with 12734 additions and 0 deletions

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
# DeepSeek API (https://platform.deepseek.com)
VITE_DEEPSEEK_API_KEY=sk-...
# fal.ai API (https://fal.ai/dashboard/keys)
VITE_FAL_KEY=...
# Optional: Analytics
VITE_ANALYTICS_ID=...

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

33
eslint.config.js Normal file
View File

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.strictTypeChecked,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: {
project: ['./tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
])

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Comic Creator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6719
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "comicsroll",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@fal-ai/client": "^1.3.0",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fabric": "^7.3.1",
"file-saver": "^2.0.5",
"framer-motion": "^12.38.0",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"jszip": "^3.10.1",
"lucide-react": "^1.16.0",
"openai": "^4.104.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/file-saver": "^2.0.7",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.6"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

106
src/App.tsx Normal file
View File

@ -0,0 +1,106 @@
import type { JSX } from 'react';
import { useState } from 'react';
import { TopBar } from '@/components/layout/TopBar';
import { Sidebar } from '@/components/layout/Sidebar';
import { StoryInput } from '@/components/story/StoryInput';
import { ScriptViewer } from '@/components/story/ScriptViewer';
import { CharacterManager } from '@/components/story/CharacterManager';
import { PanelGrid } from '@/components/panels/PanelGrid';
import { LayoutEngine } from '@/components/layout/LayoutEngine';
import { SpeechBubbleSystem } from '@/components/speech/SpeechBubbleSystem';
import { ExportSection } from '@/components/export/ExportSection';
import { useComicStore } from '@/store/comicStore';
// Placeholder components for other sections
function PlaceholderSection({ title }: { title: string }): JSX.Element {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4">
<h2 className="text-2xl font-bold">{title}</h2>
<p className="text-muted-foreground">This section is coming soon...</p>
</div>
</div>
);
}
// Story section that shows either input or viewer based on workflow
function StorySection(): JSX.Element {
const { workflow } = useComicStore();
// Show script viewer if we have a script or are reviewing
if (workflow.currentStep === 'script-review') {
return <ScriptViewer />;
}
// Show character manager during character setup
if (workflow.currentStep === 'character-setup') {
return <CharacterManager />;
}
// Show panel grid during panel generation
if (workflow.currentStep === 'generating-panels') {
return <PanelGrid />;
}
// Show layout engine during layout
if (workflow.currentStep === 'layout') {
return <LayoutEngine />;
}
// Show speech bubble system during speech bubbles step
if (workflow.currentStep === 'speech-bubbles') {
return <SpeechBubbleSystem />;
}
// Show script viewer for complete step
if (workflow.currentStep === 'complete') {
return <ScriptViewer />;
}
// Otherwise show input
return <StoryInput />;
}
function App(): JSX.Element {
const [activeSection, setActiveSection] = useState('story');
const renderContent = (): JSX.Element => {
switch (activeSection) {
case 'story':
return <StorySection />;
case 'characters':
return <CharacterManager />;
case 'panels':
return <PanelGrid />;
case 'layout':
return <LayoutEngine />;
case 'bubbles':
return <SpeechBubbleSystem />;
case 'export':
return <ExportSection />;
case 'community':
return <PlaceholderSection title="Community" />;
default:
return <StorySection />;
}
};
return (
<div className="h-screen flex flex-col overflow-hidden bg-background">
<TopBar />
<div className="flex-1 flex overflow-hidden">
<Sidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto p-6">
{renderContent()}
</div>
</main>
</div>
</div>
);
}
export default App;

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,316 @@
/**
* Export Modal Component
*
* Allows users to export their comic in various formats
* with customizable settings
*/
import type { JSX } from 'react';
import { useState, useCallback } from 'react';
import {
Download,
FileText,
Image,
Archive,
X,
Check,
Loader2,
Settings,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useComicStore } from '@/store/comicStore';
import { exportWithProgress, downloadBlob, type ExportOptions } from '@/services/exportService';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
}
const EXPORT_FORMATS = [
{
value: 'pdf' as const,
label: 'PDF (Print)',
description: 'High-quality PDF for printing',
icon: FileText,
extension: 'pdf',
},
{
value: 'cbz' as const,
label: 'CBZ (Digital)',
description: 'Comic book archive for digital readers',
icon: Archive,
extension: 'cbz',
},
{
value: 'png' as const,
label: 'PNG (Images)',
description: 'Individual PNG images for each page',
icon: Image,
extension: 'zip',
},
];
const PAGE_SIZES = [
{ value: 'a4', label: 'A4 (210×297mm)', width: 2480, height: 3508 },
{ value: 'letter', label: 'US Letter (8.5×11″)', width: 2550, height: 3300 },
{ value: 'manga', label: 'B5 Manga', width: 2158, height: 3035 },
{ value: 'webtoon', label: 'Webtoon', width: 800, height: 1280 },
{ value: 'square', label: 'Instagram (1:1)', width: 1080, height: 1080 },
];
const DPI_OPTIONS = [
{ value: 72, label: '72 DPI (Web)', description: 'Optimized for web sharing' },
{ value: 150, label: '150 DPI (Draft)', description: 'Good for drafts and previews' },
{ value: 300, label: '300 DPI (Print)', description: 'Professional print quality' },
{ value: 600, label: '600 DPI (High)', description: 'Maximum quality for large prints' },
];
export function ExportModal({ isOpen, onClose }: ExportModalProps): JSX.Element | null {
const { pageLayouts, panelImages, project } = useComicStore();
const [selectedFormat, setSelectedFormat] = useState<ExportOptions['format']>('pdf');
const [pageSize, setPageSize] = useState('a4');
const [dpi, setDpi] = useState(300);
const [quality, setQuality] = useState(90);
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleExport = useCallback(async () => {
if (pageLayouts.length === 0) {
setError('No pages to export. Create a layout first.');
return;
}
setIsExporting(true);
setProgress(0);
setError(null);
setSuccess(false);
try {
const options: ExportOptions = {
format: selectedFormat,
pageSize,
dpi,
quality,
};
const blob = await exportWithProgress(
pageLayouts,
panelImages,
options,
setProgress
);
const format = EXPORT_FORMATS.find((f) => f.value === selectedFormat);
const filename = `${project.projectName || 'comic'}.${format?.extension || 'pdf'}`;
downloadBlob(blob, filename);
setSuccess(true);
} catch (err) {
console.error('Export failed:', err);
setError(err instanceof Error ? err.message : 'Export failed. Please try again.');
} finally {
setIsExporting(false);
}
}, [pageLayouts, panelImages, selectedFormat, pageSize, dpi, quality, project.projectName]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-background rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] overflow-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Download className="w-6 h-6" />
Export Comic
</h2>
<p className="text-muted-foreground mt-1">
Export your comic in various formats
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-muted rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Format Selection */}
<div>
<h3 className="font-semibold mb-3">Export Format</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{EXPORT_FORMATS.map((format) => {
const Icon = format.icon;
return (
<button
key={format.value}
onClick={() => setSelectedFormat(format.value)}
className={`p-4 border rounded-lg text-left transition-all ${
selectedFormat === format.value
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'hover:border-primary/50'
}`}
>
<Icon className="w-8 h-8 mb-2 text-primary" />
<h4 className="font-medium">{format.label}</h4>
<p className="text-xs text-muted-foreground mt-1">
{format.description}
</p>
</button>
);
})}
</div>
</div>
{/* Page Size */}
<div>
<h3 className="font-semibold mb-3">Page Size</h3>
<div className="flex flex-wrap gap-2">
{PAGE_SIZES.map((size) => (
<button
key={size.value}
onClick={() => setPageSize(size.value)}
className={`px-4 py-2 border rounded-lg text-sm transition-colors ${
pageSize === size.value
? 'border-primary bg-primary/10 text-primary'
: 'hover:border-primary/50'
}`}
>
{size.label}
</button>
))}
</div>
</div>
{/* Quality Settings */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* DPI Selection */}
<div>
<h3 className="font-semibold mb-3">Resolution (DPI)</h3>
<div className="space-y-2">
{DPI_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => setDpi(option.value)}
className={`w-full p-3 border rounded-lg text-left transition-colors ${
dpi === option.value
? 'border-primary bg-primary/5'
: 'hover:border-primary/50'
}`}
>
<div className="font-medium">{option.label}</div>
<div className="text-xs text-muted-foreground">
{option.description}
</div>
</button>
))}
</div>
</div>
{/* Quality Slider */}
<div>
<h3 className="font-semibold mb-3">Image Quality</h3>
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm">Compression</span>
<span className="text-sm font-medium">{quality}%</span>
</div>
<input
type="range"
min="50"
max="100"
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>Smaller file</span>
<span>Better quality</span>
</div>
</div>
{/* Summary */}
<div className="mt-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Settings className="w-4 h-4" />
<span className="font-medium">Export Summary</span>
</div>
<ul className="text-sm text-muted-foreground space-y-1">
<li>Pages: {pageLayouts.length}</li>
<li>Format: {selectedFormat.toUpperCase()}</li>
<li>Size: {PAGE_SIZES.find((s) => s.value === pageSize)?.label}</li>
<li>Resolution: {dpi} DPI</li>
</ul>
</div>
</div>
</div>
{/* Error */}
{error && (
<div className="p-4 bg-destructive/10 border border-destructive/30 text-destructive rounded-lg">
{error}
</div>
)}
{/* Success */}
{success && (
<div className="p-4 bg-green-50 border border-green-200 text-green-800 rounded-lg flex items-center gap-2">
<Check className="w-5 h-5" />
Export complete! File downloaded.
</div>
)}
{/* Progress */}
{isExporting && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Exporting...</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-center">
{Math.round(progress)}% complete
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t bg-muted/50">
<Button variant="outline" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
<Button
onClick={handleExport}
disabled={isExporting || pageLayouts.length === 0}
className="min-w-[120px]"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Export
</>
)}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,232 @@
/**
* Export Section Component
*
* Displays export options and a preview of the final comic
*/
import type { JSX } from 'react';
import { useState } from 'react';
import {
Download,
FileText,
Image,
Archive,
Check,
AlertCircle,
Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useComicStore } from '@/store/comicStore';
import { exportWithProgress, downloadBlob, type ExportOptions } from '@/services/exportService';
import { PAGE_SIZES } from '@/utils/layoutPatterns';
const EXPORT_FORMATS = [
{
value: 'pdf' as const,
label: 'PDF',
description: 'Print-ready PDF',
icon: FileText,
color: 'bg-red-50 border-red-200 text-red-700',
},
{
value: 'cbz' as const,
label: 'CBZ',
description: 'Digital comic archive',
icon: Archive,
color: 'bg-blue-50 border-blue-200 text-blue-700',
},
{
value: 'png' as const,
label: 'PNG',
description: 'High-res images',
icon: Image,
color: 'bg-green-50 border-green-200 text-green-700',
},
];
export function ExportSection(): JSX.Element {
const { pageLayouts, panelImages, project, pageSize } = useComicStore();
const [isExporting, setIsExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<ExportOptions['format']>('pdf');
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleExport = async () => {
if (pageLayouts.length === 0) {
setError('No pages to export. Complete the layout first.');
return;
}
setIsExporting(true);
setProgress(0);
setError(null);
setSuccess(false);
try {
const options: ExportOptions = {
format: exportFormat,
pageSize,
dpi: 300,
quality: 90,
};
const blob = await exportWithProgress(
pageLayouts,
panelImages,
options,
setProgress
);
const extensions: Record<string, string> = {
pdf: 'pdf',
cbz: 'cbz',
png: 'zip',
};
const filename = `${project.projectName || 'comic'}.${extensions[exportFormat]}`;
downloadBlob(blob, filename);
setSuccess(true);
} catch (err) {
console.error('Export failed:', err);
setError(err instanceof Error ? err.message : 'Export failed');
} finally {
setIsExporting(false);
}
};
const canExport = pageLayouts.length > 0;
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold flex items-center justify-center gap-2">
<Download className="w-8 h-8" />
Export Your Comic
</h2>
<p className="text-muted-foreground">
Download your comic in your preferred format
</p>
</div>
{/* Status */}
{!canExport && (
<div className="bg-amber-50 border border-amber-200 text-amber-800 px-4 py-3 rounded-lg flex items-center gap-3">
<AlertCircle className="w-5 h-5" />
<p>Complete the layout step before exporting.</p>
</div>
)}
{error && (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg flex items-center gap-3">
<AlertCircle className="w-5 h-5" />
<p>{error}</p>
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg flex items-center gap-3">
<Check className="w-5 h-5" />
<p>Export complete! Your file has been downloaded.</p>
</div>
)}
{/* Export Options */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{EXPORT_FORMATS.map((format) => {
const Icon = format.icon;
return (
<button
key={format.value}
onClick={() => setExportFormat(format.value)}
disabled={!canExport || isExporting}
className={`p-6 border-2 rounded-xl text-left transition-all ${
exportFormat === format.value
? format.color
: 'border-muted hover:border-primary/50'
} ${!canExport || isExporting ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<Icon className="w-10 h-10 mb-3" />
<h3 className="text-xl font-bold">{format.label}</h3>
<p className="text-sm opacity-80 mt-1">{format.description}</p>
</button>
);
})}
</div>
{/* Export Settings Summary */}
{canExport && (
<div className="bg-muted/50 p-6 rounded-lg border">
<h3 className="font-semibold mb-4">Export Settings</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Total Pages</span>
<p className="font-medium text-lg">{pageLayouts.length}</p>
</div>
<div>
<span className="text-muted-foreground">Page Size</span>
<p className="font-medium">{PAGE_SIZES[pageSize]?.name || 'A4'}</p>
</div>
<div>
<span className="text-muted-foreground">Resolution</span>
<p className="font-medium">300 DPI</p>
</div>
<div>
<span className="text-muted-foreground">Format</span>
<p className="font-medium uppercase">{exportFormat}</p>
</div>
</div>
</div>
)}
{/* Progress */}
{isExporting && (
<div className="space-y-3">
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
<span>Exporting your comic...</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden max-w-md mx-auto">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-center text-sm text-muted-foreground">
{Math.round(progress)}% complete
</p>
</div>
)}
{/* Export Button */}
<div className="flex justify-center">
<Button
size="lg"
onClick={handleExport}
disabled={!canExport || isExporting}
className="min-w-[200px]"
>
{isExporting ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="w-5 h-5 mr-2" />
Export Comic
</>
)}
</Button>
</div>
{/* Tips */}
<div className="text-center text-sm text-muted-foreground space-y-1">
<p>💡 Tip: PDF is best for printing, CBZ for digital readers</p>
<p>All formats include your page layouts and panel images</p>
</div>
</div>
);
}

View File

@ -0,0 +1,399 @@
/**
* Layout Engine Component
*
* Allows users to arrange panels into comic pages using layout templates
*/
import type { JSX } from "react";
import { useState, useCallback, useMemo } from "react";
import {
LayoutGrid,
RefreshCw,
AlertCircle,
Check,
ChevronLeft,
ChevronRight,
Grid3X3,
Maximize,
Settings,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useComicStore } from "@/store/comicStore";
import {
LAYOUT_PATTERNS,
PAGE_SIZES,
autoLayoutPages,
} from "@/utils/layoutPatterns";
import type { LayoutPattern, PageLayout } from "@/types";
interface LayoutTemplateCardProps {
pattern: LayoutPattern;
isSelected: boolean;
onClick: () => void;
}
function LayoutTemplateCard({
pattern,
isSelected,
onClick,
}: LayoutTemplateCardProps): JSX.Element {
return (
<button
onClick={onClick}
className={`p-4 border rounded-lg text-left transition-all hover:border-primary ${
isSelected ? "border-primary bg-primary/5 ring-1 ring-primary" : "bg-card"
}`}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold">{pattern.name}</h4>
{isSelected && <Check className="w-4 h-4 text-primary" />}
</div>
<p className="text-sm text-muted-foreground mb-3">
{pattern.description}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Grid3X3 className="w-3 h-3" />
<span>Up to {pattern.maxPanels} panels</span>
</div>
{/* Visual preview of layout */}
<div className="mt-3 aspect-[3/4] bg-muted rounded relative overflow-hidden">
{pattern.cells.map((cell, i) => (
<div
key={i}
className="absolute bg-primary/20 border border-primary/30 rounded-sm"
style={{
left: `${cell.x * 100}%`,
top: `${cell.y * 100}%`,
width: `${cell.w * 100}%`,
height: `${cell.h * 100}%`,
}}
/>
))}
</div>
</button>
);
}
interface PagePreviewProps {
pageLayout: PageLayout;
panelImages: Record<string, { url: string }>;
pageNumber: number;
isSelected: boolean;
onClick: () => void;
}
function PagePreview({
pageLayout,
panelImages,
pageNumber,
isSelected,
onClick,
}: PagePreviewProps): JSX.Element {
return (
<button
onClick={onClick}
className={`border rounded-lg overflow-hidden transition-all hover:border-primary ${
isSelected ? "border-primary ring-1 ring-primary" : "bg-card"
}`}
>
{/* Page Header */}
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between">
<span className="font-medium">Page {pageNumber}</span>
<span className="text-xs text-muted-foreground">
{pageLayout.panels.length} panels
</span>
</div>
{/* Page Preview */}
<div className="aspect-[3/4] bg-white relative">
{pageLayout.panels.map((panel) => {
const image = panelImages[panel.panelId];
return (
<div
key={panel.panelId}
className="absolute border border-gray-300 bg-gray-50 overflow-hidden"
style={{
left: `${panel.layoutCell.x * 100}%`,
top: `${panel.layoutCell.y * 100}%`,
width: `${panel.layoutCell.w * 100}%`,
height: `${panel.layoutCell.h * 100}%`,
}}
>
{image ? (
<img
src={image.url}
alt={`Panel ${panel.panelNumber}`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-gray-400">
{panel.panelNumber}
</div>
)}
</div>
);
})}
</div>
{/* Pattern Name */}
<div className="px-3 py-2 border-t text-xs text-muted-foreground text-center">
{pageLayout.pattern.name}
</div>
</button>
);
}
export function LayoutEngine(): JSX.Element {
const {
script,
panelImages,
pageLayouts,
setPageLayouts,
pageSize,
setPageSize,
artStyle,
setWorkflowStep,
workflow,
} = useComicStore();
const [selectedPattern, setSelectedPattern] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0);
// Get all panels from script
const allPanels = useMemo(() => {
if (!script) return [];
return script.pages.flatMap((page, pageIdx) =>
page.panels.map((panel) => ({
panelId: panel.panelId,
panelNumber: panel.panelNumber,
pageNumber: pageIdx + 1,
}))
);
}, [script]);
const hasLayouts = pageLayouts.length > 0;
const handleAutoLayout = useCallback(() => {
if (allPanels.length === 0) return;
const size = PAGE_SIZES[pageSize];
const layouts = autoLayoutPages(
allPanels,
size,
artStyle,
selectedPattern || undefined
);
setPageLayouts(layouts);
}, [allPanels, pageSize, artStyle, selectedPattern, setPageLayouts]);
const handlePatternSelect = useCallback(
(patternId: string) => {
setSelectedPattern(patternId);
// If we have panels, auto-apply layout with this pattern
if (allPanels.length > 0 && !hasLayouts) {
const pattern = LAYOUT_PATTERNS.find((p) => p.id === patternId);
if (pattern) {
const size = PAGE_SIZES[pageSize];
const layouts = autoLayoutPages(allPanels, size, artStyle, patternId);
setPageLayouts(layouts);
}
}
},
[allPanels, pageSize, artStyle, hasLayouts, setPageLayouts]
);
const handleProceed = useCallback(() => {
setWorkflowStep("speech-bubbles");
}, [setWorkflowStep]);
const handleClearLayouts = useCallback(() => {
setPageLayouts([]);
setSelectedPattern(null);
}, [setPageLayouts]);
if (!script) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
No script available. Generate a script and panels first.
</p>
</div>
);
}
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<LayoutGrid className="w-6 h-6" />
Page Layout
</h2>
<p className="text-muted-foreground">
{hasLayouts
? `${pageLayouts.length} pages created with ${allPanels.length} panels`
: `${allPanels.length} panels ready to layout`}
</p>
</div>
<div className="flex gap-2">
{hasLayouts && (
<Button variant="outline" onClick={handleClearLayouts}>
<RefreshCw className="w-4 h-4 mr-2" />
Redo Layout
</Button>
)}
<Button onClick={handleProceed} disabled={!hasLayouts}>
{hasLayouts ? "Proceed to Speech Bubbles" : "Create Layout First"}
</Button>
</div>
</div>
{/* Error Display */}
{workflow.error && (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg flex items-center gap-3">
<AlertCircle className="w-5 h-5 shrink-0" />
<p className="text-sm">{workflow.error}</p>
</div>
)}
{/* Layout Options */}
{!hasLayouts && (
<div className="space-y-6">
{/* Page Size Selector */}
<div className="bg-muted/50 p-4 rounded-lg border">
<div className="flex items-center gap-2 mb-4">
<Maximize className="w-4 h-4" />
<h3 className="font-semibold">Page Size</h3>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(PAGE_SIZES).map(([key, size]) => (
<button
key={key}
onClick={() => setPageSize(key as 'a4' | 'letter' | 'manga' | 'webtoon' | 'square')}
className={`px-3 py-2 rounded-lg border text-sm transition-colors ${
pageSize === key
? "border-primary bg-primary/10 text-primary"
: "bg-background hover:bg-muted"
}`}
>
{size.name}
</button>
))}
</div>
</div>
{/* Layout Pattern Selector */}
<div>
<div className="flex items-center gap-2 mb-4">
<Settings className="w-4 h-4" />
<h3 className="font-semibold">Choose Layout Pattern</h3>
<span className="text-sm text-muted-foreground">
(Optional - auto-selected based on panel count)
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{LAYOUT_PATTERNS.map((pattern) => (
<LayoutTemplateCard
key={pattern.id}
pattern={pattern}
isSelected={selectedPattern === pattern.id}
onClick={() => handlePatternSelect(pattern.id)}
/>
))}
</div>
</div>
{/* Auto Layout Button */}
<div className="flex justify-center">
<Button
size="lg"
onClick={handleAutoLayout}
disabled={allPanels.length === 0}
>
<LayoutGrid className="w-4 h-4 mr-2" />
Auto-Layout Pages
</Button>
</div>
</div>
)}
{/* Page Previews */}
{hasLayouts && (
<div className="space-y-6">
{/* Page Navigation */}
{pageLayouts.length > 1 && (
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage + 1} of {pageLayouts.length}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage((p) =>
Math.min(pageLayouts.length - 1, p + 1)
)
}
disabled={currentPage === pageLayouts.length - 1}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
{/* Pages Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pageLayouts.map((layout, index) => (
<PagePreview
key={layout.pageNumber}
pageLayout={layout}
panelImages={panelImages}
pageNumber={layout.pageNumber}
isSelected={currentPage === index}
onClick={() => setCurrentPage(index)}
/>
))}
</div>
{/* Layout Summary */}
<div className="bg-muted/50 p-4 rounded-lg border">
<h3 className="font-semibold mb-2">Layout Summary</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Total Pages:</span>
<p className="font-medium">{pageLayouts.length}</p>
</div>
<div>
<span className="text-muted-foreground">Total Panels:</span>
<p className="font-medium">{allPanels.length}</p>
</div>
<div>
<span className="text-muted-foreground">Page Size:</span>
<p className="font-medium">{PAGE_SIZES[pageSize].name}</p>
</div>
<div>
<span className="text-muted-foreground">Dimensions:</span>
<p className="font-medium">
{PAGE_SIZES[pageSize].width} × {PAGE_SIZES[pageSize].height}
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,65 @@
/**
* New Comic Modal Component
*
* Confirmation dialog before starting a new comic
*/
import type { JSX } from 'react';
import { AlertTriangle, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface NewComicModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function NewComicModal({ isOpen, onClose, onConfirm }: NewComicModalProps): JSX.Element | null {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-background rounded-lg shadow-lg max-w-md w-full">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-bold flex items-center gap-2">
<AlertTriangle className="w-6 h-6 text-amber-500" />
Start New Comic?
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-muted rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6">
<p className="text-muted-foreground mb-4">
This will clear your current comic project, including:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-6">
<li>Story script and characters</li>
<li>Generated panel images</li>
<li>Page layouts</li>
<li>Speech bubbles</li>
</ul>
<p className="text-sm font-medium text-amber-600">
This action cannot be undone.
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t bg-muted/50">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Start New Comic
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
import type { JSX, ComponentType } from 'react';
import {
FileText,
Users,
Image,
LayoutGrid,
MessageSquare,
Download,
Globe
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface NavItem {
id: string;
label: string;
icon: ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{ id: 'story', label: 'Story', icon: FileText },
{ id: 'characters', label: 'Characters', icon: Users },
{ id: 'panels', label: 'Panels', icon: Image },
{ id: 'layout', label: 'Layout', icon: LayoutGrid },
{ id: 'bubbles', label: 'Speech', icon: MessageSquare },
{ id: 'export', label: 'Export', icon: Download },
{ id: 'community', label: 'Community', icon: Globe },
];
interface SidebarProps {
activeSection: string;
onSectionChange: (section: string) => void;
}
export function Sidebar({ activeSection, onSectionChange }: SidebarProps): JSX.Element {
return (
<aside className="w-16 border-r bg-muted/50 flex flex-col items-center py-4 gap-2 shrink-0">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeSection === item.id;
return (
<button
key={item.id}
onClick={() => onSectionChange(item.id)}
className={cn(
'w-12 h-12 rounded-lg flex items-center justify-center transition-colors relative group',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
title={item.label}
>
<Icon className="w-5 h-5" />
{/* Tooltip */}
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
{item.label}
</span>
</button>
);
})}
</aside>
);
}

View File

@ -0,0 +1,75 @@
import type { JSX } from 'react';
import { useState } from 'react';
import { BookOpen, Wand2, Settings2, Download, Plus } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useComicStore } from '@/store/comicStore';
import { ExportModal } from '@/components/export/ExportModal';
import { NewComicModal } from './NewComicModal';
export function TopBar(): JSX.Element {
const { userMode, setUserMode, resetProject } = useComicStore();
const [isExportOpen, setIsExportOpen] = useState(false);
const [isNewComicOpen, setIsNewComicOpen] = useState(false);
const handleNewComic = () => {
resetProject();
setIsNewComicOpen(false);
};
return (
<>
<header className="h-14 border-b flex items-center justify-between px-4 bg-background shrink-0">
<div className="flex items-center gap-2">
<BookOpen className="w-6 h-6 text-primary" />
<span className="font-bold text-lg">AI Comic Creator</span>
</div>
<div className="flex items-center gap-4">
{/* New Comic Button */}
<Button
variant="outline"
size="sm"
onClick={() => setIsNewComicOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
New Comic
</Button>
{/* Export Button */}
<Button
variant="outline"
size="sm"
onClick={() => setIsExportOpen(true)}
>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
{/* Mode Toggle */}
<div className="flex items-center gap-3 bg-muted rounded-full px-4 py-1.5">
<Wand2 className={`w-4 h-4 ${userMode === 'casual' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm ${userMode === 'casual' ? 'font-medium' : 'text-muted-foreground'}`}>
Casual
</span>
<Switch
checked={userMode === 'professional'}
onCheckedChange={(checked) => setUserMode(checked ? 'professional' : 'casual')}
/>
<span className={`text-sm ${userMode === 'professional' ? 'font-medium' : 'text-muted-foreground'}`}>
Pro
</span>
<Settings2 className={`w-4 h-4 ${userMode === 'professional' ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
</div>
</header>
<ExportModal isOpen={isExportOpen} onClose={() => setIsExportOpen(false)} />
<NewComicModal
isOpen={isNewComicOpen}
onClose={() => setIsNewComicOpen(false)}
onConfirm={handleNewComic}
/>
</>
);
}

View File

@ -0,0 +1,15 @@
import type { ReactNode, JSX } from 'react';
interface WorkspaceProps {
children: ReactNode;
}
export function Workspace({ children }: WorkspaceProps): JSX.Element {
return (
<main className="flex-1 overflow-hidden flex flex-col bg-background">
<div className="flex-1 overflow-auto p-6">
{children}
</div>
</main>
);
}

View File

@ -0,0 +1,367 @@
/**
* Panel Grid Component
*
* Displays generated comic panels in a grid layout
* Allows regeneration of individual panels
*/
import type { JSX } from "react";
import { useState, useCallback } from "react";
import {
ImageIcon,
RefreshCw,
AlertCircle,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useComicStore } from "@/store/comicStore";
import { generatePanelImage } from "@/services/falAiService";
import type { Panel, PanelImage } from "@/types";
interface PanelCardProps {
panel: Panel;
image: PanelImage | undefined;
isGenerating: boolean;
onGenerate: () => void;
panelNumber: number;
}
function PanelCard({
panel,
image,
isGenerating,
onGenerate,
panelNumber,
}: PanelCardProps): JSX.Element {
return (
<div className="border rounded-lg bg-card overflow-hidden flex flex-col">
{/* Panel Header */}
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Panel {panelNumber}</span>
<span className="text-xs px-1.5 py-0.5 bg-secondary text-secondary-foreground rounded capitalize">
{panel.shotType}
</span>
</div>
{image && (
<Check className="w-4 h-4 text-green-500" />
)}
</div>
{/* Panel Image */}
<div className="aspect-video bg-muted flex items-center justify-center relative flex-1 min-h-[150px]">
{image ? (
<img
src={image.url}
alt={`Panel ${panelNumber}`}
className="w-full h-full object-contain"
/>
) : (
<div className="text-center p-4">
<ImageIcon className="w-10 h-10 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No image generated</p>
</div>
)}
{/* Generate Button Overlay */}
{!image && !isGenerating && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity">
<Button onClick={onGenerate} variant="secondary" size="sm">
Generate
</Button>
</div>
)}
{isGenerating && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<RefreshCw className="w-8 h-8 text-white animate-spin" />
</div>
)}
</div>
{/* Panel Details */}
<div className="px-3 py-2 border-t text-xs text-muted-foreground space-y-1">
<p className="line-clamp-2">{panel.description}</p>
{panel.charactersPresent.length > 0 && (
<div className="flex flex-wrap gap-1">
{panel.charactersPresent.map((charId) => (
<span
key={charId}
className="px-1.5 py-0.5 bg-background border rounded"
>
{charId}
</span>
))}
</div>
)}
</div>
{/* Actions */}
{image && (
<div className="px-3 py-2 border-t flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onGenerate}
disabled={isGenerating}
className="flex-1"
>
<RefreshCw
className={`w-3 h-3 mr-1 ${isGenerating ? "animate-spin" : ""}`}
/>
Regenerate
</Button>
</div>
)}
</div>
);
}
export function PanelGrid(): JSX.Element {
const {
script,
characters,
panelImages,
setPanelImage,
artStyle,
project,
setWorkflowStep,
workflow,
setWorkflowError,
} = useComicStore();
const [generatingId, setGeneratingId] = useState<string | null>(null);
const [isGeneratingAll, setIsGeneratingAll] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0);
// Collect all panels from all pages
const allPanels =
script?.pages.flatMap((page, pageIdx) =>
page.panels.map((panel) => ({
...panel,
pageNumber: pageIdx + 1,
})
)) || [];
const totalPanels = allPanels.length;
const generatedCount = Object.keys(panelImages).length;
const allGenerated = totalPanels > 0 && generatedCount === totalPanels;
const handleGeneratePanel = useCallback(
async (panel: Panel) => {
setGeneratingId(panel.panelId);
setError(null);
try {
const result = await generatePanelImage(
panel,
characters,
artStyle,
project.projectId
);
setPanelImage(panel.panelId, result);
} catch (err) {
const msg =
err instanceof Error
? err.message
: "Failed to generate panel image";
setError(msg);
setWorkflowError(msg);
} finally {
setGeneratingId(null);
}
},
[characters, artStyle, project.projectId, setPanelImage, setWorkflowError]
);
const handleGenerateAll = useCallback(async () => {
if (allPanels.length === 0) return;
setIsGeneratingAll(true);
setError(null);
try {
// Generate in batches of 3
const batchSize = 3;
for (let i = 0; i < allPanels.length; i += batchSize) {
const batch = allPanels.slice(i, i + batchSize);
const results = await Promise.allSettled(
batch.map((panel) =>
generatePanelImage(panel, characters, artStyle, project.projectId)
)
);
results.forEach((result, idx) => {
const panel = batch[idx];
if (result.status === "fulfilled") {
setPanelImage(panel.panelId, result.value);
} else {
console.error(
`Failed to generate panel ${panel.panelId}:`,
result.reason
);
}
});
// Small delay between batches
if (i + batchSize < allPanels.length) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to generate panels";
setError(msg);
setWorkflowError(msg);
} finally {
setIsGeneratingAll(false);
}
}, [
allPanels,
characters,
artStyle,
project.projectId,
setPanelImage,
setWorkflowError,
]);
const handleProceed = useCallback(() => {
setWorkflowStep("layout");
}, [setWorkflowStep]);
// Calculate pages for pagination (if many panels)
const panelsPerPage = 6;
const totalPages = Math.ceil(allPanels.length / panelsPerPage);
const displayedPanels = allPanels.slice(
currentPage * panelsPerPage,
(currentPage + 1) * panelsPerPage
);
if (!script) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
No script available. Generate a script first.
</p>
</div>
);
}
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<ImageIcon className="w-6 h-6" />
Panel Generation
</h2>
<p className="text-muted-foreground">
{generatedCount} of {totalPanels} panels generated
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleGenerateAll}
disabled={isGeneratingAll || allPanels.length === 0}
>
{isGeneratingAll ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
"Generate All"
)}
</Button>
<Button onClick={handleProceed} disabled={!allGenerated}>
{allGenerated ? "Proceed to Layout" : "Generate All Panels First"}
</Button>
</div>
</div>
{/* Error Display */}
{(error || workflow.error) && (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg flex items-center gap-3">
<AlertCircle className="w-5 h-5 shrink-0" />
<p className="text-sm">{error || workflow.error}</p>
</div>
)}
{/* Progress */}
{totalPanels > 0 && (
<div className="space-y-2">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${(generatedCount / totalPanels) * 100}%` }}
/>
</div>
<p className="text-center text-sm text-muted-foreground">
{allGenerated
? "All panels ready for layout!"
: `${totalPanels - generatedCount} panels need to be generated`}
</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage + 1} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage((p) => Math.min(totalPages - 1, p + 1))
}
disabled={currentPage === totalPages - 1}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
{/* Panel Grid */}
{allPanels.length === 0 ? (
<div className="text-center py-12 bg-muted/50 rounded-lg">
<ImageIcon className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">No panels found in the script.</p>
<p className="text-sm text-muted-foreground mt-1">
Go back and generate a script with panels first.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{displayedPanels.map((panel, index) => (
<PanelCard
key={panel.panelId}
panel={panel}
image={panelImages[panel.panelId]}
isGenerating={generatingId === panel.panelId}
onGenerate={() => handleGeneratePanel(panel)}
panelNumber={currentPage * panelsPerPage + index + 1}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,544 @@
/**
* Speech Bubble System Component
*
* Canvas-based speech bubble editor using Fabric.js
* Supports auto-placement, manual editing, and multiple bubble types
*/
import type { JSX } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import {
MessageSquare,
RefreshCw,
ChevronLeft,
ChevronRight,
Type,
Palette,
MousePointer2,
Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useComicStore } from '@/store/comicStore';
import { autoPlacePanelBubbles, DEFAULT_BUBBLE_STYLE } from '@/services/bubbleEngine';
import type { SpeechBubble, BubbleType } from '@/types';
const BUBBLE_TYPES: Array<{ value: BubbleType; label: string; icon: string }> = [
{ value: 'normal', label: 'Speech', icon: '💬' },
{ value: 'thought', label: 'Thought', icon: '💭' },
{ value: 'shout', label: 'Shout', icon: '📢' },
{ value: 'whisper', label: 'Whisper', icon: '🤫' },
{ value: 'narration', label: 'Narration', icon: '📖' },
];
interface BubbleEditorProps {
bubble: SpeechBubble;
isSelected: boolean;
onSelect: () => void;
onUpdate: (updates: Partial<SpeechBubble>) => void;
scale: number;
}
function BubbleEditor({
bubble,
isSelected,
onSelect,
onUpdate,
scale,
}: BubbleEditorProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(bubble.text);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const handleSave = () => {
onUpdate({ text: editText });
setIsEditing(false);
};
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
onSelect();
// Start dragging
setIsDragging(true);
setDragOffset({
x: e.clientX,
y: e.clientY,
});
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return;
e.preventDefault();
const dx = (e.clientX - dragOffset.x) / scale;
const dy = (e.clientY - dragOffset.y) / scale;
onUpdate({
position: {
x: Math.max(0, bubble.position.x + dx),
y: Math.max(0, bubble.position.y + dy),
},
});
setDragOffset({
x: e.clientX,
y: e.clientY,
});
};
const handleMouseUp = () => {
setIsDragging(false);
};
const getBubbleShape = () => {
const { width, height } = bubble.size;
const r = bubble.style.borderRadius;
// Create path based on bubble type
let path = '';
switch (bubble.type) {
case 'thought':
// Cloud shape with circles
path = `M ${r} 0 L ${width - r} 0 Q ${width} 0 ${width} ${r} L ${width} ${height - r} Q ${width} ${height} ${width - r} ${height} L ${r} ${height} Q 0 ${height} 0 ${height - r} L 0 ${r} Q 0 0 ${r} 0 Z`;
break;
case 'shout':
// Spiky shape
path = `M ${r} 0 L ${width / 2 - 10} 0 L ${width / 2} -10 L ${width / 2 + 10} 0 L ${width - r} 0 Q ${width} 0 ${width} ${r} L ${width} ${height / 2 - 10} L ${width + 10} ${height / 2} L ${width} ${height / 2 + 10} L ${width} ${height - r} Q ${width} ${height} ${width - r} ${height} L ${r} ${height} Q 0 ${height} 0 ${height - r} L 0 ${height / 2 + 10} L -10 ${height / 2} L 0 ${height / 2 - 10} L 0 ${r} Q 0 0 ${r} 0 Z`;
break;
default:
// Standard rounded rect
path = `M ${r} 0 L ${width - r} 0 Q ${width} 0 ${width} ${r} L ${width} ${height - r} Q ${width} ${height} ${width - r} ${height} L ${r} ${height} Q 0 ${height} 0 ${height - r} L 0 ${r} Q 0 0 ${r} 0 Z`;
}
return path;
};
return (
<div
className={`absolute ${isSelected ? 'z-10' : 'z-0'}`}
style={{
left: bubble.position.x * scale,
top: bubble.position.y * scale,
width: bubble.size.width * scale,
height: bubble.size.height * scale,
}}
>
{/* Bubble Shape */}
<svg
width={bubble.size.width * scale}
height={bubble.size.height * scale + 20}
className="absolute inset-0 pointer-events-none"
style={{ overflow: 'visible' }}
>
{/* Main bubble */}
<path
d={getBubbleShape()}
fill={bubble.style.backgroundColor}
stroke={bubble.style.borderColor}
strokeWidth={bubble.style.borderWidth * scale}
strokeDasharray={bubble.type === 'whisper' ? '5,5' : undefined}
transform={`scale(${scale})`}
/>
{/* Tail for speech/thought */}
{bubble.type !== 'narration' && (
<path
d={`M ${bubble.size.width / 2} ${bubble.size.height} L ${bubble.tailTarget.x - bubble.position.x} ${bubble.tailTarget.y - bubble.position.y + bubble.size.height}`}
stroke={bubble.style.borderColor}
strokeWidth={bubble.style.borderWidth * scale}
fill="none"
/>
)}
</svg>
{/* Drag Handle (invisible border for dragging) */}
{!isEditing && (
<div
className="absolute inset-0 z-20 cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
title="Drag to move, double-click text to edit"
/>
)}
{/* Text Content */}
<div
className="absolute inset-0 flex items-center justify-center p-2 cursor-text"
style={{
fontSize: bubble.style.fontSize * scale,
fontFamily: bubble.style.fontFamily,
color: bubble.style.textColor,
}}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
onDoubleClick={() => setIsEditing(true)}
>
{isEditing ? (
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
}
}}
className="w-full h-full bg-transparent resize-none outline-none text-center"
autoFocus
/>
) : (
<span className="text-center break-words pointer-events-none">{bubble.text}</span>
)}
</div>
{/* Selection handles */}
{isSelected && (
<>
<div className="absolute -top-1 -left-1 w-2 h-2 bg-primary rounded-full" />
<div className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full" />
<div className="absolute -bottom-1 -left-1 w-2 h-2 bg-primary rounded-full" />
<div className="absolute -bottom-1 -right-1 w-2 h-2 bg-primary rounded-full" />
</>
)}
</div>
);
}
interface PanelCanvasProps {
panelImage: string | undefined;
bubbles: SpeechBubble[];
selectedBubbleId: string | null;
onSelectBubble: (id: string | null) => void;
onUpdateBubble: (id: string, updates: Partial<SpeechBubble>) => void;
}
function PanelCanvas({
panelImage,
bubbles,
selectedBubbleId,
onSelectBubble,
onUpdateBubble,
}: PanelCanvasProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => {
// Calculate scale to fit canvas in container
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth;
setScale(containerWidth / 800); // Base width of 800px
}
}, []);
return (
<div
ref={containerRef}
className="relative bg-gray-100 overflow-hidden cursor-crosshair"
style={{ aspectRatio: '4/3' }}
onClick={() => onSelectBubble(null)}
>
{/* Panel Background Image */}
{panelImage ? (
<img
src={panelImage}
alt="Panel"
className="absolute inset-0 w-full h-full object-contain"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
No panel image
</div>
)}
{/* Speech Bubbles */}
{bubbles.map((bubble) => (
<BubbleEditor
key={bubble.id}
bubble={bubble}
isSelected={bubble.id === selectedBubbleId}
onSelect={() => onSelectBubble(bubble.id)}
onUpdate={(updates) => onUpdateBubble(bubble.id, updates)}
scale={scale}
/>
))}
</div>
);
}
export function SpeechBubbleSystem(): JSX.Element {
const {
script,
pageLayouts,
panelImages,
speechBubbles,
setSpeechBubbles,
characters,
userMode,
setWorkflowStep,
} = useComicStore();
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const [currentPanelIndex, setCurrentPanelIndex] = useState(0);
const [selectedBubbleId, setSelectedBubbleId] = useState<string | null>(null);
const [isAutoPlacing, setIsAutoPlacing] = useState(false);
// Get current page and panel
const currentPage = pageLayouts[currentPageIndex];
const currentPanel = currentPage?.panels[currentPanelIndex];
const currentPanelImage = currentPanel ? panelImages[currentPanel.panelId] : undefined;
const currentBubbles = currentPanel ? speechBubbles[currentPanel.panelId] || [] : [];
// Get panel data from script
const getPanelData = useCallback((panelId: string) => {
if (!script) return null;
for (const page of script.pages) {
const panel = page.panels.find(p => p.panelId === panelId);
if (panel) return panel;
}
return null;
}, [script]);
const currentPanelData = currentPanel ? getPanelData(currentPanel.panelId) : null;
const handleAutoPlace = useCallback(() => {
if (!currentPanelData) return;
setIsAutoPlacing(true);
const bubbles = autoPlacePanelBubbles(
currentPanelData,
800, // panel width
600 // panel height
);
setSpeechBubbles(currentPanel.panelId, bubbles);
setIsAutoPlacing(false);
}, [currentPanel, currentPanelData, characters, setSpeechBubbles]);
const handleUpdateBubble = useCallback((bubbleId: string, updates: Partial<SpeechBubble>) => {
if (!currentPanel) return;
const updatedBubbles = currentBubbles.map(b =>
b.id === bubbleId ? { ...b, ...updates } : b
);
setSpeechBubbles(currentPanel.panelId, updatedBubbles);
}, [currentPanel, currentBubbles, setSpeechBubbles]);
const handleChangeBubbleType = useCallback((type: BubbleType) => {
if (!selectedBubbleId) return;
handleUpdateBubble(selectedBubbleId, { type });
}, [selectedBubbleId, handleUpdateBubble]);
const handleProceed = useCallback(() => {
setWorkflowStep('complete');
}, [setWorkflowStep]);
// Calculate total progress
const totalPanels = pageLayouts.reduce((acc, page) => acc + page.panels.length, 0);
const completedPanels = Object.keys(speechBubbles).length;
const allCompleted = totalPanels > 0 && completedPanels === totalPanels;
if (!script || pageLayouts.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
Create a layout first before adding speech bubbles.
</p>
</div>
);
}
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<MessageSquare className="w-6 h-6" />
Speech Bubbles
</h2>
<p className="text-muted-foreground">
Page {currentPageIndex + 1} of {pageLayouts.length} Panel {currentPanelIndex + 1} of {currentPage?.panels.length}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleAutoPlace}
disabled={!currentPanelData || isAutoPlacing}
>
{isAutoPlacing ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
Auto-Place
</Button>
<Button onClick={handleProceed}>
{allCompleted ? 'Finish' : 'Skip for Now'}
</Button>
</div>
</div>
{/* Progress */}
<div className="space-y-2">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${(completedPanels / totalPanels) * 100}%` }}
/>
</div>
<p className="text-center text-sm text-muted-foreground">
{completedPanels} of {totalPanels} panels have speech bubbles
</p>
</div>
{/* Navigation */}
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => {
if (currentPanelIndex > 0) {
setCurrentPanelIndex(currentPanelIndex - 1);
} else if (currentPageIndex > 0) {
setCurrentPageIndex(currentPageIndex - 1);
setCurrentPanelIndex(pageLayouts[currentPageIndex - 1].panels.length - 1);
}
}}
disabled={currentPageIndex === 0 && currentPanelIndex === 0}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
{currentPanelData?.description.substring(0, 50)}...
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
if (currentPanelIndex < (currentPage?.panels.length || 0) - 1) {
setCurrentPanelIndex(currentPanelIndex + 1);
} else if (currentPageIndex < pageLayouts.length - 1) {
setCurrentPageIndex(currentPageIndex + 1);
setCurrentPanelIndex(0);
}
}}
disabled={
currentPageIndex === pageLayouts.length - 1 &&
currentPanelIndex === (currentPage?.panels.length || 0) - 1
}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* Main Editor */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Canvas */}
<div className="lg:col-span-3">
<PanelCanvas
panelImage={currentPanelImage?.url}
bubbles={currentBubbles}
selectedBubbleId={selectedBubbleId}
onSelectBubble={setSelectedBubbleId}
onUpdateBubble={handleUpdateBubble}
/>
</div>
{/* Sidebar Tools */}
<div className="space-y-4">
{/* Bubble Type Selector */}
<div className="p-4 bg-muted/50 rounded-lg border">
<div className="flex items-center gap-2 mb-3">
<Type className="w-4 h-4" />
<h3 className="font-semibold">Bubble Type</h3>
</div>
<div className="grid grid-cols-2 gap-2">
{BUBBLE_TYPES.map((type) => (
<button
key={type.value}
onClick={() => handleChangeBubbleType(type.value)}
disabled={!selectedBubbleId}
className={`p-2 rounded-lg border text-sm transition-colors ${
selectedBubbleId && currentBubbles.find(b => b.id === selectedBubbleId)?.type === type.value
? 'border-primary bg-primary/10'
: 'bg-background hover:bg-muted'
} ${!selectedBubbleId ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="mr-1">{type.icon}</span>
{type.label}
</button>
))}
</div>
{!selectedBubbleId && (
<p className="text-xs text-muted-foreground mt-2 text-center">
Select a bubble to edit
</p>
)}
</div>
{/* Style Editor (Pro Mode) */}
{userMode === 'professional' && selectedBubbleId && (
<div className="p-4 bg-muted/50 rounded-lg border">
<div className="flex items-center gap-2 mb-3">
<Palette className="w-4 h-4" />
<h3 className="font-semibold">Style</h3>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Background</label>
<input
type="color"
value={currentBubbles.find(b => b.id === selectedBubbleId)?.style.backgroundColor || '#ffffff'}
onChange={(e) => handleUpdateBubble(selectedBubbleId, {
style: { ...DEFAULT_BUBBLE_STYLE, backgroundColor: e.target.value }
})}
className="w-full h-8 rounded cursor-pointer"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Border</label>
<input
type="color"
value={currentBubbles.find(b => b.id === selectedBubbleId)?.style.borderColor || '#000000'}
onChange={(e) => handleUpdateBubble(selectedBubbleId, {
style: { ...DEFAULT_BUBBLE_STYLE, borderColor: e.target.value }
})}
className="w-full h-8 rounded cursor-pointer"
/>
</div>
</div>
</div>
)}
{/* Instructions */}
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-start gap-2">
<MousePointer2 className="w-4 h-4 text-blue-600 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium">How to edit:</p>
<ul className="list-disc list-inside mt-1 space-y-1 text-xs">
<li>Click bubble to select</li>
<li>Drag bubble to reposition</li>
<li>Double-click text to edit</li>
<li>Click canvas to deselect</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,414 @@
/**
* Character Manager Component
*
* Main interface for managing characters in the comic
* Shows character list, allows editing, and generates reference images
*/
import type { JSX } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { Users, RefreshCw, AlertCircle, Check, User, Sparkles, Image as ImageIcon, Grid3X3 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useComicStore } from '@/store/comicStore';
import { generateCharacterReference, generateCharacterSheet } from '@/services/falAiService';
import { updateCharacterFromDescription } from '@/utils/characterTemplates';
import type { Character } from '@/types';
interface CharacterCardProps {
character: Character;
isGenerating: boolean;
onGenerateRef: () => void;
onGenerateSheet: () => void;
onEdit: (updates: Partial<Character>) => void;
userMode: 'casual' | 'professional';
}
function CharacterCard({
character,
isGenerating,
onGenerateRef,
onGenerateSheet,
onEdit,
userMode
}: CharacterCardProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false);
const [editedDesc, setEditedDesc] = useState(character.description);
const handleSave = () => {
onEdit({ description: editedDesc });
setIsEditing(false);
};
return (
<div className="border rounded-lg bg-card overflow-hidden">
{/* Character Header */}
<div className="p-4 border-b bg-muted/30">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
<div>
<h3 className="font-semibold">{character.name}</h3>
<span className="text-xs px-2 py-0.5 bg-secondary text-secondary-foreground rounded-full capitalize">
{character.role}
</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-1">
{userMode === 'professional' && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditing(!isEditing)}
disabled={isGenerating}
>
{isEditing ? 'Cancel' : 'Edit'}
</Button>
)}
</div>
</div>
</div>
{/* Reference Image */}
<div className="aspect-square bg-muted flex items-center justify-center relative">
{character.referenceImageUrl ? (
<img
src={character.referenceImageUrl}
alt={character.name}
className="w-full h-full object-cover"
/>
) : (
<div className="text-center p-4">
<ImageIcon className="w-12 h-12 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No reference image</p>
</div>
)}
{/* Generate Button Overlay */}
{!character.referenceImageUrl && !isGenerating && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<Button onClick={onGenerateRef} variant="secondary" size="lg">
<Sparkles className="w-4 h-4 mr-2" />
Generate
</Button>
</div>
)}
{isGenerating && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<RefreshCw className="w-8 h-8 text-white animate-spin" />
</div>
)}
</div>
{/* Character Details */}
<div className="p-4 space-y-3">
{/* Description */}
{isEditing ? (
<div className="space-y-2">
<textarea
value={editedDesc}
onChange={(e) => setEditedDesc(e.target.value)}
className="w-full h-24 p-2 text-sm border rounded resize-none"
placeholder="Character description..."
/>
<Button size="sm" onClick={handleSave} className="w-full">
<Check className="w-4 h-4 mr-2" />
Save Description
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground line-clamp-3">
{character.description || 'No description available.'}
</p>
)}
{/* Color Palette */}
{userMode === 'professional' && (
<div className="flex gap-2">
{Object.entries(character.colorPalette).map(([key, color]) => (
color && (
<div key={key} className="flex items-center gap-1">
<div
className="w-4 h-4 rounded border"
style={{ backgroundColor: color.toLowerCase() }}
title={color}
/>
<span className="text-xs text-muted-foreground capitalize">{key}</span>
</div>
)
))}
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
{character.referenceImageUrl && (
<Button
variant="outline"
size="sm"
onClick={onGenerateRef}
disabled={isGenerating}
className="flex-1"
>
<RefreshCw className={`w-4 h-4 mr-1 ${isGenerating ? 'animate-spin' : ''}`} />
Regenerate
</Button>
)}
{userMode === 'professional' && character.referenceImageUrl && (
<Button
variant="outline"
size="sm"
onClick={onGenerateSheet}
disabled={isGenerating}
className="flex-1"
>
<Grid3X3 className="w-4 h-4 mr-1" />
Sheet
</Button>
)}
</div>
</div>
</div>
);
}
export function CharacterManager(): JSX.Element {
const {
characters,
setCharacters,
setCharacterRefImage,
userMode,
artStyle,
setWorkflowStep,
workflow,
setWorkflowError,
} = useComicStore();
const [generatingId, setGeneratingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Auto-parse descriptions on mount if templates are empty
useEffect(() => {
const needsParsing = characters.some(char => !char.promptTemplate.age || char.promptTemplate.age === '');
if (needsParsing) {
const updated = characters.map(char => {
if (!char.promptTemplate.age || char.promptTemplate.age === '') {
return updateCharacterFromDescription(char);
}
return char;
});
setCharacters(updated);
}
}, []);
// Parse descriptions and update templates on mount
const handleParseAll = useCallback(() => {
const updated = characters.map(char => {
if (!char.promptTemplate.age || char.promptTemplate.age === '') {
return updateCharacterFromDescription(char);
}
return char;
});
setCharacters(updated);
}, [characters, setCharacters]);
const handleGenerateRef = useCallback(async (characterId: string) => {
setGeneratingId(characterId);
setError(null);
try {
const character = characters.find(c => c.id === characterId);
if (!character) return;
// Ensure character has parsed template
let charToUse = character;
if (!character.promptTemplate.age || character.promptTemplate.age === '') {
charToUse = updateCharacterFromDescription(character);
}
const imageUrl = await generateCharacterReference(charToUse, artStyle);
setCharacterRefImage(characterId, imageUrl);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to generate reference';
console.error('Character generation error:', err);
setError(msg);
setWorkflowError(msg);
} finally {
setGeneratingId(null);
}
}, [characters, artStyle, setCharacterRefImage, setWorkflowError]);
const handleGenerateAll = useCallback(async () => {
setError(null);
// First, parse all descriptions
const parsedCharacters = characters.map(char => {
if (!char.promptTemplate.age || char.promptTemplate.age === '') {
return updateCharacterFromDescription(char);
}
return char;
});
setCharacters(parsedCharacters);
// Then generate references one by one
for (const character of parsedCharacters) {
if (!character.referenceImageUrl) {
setGeneratingId(character.id);
try {
const imageUrl = await generateCharacterReference(character, artStyle);
setCharacterRefImage(character.id, imageUrl);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to generate reference';
console.error(`Failed to generate ${character.name}:`, err);
setError(msg);
setWorkflowError(msg);
setGeneratingId(null);
return;
}
}
}
setGeneratingId(null);
}, [characters, artStyle, setCharacters, setCharacterRefImage, setWorkflowError]);
const handleGenerateSheet = useCallback(async (characterId: string) => {
setGeneratingId(characterId);
setError(null);
try {
const character = characters.find(c => c.id === characterId);
if (!character) return;
const sheetUrls = await generateCharacterSheet(character, artStyle);
// Update character with sheet URLs
const updatedChars = characters.map(c =>
c.id === characterId
? { ...c, characterSheetUrls: sheetUrls }
: c
);
setCharacters(updatedChars);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to generate sheet';
setError(msg);
setWorkflowError(msg);
} finally {
setGeneratingId(null);
}
}, [characters, artStyle, setCharacters, setWorkflowError]);
const handleEditCharacter = useCallback((characterId: string, updates: Partial<Character>) => {
const character = characters.find(c => c.id === characterId);
if (!character) return;
const updated = updateCharacterFromDescription({
...character,
...updates,
});
const updatedChars = characters.map(c =>
c.id === characterId ? updated : c
);
setCharacters(updatedChars);
}, [characters, setCharacters]);
const handleProceed = useCallback(() => {
setWorkflowStep('generating-panels');
}, [setWorkflowStep]);
const allHaveRefs = characters.every(c => c.referenceImageUrl);
const generatedCount = characters.filter(c => c.referenceImageUrl).length;
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Users className="w-6 h-6" />
Characters
</h2>
<p className="text-muted-foreground">
{generatedCount} of {characters.length} have reference images
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleParseAll}>
Parse Descriptions
</Button>
<Button
variant="outline"
onClick={handleGenerateAll}
disabled={generatingId !== null || allHaveRefs}
>
{generatingId ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
Generate All
</Button>
<Button onClick={handleProceed} disabled={!allHaveRefs}>
{allHaveRefs ? 'Proceed to Panels' : 'Generate All References First'}
</Button>
</div>
</div>
{/* Error Display */}
{(error || workflow.error) && (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg flex items-center gap-3">
<AlertCircle className="w-5 h-5 shrink-0" />
<p className="text-sm">{error || workflow.error}</p>
</div>
)}
{/* Progress */}
{characters.length > 0 && (
<div className="space-y-2">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${(generatedCount / characters.length) * 100}%` }}
/>
</div>
<p className="text-center text-sm text-muted-foreground">
{generatedCount === characters.length
? 'All characters ready!'
: `${characters.length - generatedCount} characters need reference images`
}
</p>
</div>
)}
{/* Character Grid */}
{characters.length === 0 ? (
<div className="text-center py-12 bg-muted/50 rounded-lg">
<Users className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">No characters found in the script.</p>
<p className="text-sm text-muted-foreground mt-1">
Go back to Story and generate a script first.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{characters.map((character) => (
<CharacterCard
key={character.id}
character={character}
isGenerating={generatingId === character.id}
onGenerateRef={() => handleGenerateRef(character.id)}
onGenerateSheet={() => handleGenerateSheet(character.id)}
onEdit={(updates) => handleEditCharacter(character.id, updates)}
userMode={userMode}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,245 @@
/**
* Script Viewer Component - Professional Mode
*
* Displays the generated comic script with JSON editing capabilities
* Allows professionals to review and modify the script before proceeding
*/
import type { JSX } from 'react';
import { useState, useCallback } from 'react';
import { BookOpen, Users, LayoutGrid, ChevronDown, ChevronRight, AlertCircle, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useComicStore } from '@/store/comicStore';
import { validateAndNormalizeScript } from '@/services/deepseekService';
import type { Character, Page } from '@/types';
interface ScriptSectionProps {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
defaultOpen?: boolean;
}
function ScriptSection({ title, icon: Icon, children, defaultOpen = true }: ScriptSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border rounded-lg bg-card">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Icon className="w-5 h-5 text-primary" />
<h3 className="font-semibold">{title}</h3>
</div>
{isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
{isOpen && (
<div className="p-4 pt-0 border-t">
{children}
</div>
)}
</div>
);
}
function CharacterCard({ character }: { character: Character }): JSX.Element {
return (
<div className="p-3 bg-muted/50 rounded-lg space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-medium">{character.name}</h4>
<span className="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
{character.role}
</span>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{character.description}
</p>
<div className="text-xs text-muted-foreground">
First appearance: {character.firstAppearancePanel || 'TBD'}
</div>
</div>
);
}
function PanelCard({ panel }: { panel: Page['panels'][0] }): JSX.Element {
return (
<div className="p-3 bg-muted/50 rounded-lg space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium">Panel {panel.panelNumber}</span>
<span className="text-xs px-2 py-1 bg-secondary text-secondary-foreground rounded-full capitalize">
{panel.shotType}
</span>
</div>
<p className="text-sm text-muted-foreground">
{panel.description}
</p>
{panel.charactersPresent.length > 0 && (
<div className="flex flex-wrap gap-1">
{panel.charactersPresent.map((charId) => (
<span key={charId} className="text-xs px-2 py-0.5 bg-background border rounded">
{charId}
</span>
))}
</div>
)}
{panel.dialogue.length > 0 && (
<div className="space-y-1 pt-2 border-t">
{panel.dialogue.map((d, i) => (
<div key={i} className="text-sm">
<span className="font-medium text-primary">{d.speakerId}:</span>
<span className="ml-2 italic">"{d.text}"</span>
</div>
))}
</div>
)}
</div>
);
}
export function ScriptViewer(): JSX.Element {
const {
script,
userMode,
setScript,
setWorkflowStep,
setCharacters,
} = useComicStore();
const [isEditing, setIsEditing] = useState(false);
const [editedJson, setEditedJson] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const handleEdit = useCallback(() => {
if (script) {
setEditedJson(JSON.stringify(script, null, 2));
setIsEditing(true);
setJsonError(null);
}
}, [script]);
const handleSave = useCallback(() => {
try {
const parsed = JSON.parse(editedJson);
const validated = validateAndNormalizeScript(parsed);
setScript(validated);
setCharacters(validated.characters);
setIsEditing(false);
setJsonError(null);
} catch (error) {
setJsonError(error instanceof Error ? error.message : 'Invalid JSON');
}
}, [editedJson, setScript, setCharacters]);
const handleProceed = useCallback(() => {
setWorkflowStep('character-setup');
}, [setWorkflowStep]);
if (!script) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">No script generated yet.</p>
</div>
);
}
const totalPanels = script.pages.reduce((acc, page) => acc + page.panels.length, 0);
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">{script.title}</h2>
<p className="text-muted-foreground">
{script.pages.length} pages {totalPanels} panels {script.characters.length} characters
</p>
</div>
<div className="flex gap-2">
{userMode === 'professional' && (
<Button
variant={isEditing ? "default" : "outline"}
onClick={isEditing ? handleSave : handleEdit}
>
{isEditing ? (
<>
<Check className="w-4 h-4 mr-2" />
Save Changes
</>
) : (
'Edit JSON'
)}
</Button>
)}
<Button onClick={handleProceed}>
Proceed to Characters
</Button>
</div>
</div>
{/* Synopsis */}
<div className="p-4 bg-muted/50 rounded-lg border">
<h3 className="font-semibold mb-2">Synopsis</h3>
<p className="text-muted-foreground">{script.synopsis}</p>
</div>
{/* JSON Editor (Professional Mode) */}
{isEditing && userMode === 'professional' && (
<div className="space-y-2">
{jsonError && (
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{jsonError}
</div>
)}
<textarea
value={editedJson}
onChange={(e) => setEditedJson(e.target.value)}
className="w-full h-96 p-4 font-mono text-sm bg-muted rounded-lg border focus:outline-none focus:ring-2 focus:ring-ring"
spellCheck={false}
/>
</div>
)}
{/* Structured View */}
{!isEditing && (
<div className="space-y-4">
{/* Characters Section */}
<ScriptSection title="Characters" icon={Users}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{script.characters.map((character) => (
<CharacterCard key={character.id} character={character} />
))}
</div>
</ScriptSection>
{/* Pages Section */}
<ScriptSection title="Pages & Panels" icon={LayoutGrid}>
<div className="space-y-4">
{script.pages.map((page) => (
<div key={page.pageNumber} className="space-y-2">
<h4 className="font-medium flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Page {page.pageNumber}
<span className="text-xs px-2 py-0.5 bg-secondary text-secondary-foreground rounded-full capitalize">
{page.layoutType}
</span>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{page.panels.map((panel) => (
<PanelCard
key={panel.panelId}
panel={panel}
/>
))}
</div>
</div>
))}
</div>
</ScriptSection>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,307 @@
/**
* Story Input Component - Casual & Professional Modes
*
* Allows users to input their story idea and generate a comic script
*/
import type { JSX, FormEvent } from 'react';
import { useState, useCallback } from 'react';
import { Sparkles, Loader2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useComicStore } from '@/store/comicStore';
import { generateComicScriptWithRetry } from '@/services/deepseekService';
import type { GenerateScriptOptions } from '@/types';
// Genre options
const GENRES = [
{ value: 'action', label: 'Action' },
{ value: 'comedy', label: 'Comedy' },
{ value: 'drama', label: 'Drama' },
{ value: 'scifi', label: 'Sci-Fi' },
{ value: 'fantasy', label: 'Fantasy' },
{ value: 'horror', label: 'Horror' },
{ value: 'romance', label: 'Romance' },
{ value: 'mystery', label: 'Mystery' },
{ value: 'slice-of-life', label: 'Slice of Life' },
];
// Art style options
const ART_STYLES = [
{ value: 'manga', label: 'Manga', description: 'Japanese manga style' },
{ value: 'western-comic', label: 'Western Comic', description: 'American comic book style' },
{ value: 'pixel-art', label: 'Pixel Art', description: '16-bit retro style' },
{ value: 'watercolor', label: 'Watercolor', description: 'Artistic watercolor' },
{ value: 'noir', label: 'Noir', description: 'Film noir style' },
{ value: 'chibi', label: 'Chibi', description: 'Cute anime style' },
{ value: 'sketch', label: 'Sketch', description: 'Pencil sketch' },
{ value: 'cyberpunk', label: 'Cyberpunk', description: 'Futuristic neon' },
];
// Target audience options
const AUDIENCES = [
{ value: 'general', label: 'General' },
{ value: 'children', label: 'Children' },
{ value: 'teen', label: 'Teen' },
{ value: 'mature', label: 'Mature' },
];
// Page count options
const PAGE_COUNTS = [
{ value: 4, label: '4 pages' },
{ value: 8, label: '8 pages' },
{ value: 12, label: '12 pages' },
{ value: 16, label: '16 pages' },
{ value: 24, label: '24 pages' },
];
export function StoryInput(): JSX.Element {
const {
userMode,
storyIdea,
storyGenre,
targetAudience,
artStyle,
setStoryIdea,
setStoryGenre,
setTargetAudience,
setArtStyle,
setScript,
setCharacters,
workflow,
setWorkflowStep,
setWorkflowError,
setGenerationProgress,
} = useComicStore();
const [isGenerating, setIsGenerating] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const handleSubmit = useCallback(async (e: FormEvent) => {
e.preventDefault();
if (!storyIdea.trim()) {
setLocalError('Please enter a story idea');
return;
}
setLocalError(null);
setIsGenerating(true);
setWorkflowStep('generating-script');
setGenerationProgress(0);
try {
const options: GenerateScriptOptions = {
storyIdea: storyIdea.trim(),
genre: storyGenre,
artStyle: ART_STYLES.find(s => s.value === artStyle)?.label || artStyle,
numPages: userMode === 'professional' ? 8 : 4,
audience: targetAudience,
};
setGenerationProgress(10);
const script = await generateComicScriptWithRetry(options);
setGenerationProgress(100);
// Update store with generated script
setScript(script);
setCharacters(script.characters);
// Move to next step
setWorkflowStep('script-review');
} catch (error) {
console.error('Script generation failed:', error);
const errorMessage = error instanceof Error
? error.message
: 'Failed to generate script. Please try again.';
setLocalError(errorMessage);
setWorkflowError(errorMessage);
} finally {
setIsGenerating(false);
}
}, [
storyIdea,
storyGenre,
artStyle,
targetAudience,
userMode,
setScript,
setCharacters,
setWorkflowStep,
setWorkflowError,
setGenerationProgress,
]);
const isCasual = userMode === 'casual';
return (
<div className="max-w-3xl mx-auto space-y-8">
{/* Header */}
<div className="text-center space-y-3">
<h1 className="text-4xl font-bold tracking-tight">
{isCasual ? "What's your story idea?" : 'Create Your Comic Script'}
</h1>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
{isCasual
? "Describe your comic in a few sentences and we'll generate the complete script, characters, and panels."
: 'Provide detailed information to generate a professional comic script with full control over the output.'
}
</p>
</div>
{/* Error Display */}
{(localError || workflow.error) && (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded-lg flex items-center gap-3">
<AlertCircle className="w-5 h-5 shrink-0" />
<p className="text-sm">{localError || workflow.error}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Story Idea Textarea */}
<div className="space-y-2">
<label htmlFor="story-idea" className="text-sm font-medium">
Story Idea
</label>
<textarea
id="story-idea"
value={storyIdea}
onChange={(e) => setStoryIdea(e.target.value)}
placeholder={isCasual
? "A detective investigates a mysterious disappearance in a cyberpunk city..."
: "Provide a detailed story concept including setting, main characters, conflict, and resolution..."
}
disabled={isGenerating}
className="w-full h-40 p-4 rounded-lg border bg-background resize-none focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
/>
<p className="text-xs text-muted-foreground">
{storyIdea.length} characters
</p>
</div>
{/* Professional Mode: Additional Options */}
{!isCasual && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg border">
{/* Genre */}
<div className="space-y-2">
<label htmlFor="genre" className="text-sm font-medium">
Genre
</label>
<select
id="genre"
value={storyGenre}
onChange={(e) => setStoryGenre(e.target.value)}
disabled={isGenerating}
className="w-full p-2 rounded-md border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{GENRES.map((g) => (
<option key={g.value} value={g.value}>{g.label}</option>
))}
</select>
</div>
{/* Art Style */}
<div className="space-y-2">
<label htmlFor="art-style" className="text-sm font-medium">
Art Style
</label>
<select
id="art-style"
value={artStyle}
onChange={(e) => setArtStyle(e.target.value)}
disabled={isGenerating}
className="w-full p-2 rounded-md border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{ART_STYLES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
{/* Target Audience */}
<div className="space-y-2">
<label htmlFor="audience" className="text-sm font-medium">
Target Audience
</label>
<select
id="audience"
value={targetAudience}
onChange={(e) => setTargetAudience(e.target.value)}
disabled={isGenerating}
className="w-full p-2 rounded-md border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{AUDIENCES.map((a) => (
<option key={a.value} value={a.value}>{a.label}</option>
))}
</select>
</div>
{/* Page Count */}
<div className="space-y-2">
<label htmlFor="page-count" className="text-sm font-medium">
Page Count
</label>
<select
id="page-count"
defaultValue={8}
disabled={isGenerating}
className="w-full p-2 rounded-md border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{PAGE_COUNTS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
</div>
)}
{/* Casual Mode: Hidden defaults */}
{isCasual && (
<div className="flex items-center justify-center gap-4 text-sm text-muted-foreground">
<span>Genre: {GENRES.find(g => g.value === storyGenre)?.label}</span>
<span></span>
<span>Style: {ART_STYLES.find(s => s.value === artStyle)?.label}</span>
</div>
)}
{/* Submit Button */}
<Button
type="submit"
className="w-full"
size="lg"
disabled={isGenerating || !storyIdea.trim()}
>
{isGenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating Script...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
{isCasual ? 'Generate Comic' : 'Generate Script'}
</>
)}
</Button>
{/* Progress Bar */}
{isGenerating && (
<div className="space-y-2">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${workflow.generationProgress}%` }}
/>
</div>
<p className="text-center text-sm text-muted-foreground">
{workflow.generationProgress < 30 && 'Analyzing your story idea...'}
{workflow.generationProgress >= 30 && workflow.generationProgress < 60 && 'Creating characters and plot...'}
{workflow.generationProgress >= 60 && workflow.generationProgress < 90 && 'Structuring panels and dialogue...'}
{workflow.generationProgress >= 90 && 'Finalizing script...'}
</p>
</div>
)}
</form>
</div>
);
}

View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

98
src/index.css Normal file
View File

@ -0,0 +1,98 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* Canvas container for Fabric.js */
.canvas-container {
position: relative;
}
.canvas-container canvas {
display: block;
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,286 @@
/**
* Bubble Engine Service
*
* Handles speech bubble auto-placement and positioning logic
* Implements comic book layout rules for bubble placement
*/
import type { SpeechBubble, Panel, Position, Size } from '@/types';
/**
* Default bubble style
*/
export const DEFAULT_BUBBLE_STYLE: SpeechBubble['style'] = {
backgroundColor: '#ffffff',
borderColor: '#000000',
borderWidth: 2,
borderRadius: 16,
fontFamily: 'Comic Sans MS, cursive, sans-serif',
fontSize: 14,
textColor: '#000000',
padding: 12,
};
/**
* Calculate bubble size based on text content
*/
export function calculateBubbleSize(text: string, style: SpeechBubble['style']): Size {
// Estimate character dimensions (rough approximation)
const charWidth = style.fontSize * 0.6;
const charHeight = style.fontSize * 1.4;
// Target line length (characters per line)
const charsPerLine = 25;
const lines = Math.ceil(text.length / charsPerLine);
// Calculate dimensions
const width = Math.min(
Math.max(text.length * charWidth / Math.min(lines, 3) + style.padding * 2, 100),
300
);
const height = lines * charHeight + style.padding * 2 + 20; // +20 for tail space
return { width, height };
}
/**
* Auto-place bubble based on type and panel composition
* Implements comic book placement rules
*/
export function autoPlaceBubble(
bubble: SpeechBubble,
panelWidth: number,
panelHeight: number,
speakerPosition?: Position
): SpeechBubble {
const updatedBubble = { ...bubble };
const size = calculateBubbleSize(bubble.text, bubble.style);
updatedBubble.size = size;
// Default speaker position (center, lower half if not specified)
const speaker: Position = speakerPosition || {
x: panelWidth * 0.5,
y: panelHeight * 0.7,
};
// Calculate position based on bubble type
switch (bubble.type) {
case 'thought':
// Thought bubbles go above character's head
updatedBubble.position = {
x: Math.max(20, Math.min(speaker.x - size.width / 2, panelWidth - size.width - 20)),
y: Math.max(20, speaker.y - size.height - 40),
};
updatedBubble.tailTarget = { x: speaker.x, y: speaker.y - 20 };
updatedBubble.tailDirection = 'bottom';
break;
case 'shout':
// Shout bubbles can be larger and more prominent
updatedBubble.size = { width: size.width * 1.1, height: size.height * 1.1 };
updatedBubble.position = {
x: Math.max(10, Math.min(speaker.x - updatedBubble.size.width / 2, panelWidth - updatedBubble.size.width - 10)),
y: Math.max(10, speaker.y - updatedBubble.size.height - 30),
};
updatedBubble.tailTarget = { x: speaker.x, y: speaker.y - 10 };
updatedBubble.tailDirection = 'bottom';
break;
case 'whisper':
// Whisper bubbles typically dashed border, positioned near character
updatedBubble.position = {
x: Math.max(20, Math.min(speaker.x - size.width / 2, panelWidth - size.width - 20)),
y: Math.max(20, speaker.y - size.height - 30),
};
updatedBubble.tailTarget = { x: speaker.x, y: speaker.y - 20 };
updatedBubble.tailDirection = 'bottom';
break;
case 'narration':
// Narration boxes go at top of panel
updatedBubble.position = {
x: panelWidth * 0.1,
y: 20,
};
updatedBubble.size = { width: panelWidth * 0.8, height: size.height };
updatedBubble.tailDirection = 'bottom'; // No tail for narration
break;
case 'sound-effect':
// Sound effects usually placed near action source
updatedBubble.position = {
x: Math.max(10, Math.min(speaker.x + 20, panelWidth - size.width - 10)),
y: Math.max(10, speaker.y - size.height - 50),
};
updatedBubble.tailTarget = { x: speaker.x + 30, y: speaker.y - 30 };
updatedBubble.tailDirection = 'bottom-left';
break;
default: // 'normal'
// Normal speech bubble - avoid covering character face
updatedBubble.position = {
x: Math.max(20, Math.min(speaker.x - size.width / 2, panelWidth - size.width - 20)),
y: Math.max(20, speaker.y - size.height - 30),
};
updatedBubble.tailTarget = { x: speaker.x, y: speaker.y - 20 };
updatedBubble.tailDirection = 'bottom';
break;
}
return updatedBubble;
}
/**
* Auto-place all bubbles for a panel based on dialogue
*/
export function autoPlacePanelBubbles(
panel: Panel,
panelWidth: number,
panelHeight: number
): SpeechBubble[] {
const bubbles: SpeechBubble[] = [];
// Track occupied areas to prevent overlapping
const occupiedAreas: Array<{ x: number; y: number; width: number; height: number }> = [];
panel.dialogue.forEach((dialogue, index) => {
// Estimate speaker position (distribute characters across panel)
const speakerCount = panel.charactersPresent.length;
const speakerIndex = panel.charactersPresent.indexOf(dialogue.speakerId);
const speakerX = speakerCount > 1
? (panelWidth / (speakerCount + 1)) * (speakerIndex + 1)
: panelWidth * 0.5;
const speakerY = panelHeight * 0.75; // Lower half of panel
// Create bubble
const bubble: SpeechBubble = {
id: `bubble_${panel.panelId}_${index}`,
panelId: panel.panelId,
type: dialogue.bubbleType,
text: dialogue.text,
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
tailDirection: 'bottom',
tailTarget: { x: speakerX, y: speakerY },
style: { ...DEFAULT_BUBBLE_STYLE },
speakerId: dialogue.speakerId,
};
// Auto-place with offset for multiple bubbles
let placedBubble = autoPlaceBubble(bubble, panelWidth, panelHeight, { x: speakerX, y: speakerY });
// Adjust position to avoid overlapping previous bubbles
const verticalOffset = index * 10; // Slight stagger
placedBubble.position.y = Math.max(20, placedBubble.position.y - verticalOffset);
// Ensure bubble stays within panel bounds
placedBubble.position.x = Math.max(
10,
Math.min(placedBubble.position.x, panelWidth - placedBubble.size.width - 10)
);
placedBubble.position.y = Math.max(
10,
Math.min(placedBubble.position.y, panelHeight - placedBubble.size.height - 10)
);
occupiedAreas.push({
x: placedBubble.position.x,
y: placedBubble.position.y,
width: placedBubble.size.width,
height: placedBubble.size.height,
});
bubbles.push(placedBubble);
});
// Add caption if present
if (panel.caption) {
const captionBubble: SpeechBubble = {
id: `caption_${panel.panelId}`,
panelId: panel.panelId,
type: 'narration',
text: panel.caption,
position: { x: panelWidth * 0.1, y: 20 },
size: { width: panelWidth * 0.8, height: 40 },
tailDirection: 'bottom',
tailTarget: { x: panelWidth * 0.5, y: 60 },
style: {
...DEFAULT_BUBBLE_STYLE,
backgroundColor: '#f5f5f5',
borderColor: '#999999',
},
speakerId: '',
};
bubbles.push(captionBubble);
}
return bubbles;
}
/**
* Create bubble SVG path based on type and tail direction
*/
export function createBubblePath(
width: number,
height: number,
type: SpeechBubble['type'],
tailDirection: SpeechBubble['tailDirection'],
tailTarget: Position
): string {
const r = 16; // border radius
// Base rounded rectangle path
let path = `M ${r} 0 `;
path += `L ${width - r} 0 `;
path += `Q ${width} 0 ${width} ${r} `;
path += `L ${width} ${height - r} `;
path += `Q ${width} ${height} ${width - r} ${height} `;
// Add tail based on direction
if (type !== 'narration') {
const tailSize = 15;
const tailWidth = 12;
switch (tailDirection) {
case 'bottom':
const tailX = Math.max(tailWidth, Math.min(tailTarget.x, width - tailWidth));
path += `L ${tailX + tailWidth} ${height} `;
path += `L ${tailTarget.x} ${height + tailSize} `;
path += `L ${tailX - tailWidth} ${height} `;
break;
case 'top':
const topX = Math.max(tailWidth, Math.min(tailTarget.x, width - tailWidth));
path += `L ${topX + tailWidth} ${height} `;
path += `L ${width - r} ${height} `;
path = `M ${r} 0 L ${topX - tailWidth} 0 L ${tailTarget.x} ${-tailSize} L ${topX + tailWidth} 0 L ${width - r} 0 ` + path.substring(path.indexOf('Q'));
break;
// Add more directions as needed
}
}
path += `L ${r} ${height} `;
path += `Q 0 ${height} 0 ${height - r} `;
path += `L 0 ${r} `;
path += `Q 0 0 ${r} 0 Z`;
// Modify for thought bubbles (cloud shape)
if (type === 'thought') {
// Add small circles for thought bubble effect
return path; // Simplified - full cloud shape would be more complex
}
// Modify for shout bubbles (spiky edges)
if (type === 'shout') {
// Add spikes around the edge
return path; // Simplified - full spiky shape would be more complex
}
return path;
}
/**
* Generate unique bubble ID
*/
export function generateBubbleId(panelId: string, index: number): string {
return `bubble_${panelId}_${Date.now()}_${index}`;
}

View File

@ -0,0 +1,295 @@
/**
* DeepSeek Service - Script Generation
*
* Uses OpenAI-compatible SDK with DeepSeek API for comic script generation
*/
import OpenAI from 'openai';
import type { ComicScript, GenerateScriptOptions } from '@/types';
const DEEPSEEK_API_KEY = import.meta.env.VITE_DEEPSEEK_API_KEY;
if (!DEEPSEEK_API_KEY) {
console.warn('VITE_DEEPSEEK_API_KEY not set. DeepSeek functionality will not work.');
}
const deepseek = new OpenAI({
apiKey: DEEPSEEK_API_KEY || 'dummy-key',
baseURL: 'https://api.deepseek.com',
dangerouslyAllowBrowser: true,
});
/**
* System prompt for comic script generation
* Produces structured, panel-by-panel comic scripts optimized for AI image generation
*/
const SCRIPT_SYSTEM_PROMPT = `You are an expert comic book writer and storyboard artist.
Your task is to take a user's story idea and transform it into a detailed comic book script optimized for AI image generation.
OUTPUT FORMAT: Return ONLY valid JSON in this exact structure:
{
"title": "Comic Title",
"synopsis": "One-paragraph summary",
"characters": [
{
"id": "char_001",
"name": "Character Name",
"role": "protagonist|antagonist|supporting",
"description": "Detailed visual description for image generation: age, gender, hair color/style, eye color, skin tone, body type, clothing, distinguishing features. Be extremely specific.",
"personality": "Brief personality notes for dialogue",
"firstAppearancePanel": "panel_001"
}
],
"pages": [
{
"pageNumber": 1,
"layoutType": "grid|manga|western|action|dialogue|splash",
"panels": [
{
"panelId": "panel_001",
"panelNumber": 1,
"shotType": "establishing|wide|medium|close-up|extreme-close-up|over-shoulder|aerial",
"description": "Detailed scene description for image generation. Include: setting, lighting, mood, character positions, action, camera angle. 2-3 sentences.",
"charactersPresent": ["char_001"],
"dialogue": [
{
"speakerId": "char_001",
"text": "Dialogue text",
"bubbleType": "normal|thought|shout|whisper",
"emotion": "happy|sad|angry|surprised|neutral|determined"
}
],
"caption": "Optional narration caption",
"soundEffects": ["BANG!", "CRASH!"],
"transitionFromPrevious": "none|fade|wipe|dissolve|action-lines"
}
]
}
]
}
RULES:
- 4-8 panels per page for standard pages, 1 panel for splash pages
- Vary shot types across panels for visual interest
- Include at least one establishing shot per scene
- Dialogue should be concise (1-3 lines per bubble)
- Description fields must be IMAGE-GENERATION-READY: vivid, specific, include art style keywords
- Character descriptions must be EXTREMELY detailed and consistent for all panels
- Include mood and lighting keywords in every panel description`;
/**
* Build user prompt for script generation
*/
function buildUserPrompt(options: GenerateScriptOptions): string {
const { storyIdea, genre, artStyle, numPages, audience } = options;
return `Create a ${numPages}-page comic script based on this idea: "${storyIdea}".
Genre: ${genre}
Target Art Style: ${artStyle} (incorporate style keywords into panel descriptions)
Target Audience: ${audience}`;
}
/**
* Validate and normalize the generated script
* Ensures all required fields are present and properly formatted
*/
export function validateAndNormalizeScript(script: unknown): ComicScript {
if (!script || typeof script !== 'object') {
throw new Error('Invalid script: not an object');
}
const s = script as Record<string, unknown>;
// Validate required fields
if (!s.title || typeof s.title !== 'string') {
throw new Error('Invalid script: missing or invalid title');
}
if (!s.synopsis || typeof s.synopsis !== 'string') {
throw new Error('Invalid script: missing or invalid synopsis');
}
if (!Array.isArray(s.characters)) {
throw new Error('Invalid script: characters must be an array');
}
if (!Array.isArray(s.pages)) {
throw new Error('Invalid script: pages must be an array');
}
// Normalize characters
const characters: ComicScript['characters'] = s.characters.map((char: unknown, index: number) => {
const c = char as Record<string, unknown>;
return {
id: String(c.id || `char_${String(index + 1).padStart(3, '0')}`),
name: String(c.name || `Character ${index + 1}`),
role: (c.role as 'protagonist' | 'antagonist' | 'supporting' | 'extra') || 'supporting',
description: String(c.description || ''),
promptTemplate: (c.promptTemplate as ComicScript['characters'][0]['promptTemplate']) || {
age: '',
gender: '',
hairColor: '',
hairStyle: '',
skinTone: '',
eyeColor: '',
bodyType: '',
outfit: '',
accessories: '',
distinguishingFeatures: '',
},
referenceImageUrl: String(c.referenceImageUrl || ''),
characterSheetUrls: Array.isArray(c.characterSheetUrls) ? c.characterSheetUrls.map(String) : [],
seed: Number(c.seed || Math.floor(Math.random() * 2147483647)),
colorPalette: (c.colorPalette as ComicScript['characters'][0]['colorPalette']) || {
hair: '',
eyes: '',
skin: '',
outfit: '',
},
appearanceCount: Number(c.appearanceCount || 0),
firstAppearancePanel: c.firstAppearancePanel ? String(c.firstAppearancePanel) : undefined,
};
});
// Normalize pages and panels
const pages: ComicScript['pages'] = s.pages.map((page: unknown, pageIndex: number) => {
const p = page as Record<string, unknown>;
const panels: ComicScript['pages'][0]['panels'] = Array.isArray(p.panels)
? p.panels.map((panel: unknown, panelIndex: number) => {
const pan = panel as Record<string, unknown>;
return {
panelId: String(pan.panelId || `panel_${String(pageIndex + 1).padStart(3, '0')}_${String(panelIndex + 1).padStart(3, '0')}`),
panelNumber: Number(pan.panelNumber || panelIndex + 1),
shotType: (pan.shotType as 'establishing' | 'wide' | 'medium' | 'close-up' | 'extreme-close-up' | 'over-shoulder' | 'aerial') || 'medium',
description: String(pan.description || ''),
charactersPresent: Array.isArray(pan.charactersPresent) ? pan.charactersPresent.map(String) : [],
dialogue: Array.isArray(pan.dialogue) ? pan.dialogue.map((d: unknown) => {
const dia = d as Record<string, unknown>;
return {
speakerId: String(dia.speakerId || ''),
text: String(dia.text || ''),
bubbleType: (dia.bubbleType as 'normal' | 'thought' | 'shout' | 'whisper') || 'normal',
emotion: (dia.emotion as 'happy' | 'sad' | 'angry' | 'surprised' | 'neutral' | 'determined') || 'neutral',
};
}) : [],
caption: pan.caption ? String(pan.caption) : undefined,
soundEffects: Array.isArray(pan.soundEffects) ? pan.soundEffects.map(String) : [],
transitionFromPrevious: (pan.transitionFromPrevious as 'none' | 'fade' | 'wipe' | 'dissolve' | 'action-lines') || 'none',
};
})
: [];
return {
pageNumber: Number(p.pageNumber || pageIndex + 1),
layoutType: (p.layoutType as 'grid' | 'manga' | 'western' | 'action' | 'dialogue' | 'splash') || 'grid',
panels,
};
});
return {
title: s.title,
synopsis: s.synopsis,
characters,
pages,
};
}
/**
* Generate comic script from story idea
* Returns complete structured script with characters, pages, and panels
*/
export async function generateComicScript(
options: GenerateScriptOptions
): Promise<ComicScript> {
if (!DEEPSEEK_API_KEY) {
throw new Error('DeepSeek API key not configured. Please set VITE_DEEPSEEK_API_KEY in .env.local');
}
const userPrompt = buildUserPrompt(options);
const response = await deepseek.chat.completions.create({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: SCRIPT_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
response_format: { type: 'json_object' },
temperature: 0.8,
max_tokens: 8000,
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('Empty response from DeepSeek API');
}
try {
const script = JSON.parse(content);
return validateAndNormalizeScript(script);
} catch (error) {
console.error('Failed to parse script:', error);
console.error('Raw content:', content);
throw new Error('Failed to parse generated script. Please try again.');
}
}
/**
* Stream comic script generation for real-time preview (Professional mode)
* Yields partial script chunks as they are generated
*/
export async function* streamComicScript(
options: GenerateScriptOptions
): AsyncGenerator<{ content: string; partial: string }> {
if (!DEEPSEEK_API_KEY) {
throw new Error('DeepSeek API key not configured');
}
const userPrompt = buildUserPrompt(options);
const stream = await deepseek.chat.completions.create({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: SCRIPT_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
stream: true,
temperature: 0.8,
max_tokens: 8000,
});
let buffer = '';
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
buffer += content;
yield { content, partial: buffer };
}
}
/**
* Generate script with retry logic
* Automatically retries on transient failures
*/
export async function generateComicScriptWithRetry(
options: GenerateScriptOptions,
retries: number = 3,
delay: number = 1000
): Promise<ComicScript> {
for (let i = 0; i < retries; i++) {
try {
return await generateComicScript(options);
} catch (error) {
if (i === retries - 1) throw error;
// Check if it's a rate limit error
if (error instanceof Error && error.message.includes('429')) {
const waitTime = delay * Math.pow(2, i);
console.log(`Rate limited. Retrying in ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
} else {
throw error;
}
}
}
throw new Error('Max retries exceeded');
}

View File

@ -0,0 +1,276 @@
/**
* Export Service
*
* Handles exporting comics to various formats:
* - PDF (print-ready)
* - CBZ (digital comic archive)
* - PNG (individual pages)
*/
import { jsPDF } from 'jspdf';
import type { PageLayout, PanelImage } from '@/types';
export interface ExportOptions {
format: 'pdf' | 'cbz' | 'png';
pageSize: string;
dpi: number;
quality: number;
}
/**
* Export comic to PDF format
* Uses html2canvas to capture pages and jsPDF to generate PDF
*/
export async function exportToPDF(
pageLayouts: PageLayout[],
panelImages: Record<string, PanelImage>,
options: ExportOptions
): Promise<Blob> {
const pageSize = getPageSizeDimensions(options.pageSize);
const isLandscape = pageSize.width > pageSize.height;
const pdf = new jsPDF({
orientation: isLandscape ? 'landscape' : 'portrait',
unit: 'mm',
format: options.pageSize.toLowerCase() as 'a4' | 'letter',
});
for (let i = 0; i < pageLayouts.length; i++) {
if (i > 0) {
pdf.addPage();
}
const pageLayout = pageLayouts[i];
const canvas = await renderPageToCanvas(pageLayout, panelImages, options.dpi);
if (!canvas) {
console.warn(`Failed to render page ${i + 1}`);
continue;
}
const imgData = canvas.toDataURL('image/png', options.quality / 100);
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight);
}
return pdf.output('blob');
}
/**
* Export comic to CBZ format (ZIP archive of images)
*/
export async function exportToCBZ(
pageLayouts: PageLayout[],
panelImages: Record<string, PanelImage>,
options: ExportOptions
): Promise<Blob> {
const JSZip = await import('jszip');
const zip = new JSZip.default();
// Add each page as an image
for (let i = 0; i < pageLayouts.length; i++) {
const pageLayout = pageLayouts[i];
const canvas = await renderPageToCanvas(pageLayout, panelImages, options.dpi);
if (!canvas) {
console.warn(`Failed to render page ${i + 1}`);
continue;
}
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b!), 'image/jpeg', options.quality / 100);
});
// Pad page number: 001, 002, etc.
const pageNum = String(i + 1).padStart(3, '0');
zip.file(`page_${pageNum}.jpg`, blob);
}
// Add ComicInfo.xml metadata
const comicInfo = generateComicInfoXML(pageLayouts);
zip.file('ComicInfo.xml', comicInfo);
return await zip.generateAsync({ type: 'blob' });
}
/**
* Export individual pages as PNG images
* Returns a ZIP containing all pages as PNG
*/
export async function exportToPNG(
pageLayouts: PageLayout[],
panelImages: Record<string, PanelImage>,
options: ExportOptions
): Promise<Blob> {
const JSZip = await import('jszip');
const zip = new JSZip.default();
for (let i = 0; i < pageLayouts.length; i++) {
const pageLayout = pageLayouts[i];
const canvas = await renderPageToCanvas(pageLayout, panelImages, options.dpi);
if (!canvas) {
console.warn(`Failed to render page ${i + 1}`);
continue;
}
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b!), 'image/png');
});
const pageNum = String(i + 1).padStart(3, '0');
zip.file(`page_${pageNum}.png`, blob);
}
return await zip.generateAsync({ type: 'blob' });
}
/**
* Render a page layout to a canvas element
* Combines panel images into the page layout
*/
async function renderPageToCanvas(
pageLayout: PageLayout,
panelImages: Record<string, PanelImage>,
dpi: number
): Promise<HTMLCanvasElement | null> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// Set canvas size based on page dimensions and DPI
const scale = dpi / 300; // Base scale on 300 DPI
canvas.width = pageLayout.width * scale;
canvas.height = pageLayout.height * scale;
// Fill white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Render each panel
for (const panel of pageLayout.panels) {
const panelImage = panelImages[panel.panelId];
if (!panelImage?.url) continue;
try {
const img = await loadImage(panelImage.url);
// Calculate panel position and size
const x = panel.layoutCell.x * canvas.width;
const y = panel.layoutCell.y * canvas.height;
const w = panel.layoutCell.w * canvas.width;
const h = panel.layoutCell.h * canvas.height;
// Draw panel image with gutter margins
const gutter = 4 * scale;
ctx.drawImage(
img,
x + gutter,
y + gutter,
w - gutter * 2,
h - gutter * 2
);
// Draw panel border
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2 * scale;
ctx.strokeRect(x + gutter, y + gutter, w - gutter * 2, h - gutter * 2);
} catch (error) {
console.error(`Failed to load panel image ${panel.panelId}:`, error);
}
}
return canvas;
}
/**
* Load an image from URL
*/
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
/**
* Generate ComicInfo.xml metadata for CBZ files
* Standard format used by comic readers
*/
function generateComicInfoXML(pageLayouts: PageLayout[]): string {
return `<?xml version="1.0" encoding="utf-8"?>
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Title>Generated Comic</Title>
<PageCount>${pageLayouts.length}</PageCount>
<Pages>
${pageLayouts.map((_, index) => ` <Page Image="${index}" ImageSize="0" />`).join('\n')}
</Pages>
</ComicInfo>`;
}
/**
* Get page dimensions in pixels at specified DPI
*/
function getPageSizeDimensions(pageSize: string): { width: number; height: number } {
const sizes: Record<string, { width: number; height: number }> = {
a4: { width: 2480, height: 3508 },
letter: { width: 2550, height: 3300 },
manga: { width: 2158, height: 3035 },
webtoon: { width: 800, height: 1280 },
square: { width: 1080, height: 1080 },
};
return sizes[pageSize] || sizes.a4;
}
/**
* Trigger file download
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Export with progress tracking
*/
export async function exportWithProgress(
pageLayouts: PageLayout[],
panelImages: Record<string, PanelImage>,
options: ExportOptions,
onProgress?: (progress: number) => void
): Promise<Blob> {
const totalSteps = pageLayouts.length + 1; // +1 for final processing
let currentStep = 0;
// Wrapper function to track progress
const trackProgress = async <T,>(fn: () => Promise<T>): Promise<T> => {
const result = await fn();
currentStep++;
onProgress?.(Math.min((currentStep / totalSteps) * 100, 100));
return result;
};
switch (options.format) {
case 'pdf':
return trackProgress(() => exportToPDF(pageLayouts, panelImages, options));
case 'cbz':
return trackProgress(() => exportToCBZ(pageLayouts, panelImages, options));
case 'png':
return trackProgress(() => exportToPNG(pageLayouts, panelImages, options));
default:
throw new Error(`Unknown export format: ${options.format}`);
}
}

View File

@ -0,0 +1,289 @@
/**
* fal.ai Service - Image Generation
*
* Handles image generation for character references and comic panels
* Uses NanoBanan model for anime/manga-style artwork
*/
import { fal } from '@fal-ai/client';
import type { Character, Panel, PanelImage } from '@/types';
import {
buildCharacterPrompt,
generatePanelSeed
} from '@/utils/characterTemplates';
const FAL_KEY = import.meta.env.VITE_FAL_KEY;
if (!FAL_KEY) {
console.warn('VITE_FAL_KEY not set. Image generation will not work.');
}
fal.config({
credentials: FAL_KEY || 'dummy-key',
});
// Art style keywords mapping
const ART_STYLE_KEYWORDS: Record<string, string> = {
'manga': 'Japanese manga style, black and white ink, screentone shading, speed lines, dramatic shadows, clean linework, right-to-left reading',
'western-comic': 'American comic book style, bold outlines, vibrant colors, dynamic poses, cross-hatching, Ben-Day dots, left-to-right reading',
'pixel-art': '16-bit pixel art style, retro game aesthetic, limited color palette, crisp pixels, dithering, nostalgic',
'watercolor': 'watercolor painting style, soft edges, paper texture, translucent washes, delicate linework, artistic',
'noir': 'film noir style, high contrast black and white, dramatic chiaroscuro lighting, gritty, shadow-heavy, 1940s aesthetic',
'chibi': 'chibi anime style, super deformed characters, big heads, small bodies, cute, colorful, expressive',
'sketch': 'pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome',
'cyberpunk': 'cyberpunk aesthetic, neon lighting, futuristic cityscapes, holographic elements, high contrast, synthwave colors',
};
// Default style modifier for quality
const QUALITY_MODIFIER = 'high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece';
/**
* Map shot type to optimal image dimensions
*/
function getImageSizeForShotType(shotType: string): "square" | "landscape_16_9" | "landscape_4_3" | "portrait_4_3" | "square_hd" | "portrait_16_9" {
const mapping: Record<string, "square" | "landscape_16_9" | "landscape_4_3" | "portrait_4_3" | "square_hd" | "portrait_16_9"> = {
'establishing': 'landscape_16_9',
'wide': 'landscape_16_9',
'medium': 'landscape_4_3',
'close-up': 'portrait_4_3',
'extreme-close-up': 'square_hd',
'over-shoulder': 'landscape_4_3',
'aerial': 'landscape_16_9',
};
return mapping[shotType] || 'landscape_4_3';
}
/**
* Generate character reference portrait
* This is the master anchor image for consistency
*/
export async function generateCharacterReference(
character: Character,
artStyle: string
): Promise<string> {
if (!FAL_KEY) {
throw new Error('fal.ai API key not configured. Please set VITE_FAL_KEY in .env.local');
}
const styleKeywords = ART_STYLE_KEYWORDS[artStyle] || artStyle;
const prompt = buildCharacterPrompt(
character.promptTemplate,
'standing in neutral pose facing camera, character portrait, clean background',
'studio lighting, white background',
'front-facing portrait, centered composition',
`${styleKeywords}, ${QUALITY_MODIFIER}`
);
try {
const result = await fal.subscribe('fal-ai/fast-sdxl', {
input: {
prompt,
negative_prompt: 'blurry, low quality, distorted face, extra limbs, bad anatomy, deformed, watermark, signature',
seed: character.seed,
image_size: 'square_hd',
num_inference_steps: 30,
guidance_scale: 7.5,
},
});
if (!result.data?.images?.[0]?.url) {
throw new Error('No image returned from fal.ai');
}
return result.data.images[0].url;
} catch (error) {
console.error('Character reference generation failed:', error);
throw new Error(`Failed to generate character reference: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Generate multi-angle character sheet (Professional mode)
* Creates front, 3/4, profile, and back views
*/
export async function generateCharacterSheet(
character: Character,
artStyle: string
): Promise<string[]> {
if (!FAL_KEY) {
throw new Error('fal.ai API key not configured');
}
const styleKeywords = ART_STYLE_KEYWORDS[artStyle] || artStyle;
const poses = [
{ name: 'front-facing portrait', view: 'front' },
{ name: 'three-quarter view portrait', view: 'three-quarter' },
{ name: 'side profile view', view: 'profile' },
{ name: 'back view showing hair and outfit', view: 'back' },
];
const sheetUrls: string[] = [];
for (let i = 0; i < poses.length; i++) {
const pose = poses[i];
const prompt = buildCharacterPrompt(
character.promptTemplate,
`${pose.name}, ${pose.view} view, character reference, clean background`,
'studio lighting, white background',
`${pose.view} view, centered composition`,
`${styleKeywords}, ${QUALITY_MODIFIER}`
);
// Use previous image as reference for consistency
const referenceImages = sheetUrls.length > 0 ? [sheetUrls[0]] : undefined;
try {
const result = await fal.subscribe('fal-ai/fast-sdxl', {
input: {
prompt,
negative_prompt: 'blurry, low quality, distorted face, extra limbs, bad anatomy, deformed',
seed: character.seed + i, // Increment seed for variation
image_size: 'square_hd',
num_inference_steps: 30,
guidance_scale: 7.5,
...(referenceImages && {
reference_images: referenceImages,
reference_image_strength: 0.65, // Sweet spot for preserving identity
}),
},
});
if (result.data?.images?.[0]?.url) {
sheetUrls.push(result.data.images[0].url);
}
} catch (error) {
console.error(`Failed to generate ${pose.view} view:`, error);
// Continue with other poses even if one fails
}
}
if (sheetUrls.length === 0) {
throw new Error('Failed to generate any character sheet images');
}
return sheetUrls;
}
/**
* Generate panel image with character consistency
* Uses character reference images for IP-Adapter consistency
*/
export async function generatePanelImage(
panel: Panel,
characters: Character[],
artStyle: string,
projectId: string
): Promise<PanelImage> {
if (!FAL_KEY) {
throw new Error('fal.ai API key not configured');
}
const styleKeywords = ART_STYLE_KEYWORDS[artStyle] || artStyle;
// Build character prompts for all characters in the panel
const characterPrompts = panel.charactersPresent.map(charId => {
const char = characters.find(c => c.id === charId);
if (!char) return '';
return buildCharacterPrompt(
char.promptTemplate,
panel.description,
'', // setting is in panel.description
'', // lighting is in panel.description
styleKeywords
);
}).filter(Boolean).join('. ');
const fullPrompt = `${styleKeywords} comic panel. ${panel.description}. Characters: ${characterPrompts}. ${panel.shotType} shot. ${QUALITY_MODIFIER}`;
// Collect reference images for all characters
const referenceImages = panel.charactersPresent
.map(id => characters.find(c => c.id === id)?.referenceImageUrl)
.filter((url): url is string => Boolean(url));
// Generate deterministic seed for this panel
const seed = generatePanelSeed(
projectId,
1, // We don't have page number in Panel, use 1
panel.panelNumber,
panel.panelId
);
try {
const result = await fal.subscribe('fal-ai/fast-sdxl', {
input: {
prompt: fullPrompt,
negative_prompt: 'blurry, low quality, distorted face, extra limbs, bad anatomy, deformed, watermark, signature, text, speech bubble',
seed,
image_size: getImageSizeForShotType(panel.shotType),
num_inference_steps: 30,
guidance_scale: 7.5,
...(referenceImages.length > 0 && {
reference_images: referenceImages,
reference_image_strength: 0.65,
}),
},
});
if (!result.data?.images?.[0]) {
throw new Error('No image returned from fal.ai');
}
const image = result.data.images[0];
return {
url: image.url,
width: image.width || 1024,
height: image.height || 1024,
seed,
prompt: fullPrompt,
};
} catch (error) {
console.error('Panel generation failed:', error);
throw new Error(`Failed to generate panel: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Generate all panels in parallel batches
* Prevents overwhelming the API while maintaining efficiency
*/
export async function generateAllPanels(
panels: Panel[],
characters: Character[],
artStyle: string,
projectId: string,
onProgress?: (progress: number) => void
): Promise<Record<string, PanelImage>> {
const batchSize = 3; // Max concurrent requests
const results: Record<string, PanelImage> = {};
for (let i = 0; i < panels.length; i += batchSize) {
const batch = panels.slice(i, i + batchSize);
const batchResults = await Promise.allSettled(
batch.map(panel => generatePanelImage(panel, characters, artStyle, projectId))
);
batchResults.forEach((result, idx) => {
const panel = batch[idx];
if (result.status === 'fulfilled') {
results[panel.panelId] = result.value;
} else {
console.error(`Failed to generate panel ${panel.panelId}:`, result.reason);
}
});
// Report progress
if (onProgress) {
onProgress(Math.min(((i + batch.length) / panels.length) * 100, 100));
}
// Small delay between batches to avoid rate limits
if (i + batchSize < panels.length) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
return results;
}

227
src/store/comicStore.ts Normal file
View File

@ -0,0 +1,227 @@
/**
* Comic Store - Zustand State Management
*
* Global state for the entire comic project with localStorage persistence
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type {
ComicStore,
UserMode,
ComicScript,
Character,
PanelImage,
PageLayout,
SpeechBubble,
WorkflowStep,
ExportFormat,
PageSizeName,
ColorProfile
} from '@/types';
const generateProjectId = (): string => {
return `proj_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
const initialState = {
project: {
projectId: generateProjectId(),
projectName: 'Untitled Comic',
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
},
userMode: 'casual' as UserMode,
storyIdea: '',
storyGenre: 'action',
targetAudience: 'general',
artStyle: 'manga',
script: null as ComicScript | null,
characters: [] as Character[],
panelImages: {} as Record<string, PanelImage>,
characterRefImages: {} as Record<string, string>,
pageLayouts: [] as PageLayout[],
speechBubbles: {} as Record<string, SpeechBubble[]>,
exportFormat: 'pdf' as ExportFormat,
pageSize: 'a4' as PageSizeName,
colorProfile: 'rgb' as ColorProfile,
workflow: {
currentStep: 'story-input' as WorkflowStep,
completedSteps: [] as WorkflowStep[],
isGenerating: false,
generationProgress: 0,
error: null as string | null,
},
};
export const useComicStore = create<ComicStore>()(
persist(
(set) => ({
...initialState,
// ─── Actions ───
setUserMode: (mode: UserMode) => {
set((state) => ({
userMode: mode,
project: { ...state.project, lastModified: new Date().toISOString() },
}));
},
setStoryIdea: (idea: string) => {
set((state) => ({
storyIdea: idea,
project: { ...state.project, lastModified: new Date().toISOString() },
}));
},
setStoryGenre: (genre: string) => {
set((state) => ({
storyGenre: genre,
project: { ...state.project, lastModified: new Date().toISOString() },
}));
},
setTargetAudience: (audience: string) => {
set((state) => ({
targetAudience: audience,
project: { ...state.project, lastModified: new Date().toISOString() },
}));
},
setArtStyle: (style: string) => {
set((state) => ({
artStyle: style,
project: { ...state.project, lastModified: new Date().toISOString() },
}));
},
setScript: (script: ComicScript | null) => {
set((state) => ({
script,
project: { ...state.project, lastModified: new Date().toISOString() },
workflow: {
...state.workflow,
currentStep: script ? 'character-setup' : 'story-input',
completedSteps: script
? [...state.workflow.completedSteps, 'story-input', 'generating-script']
: state.workflow.completedSteps,
},
}));
},
setCharacters: (characters: Character[]) => {
set((state) => ({
characters,
project: { ...state.project, lastModified: new Date().toISOString() },
}));
},
setPanelImage: (panelId: string, image: PanelImage) => {
set((state) => ({
panelImages: { ...state.panelImages, [panelId]: image },
project: { ...state.project, lastModified: new Date().toISOString() },
}));
},
setCharacterRefImage: (characterId: string, url: string) => {
set((state) => ({
characterRefImages: { ...state.characterRefImages, [characterId]: url },
characters: state.characters.map(c =>
c.id === characterId ? { ...c, referenceImageUrl: url } : c
),
project: { ...state.project, lastModified: new Date().toISOString() },
}));
},
setPageLayouts: (layouts: PageLayout[]) => {
set((state) => ({
pageLayouts: layouts,
project: { ...state.project, lastModified: new Date().toISOString() },
workflow: {
...state.workflow,
currentStep: layouts.length > 0 ? 'speech-bubbles' : 'layout',
completedSteps: layouts.length > 0
? [...state.workflow.completedSteps, 'layout']
: state.workflow.completedSteps,
},
}));
},
setSpeechBubbles: (panelId: string, bubbles: SpeechBubble[]) => {
set((state) => ({
speechBubbles: { ...state.speechBubbles, [panelId]: bubbles },
project: { ...state.project, lastModified: new Date().toISOString() },
}));
},
setExportFormat: (format: ExportFormat) => {
set({ exportFormat: format });
},
setPageSize: (size: PageSizeName) => {
set({ pageSize: size });
},
setWorkflowStep: (step: WorkflowStep) => {
set((state) => ({
workflow: {
...state.workflow,
currentStep: step,
completedSteps: state.workflow.completedSteps.includes(step)
? state.workflow.completedSteps
: [...state.workflow.completedSteps, step],
},
}));
},
setWorkflowError: (error: string | null) => {
set((state) => ({
workflow: { ...state.workflow, error, isGenerating: false },
}));
},
setGenerationProgress: (progress: number) => {
set((state) => ({
workflow: { ...state.workflow, generationProgress: progress },
}));
},
resetProject: () => {
set({
...initialState,
project: {
projectId: generateProjectId(),
projectName: 'Untitled Comic',
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
},
});
},
}),
{
name: 'comic-creator-storage',
partialize: (state) => ({
project: state.project,
userMode: state.userMode,
storyIdea: state.storyIdea,
storyGenre: state.storyGenre,
targetAudience: state.targetAudience,
artStyle: state.artStyle,
script: state.script,
characters: state.characters,
panelImages: state.panelImages,
characterRefImages: state.characterRefImages,
pageLayouts: state.pageLayouts,
speechBubbles: state.speechBubbles,
exportFormat: state.exportFormat,
pageSize: state.pageSize,
colorProfile: state.colorProfile,
workflow: {
currentStep: state.workflow.currentStep,
completedSteps: state.workflow.completedSteps,
error: state.workflow.error,
},
}),
}
)
);

1
src/test/setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

387
src/types/index.ts Normal file
View File

@ -0,0 +1,387 @@
/**
* AI Comic Creator - TypeScript Type Definitions
*
* Based on the specification document (comic-creator-app-instruction.md)
*/
// ============================================================================
// Core Enums and Types
// ============================================================================
export type UserMode = 'casual' | 'professional';
export type WorkflowStep =
| 'story-input'
| 'generating-script'
| 'script-review'
| 'character-setup'
| 'generating-panels'
| 'layout'
| 'speech-bubbles'
| 'complete';
export type ExportFormat = 'pdf' | 'cbz' | 'png';
export type PageSizeName = 'a4' | 'letter' | 'manga' | 'webtoon' | 'square';
export type ColorProfile = 'rgb' | 'cmyk';
export type ShotType =
| 'establishing'
| 'wide'
| 'medium'
| 'close-up'
| 'extreme-close-up'
| 'over-shoulder'
| 'aerial';
export type LayoutType = 'grid' | 'manga' | 'western' | 'action' | 'dialogue' | 'splash';
export type BubbleType = 'normal' | 'thought' | 'shout' | 'whisper' | 'narration' | 'sound-effect';
export type CharacterRole = 'protagonist' | 'antagonist' | 'supporting' | 'extra';
export type Emotion = 'happy' | 'sad' | 'angry' | 'surprised' | 'neutral' | 'determined';
export type TransitionType = 'none' | 'fade' | 'wipe' | 'dissolve' | 'action-lines';
// ============================================================================
// Character Types
// ============================================================================
export interface CharacterPromptTemplate {
age: string;
gender: string;
hairColor: string;
hairStyle: string;
skinTone: string;
eyeColor: string;
bodyType: string;
outfit: string;
accessories: string;
distinguishingFeatures: string;
}
export interface ColorPalette {
hair: string;
eyes: string;
skin: string;
outfit: string;
}
export interface Character {
id: string;
name: string;
role: CharacterRole;
description: string;
promptTemplate: CharacterPromptTemplate;
referenceImageUrl: string;
characterSheetUrls: string[];
seed: number;
colorPalette: ColorPalette;
appearanceCount: number;
firstAppearancePanel?: string;
}
// ============================================================================
// Dialogue Types
// ============================================================================
export interface Dialogue {
speakerId: string;
text: string;
bubbleType: Exclude<BubbleType, 'narration' | 'sound-effect'>;
emotion: Emotion;
}
// ============================================================================
// Panel Types
// ============================================================================
export interface Panel {
panelId: string;
panelNumber: number;
shotType: ShotType;
description: string;
charactersPresent: string[];
dialogue: Dialogue[];
caption?: string;
soundEffects: string[];
transitionFromPrevious: TransitionType;
}
export interface PanelImage {
url: string;
width: number;
height: number;
seed: number;
prompt: string;
}
export interface Position {
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}
export interface LayoutCell {
x: number;
y: number;
w: number;
h: number;
type: 'panel' | 'gutter';
}
// ============================================================================
// Page Types
// ============================================================================
export interface Page {
pageNumber: number;
layoutType: LayoutType;
panels: Panel[];
}
export interface PageLayout {
pageNumber: number;
patternId: string;
pattern: LayoutPattern;
panels: Array<{ panelId: string; panelNumber: number; layoutCell: LayoutCell }>;
width: number;
height: number;
}
export interface PageSize {
name: string;
width: number;
height: number;
dpi: number;
unit: string;
}
// ============================================================================
// Script Types
// ============================================================================
export interface ComicScript {
title: string;
synopsis: string;
characters: Character[];
pages: Page[];
}
// ============================================================================
// Speech Bubble Types
// ============================================================================
export interface BubbleStyle {
backgroundColor: string;
borderColor: string;
borderWidth: number;
borderRadius: number;
fontFamily: string;
fontSize: number;
textColor: string;
padding: number;
}
export interface SpeechBubble {
id: string;
panelId: string;
type: BubbleType;
text: string;
position: Position;
size: Size;
tailDirection: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
tailTarget: Position;
style: BubbleStyle;
speakerId: string;
}
// ============================================================================
// Store State Types
// ============================================================================
export interface WorkflowState {
currentStep: WorkflowStep;
completedSteps: WorkflowStep[];
isGenerating: boolean;
generationProgress: number;
error: string | null;
}
export interface ComicProject {
projectId: string;
projectName: string;
createdAt: string;
lastModified: string;
}
export interface ComicStore {
// ─── Project ───
project: ComicProject;
// ─── User Mode ───
userMode: UserMode;
// ─── Story ───
storyIdea: string;
storyGenre: string;
targetAudience: string;
artStyle: string;
// ─── Script (generated by DeepSeek) ───
script: ComicScript | null;
// ─── Characters (with consistency data) ───
characters: Character[];
// ─── Generated Assets ───
panelImages: Record<string, PanelImage>;
characterRefImages: Record<string, string>;
// ─── Layout ───
pageLayouts: PageLayout[];
// ─── Speech Bubbles ───
speechBubbles: Record<string, SpeechBubble[]>;
// ─── Export Settings ───
exportFormat: ExportFormat;
pageSize: PageSizeName;
colorProfile: ColorProfile;
// ─── Workflow ───
workflow: WorkflowState;
// ─── Actions ───
setUserMode: (mode: UserMode) => void;
setStoryIdea: (idea: string) => void;
setStoryGenre: (genre: string) => void;
setTargetAudience: (audience: string) => void;
setArtStyle: (style: string) => void;
setScript: (script: ComicScript | null) => void;
setCharacters: (characters: Character[]) => void;
setPanelImage: (panelId: string, image: PanelImage) => void;
setCharacterRefImage: (characterId: string, url: string) => void;
setPageLayouts: (layouts: PageLayout[]) => void;
setSpeechBubbles: (panelId: string, bubbles: SpeechBubble[]) => void;
setExportFormat: (format: ExportFormat) => void;
setPageSize: (size: PageSizeName) => void;
setWorkflowStep: (step: WorkflowStep) => void;
setWorkflowError: (error: string | null) => void;
setGenerationProgress: (progress: number) => void;
resetProject: () => void;
}
// ============================================================================
// Layout Pattern Types
// ============================================================================
export interface LayoutPattern {
id: string;
name: string;
description: string;
genre: string[];
maxPanels: number;
cells: LayoutCell[];
}
// ============================================================================
// Art Style Types
// ============================================================================
export type ArtStyleKey = 'manga' | 'western-comic' | 'pixel-art' | 'watercolor' | 'noir' | 'chibi' | 'sketch' | 'cyberpunk';
export interface ArtStyle {
key: ArtStyleKey;
name: string;
keywords: string;
}
// ============================================================================
// Community Types
// ============================================================================
export interface PublishMetadata {
title: string;
description: string;
tags: string[];
visibility: 'public' | 'unlisted';
}
export interface PublishedComic {
id: string;
projectId: string;
title: string;
author: string;
coverImage: string;
genre: string;
likes: number;
comments: number;
createdAt: string;
metadata: PublishMetadata;
}
// ============================================================================
// API Types
// ============================================================================
export interface DeepSeekConfig {
apiKey: string;
baseURL: string;
}
export interface FalAiConfig {
credentials: string;
}
export interface GenerateScriptOptions {
storyIdea: string;
genre: string;
artStyle: string;
numPages: number;
audience: string;
}
export interface GeneratePanelOptions {
panel: Panel;
characters: Character[];
artStyle: string;
referenceImages?: string[];
}
// ============================================================================
// Error Types
// ============================================================================
export interface ComicError extends Error {
code: string;
recoverable: boolean;
}
export function createComicError(
message: string,
code: string,
recoverable: boolean = false
): ComicError {
const error = new Error(message) as ComicError;
error.name = 'ComicError';
error.code = code;
error.recoverable = recoverable;
return error;
}
export function createRateLimitError(message: string = 'Rate limit exceeded'): ComicError {
return createComicError(message, 'RATE_LIMIT', true);
}
export function createContentPolicyError(message: string = 'Content violates policy'): ComicError {
return createComicError(message, 'CONTENT_POLICY', false);
}
export function createGenerationError(message: string = 'Generation failed'): ComicError {
return createComicError(message, 'GENERATION_FAILED', true);
}

View File

@ -0,0 +1,248 @@
/**
* Character Template Utilities
*
* Manages prompt templates for character consistency across panels
* Based on research: IP-Adapter with frozen reference at 0.65 strength
* and locked attribute order to prevent drift
*/
import type { Character, CharacterPromptTemplate } from '@/types';
/**
* Standard template format with locked attribute order
* Text encoders are order-sensitive - this prevents drift
*/
const CHARACTER_TEMPLATE_STRING =
"{age}-year-old {gender}, " +
"{hairColor} {hairStyle} hair, " +
"{eyeColor} eyes, " +
"{skinTone} skin, " +
"{bodyType} build, " +
"wearing {outfit}, " +
"{accessories}, " +
"{distinguishingFeatures}";
/**
* Parse a natural language description into a structured template
* Uses regex patterns to extract key attributes
*/
export function parseDescriptionToTemplate(description: string): CharacterPromptTemplate {
const template: CharacterPromptTemplate = {
age: extractAge(description),
gender: extractGender(description),
hairColor: extractHairColor(description),
hairStyle: extractHairStyle(description),
eyeColor: extractEyeColor(description),
skinTone: extractSkinTone(description),
bodyType: extractBodyType(description),
outfit: extractOutfit(description),
accessories: extractAccessories(description),
distinguishingFeatures: extractDistinguishingFeatures(description),
};
return template;
}
/**
* Build character prompt from template with locked order
* This ensures consistency across all panel generations
*/
export function buildCharacterPrompt(
template: CharacterPromptTemplate,
action: string,
setting: string,
lighting: string,
artStyle: string
): string {
const base = CHARACTER_TEMPLATE_STRING
.replace('{age}', template.age || 'young adult')
.replace('{gender}', template.gender || 'person')
.replace('{hairColor}', template.hairColor || '')
.replace('{hairStyle}', template.hairStyle || '')
.replace('{eyeColor}', template.eyeColor || '')
.replace('{skinTone}', template.skinTone || '')
.replace('{bodyType}', template.bodyType || 'average')
.replace('{outfit}', template.outfit || 'casual clothing')
.replace('{accessories}', template.accessories || 'no accessories')
.replace('{distinguishingFeatures}', template.distinguishingFeatures || '');
return `${artStyle} style. ${base}, ${action}, ${setting}, ${lighting}`.replace(/\s+/g, ' ').trim();
}
/**
* Generate a deterministic seed from character name
* Ensures reproducibility while allowing variation
*/
export function deriveSeedFromString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash) % 2147483647;
}
/**
* Generate panel-specific seed for consistency
*/
export function generatePanelSeed(
projectId: string,
pageNumber: number,
panelNumber: number,
panelId: string
): number {
return deriveSeedFromString(`${projectId}_${pageNumber}_${panelNumber}_${panelId}`);
}
// Helper extraction functions using regex patterns
function extractAge(description: string): string {
const patterns = [
/(\d+)[-\s]?year[\s-]?old/i,
/(\d+)[-\s]?years?\s+old/i,
/(young|teen|adult|middle[\s-]?aged|elderly|old)/i,
];
for (const pattern of patterns) {
const match = description.match(pattern);
if (match) return match[1] || match[0];
}
return 'young adult';
}
function extractGender(description: string): string {
if (/\b(female|woman|girl|lady)\b/i.test(description)) return 'female';
if (/\b(male|man|boy|guy)\b/i.test(description)) return 'male';
if (/\b(non[\s-]?binary|nb|enby)\b/i.test(description)) return 'non-binary';
return 'person';
}
function extractHairColor(description: string): string {
const colors = ['black', 'brown', 'blonde', 'blond', 'red', 'auburn', 'brunette', 'silver', 'gray', 'grey', 'white', 'blue', 'green', 'purple', 'pink', 'orange'];
for (const color of colors) {
const pattern = new RegExp(`\\b(${color}\\s+hair|${color}-haired)\\b`, 'i');
if (pattern.test(description)) return color.replace('blond', 'blonde');
}
return '';
}
function extractHairStyle(description: string): string {
const styles = ['long', 'short', 'medium', 'curly', 'straight', 'wavy', 'spikey', 'spiky', 'ponytail', 'bun', 'braid', 'buzz cut', 'bald'];
for (const style of styles) {
if (description.toLowerCase().includes(style)) return style;
}
return '';
}
function extractEyeColor(description: string): string {
const colors = ['blue', 'brown', 'green', 'hazel', 'amber', 'gray', 'grey', 'violet', 'purple'];
for (const color of colors) {
if (new RegExp(`\\b${color}\\s+eyes?\\b`, 'i').test(description)) return color;
}
return '';
}
function extractSkinTone(description: string): string {
const tones = ['fair', 'pale', 'light', 'medium', 'tan', 'olive', 'brown', 'dark'];
for (const tone of tones) {
if (new RegExp(`\\b${tone}\\s+(skin|complexion)\\b`, 'i').test(description)) return tone;
}
return '';
}
function extractBodyType(description: string): string {
const types = ['slim', 'skinny', 'thin', 'average', 'athletic', 'muscular', 'heavy', 'large', 'tall', 'short', 'petite'];
for (const type of types) {
if (new RegExp(`\\b${type}\\s+(build|body|frame)\\b`, 'i').test(description)) return type;
if (description.toLowerCase().includes(type)) return type;
}
return 'average';
}
function extractOutfit(description: string): string {
const patterns = [
/wearing\s+([^,.]+)/i,
/dressed\s+(?:in|as)\s+([^,.]+)/i,
/outfit[:\s]+([^,.]+)/i,
/clothing[:\s]+([^,.]+)/i,
];
for (const pattern of patterns) {
const match = description.match(pattern);
if (match) return match[1].trim();
}
return 'casual clothing';
}
function extractAccessories(description: string): string {
const accessories: string[] = [];
const accessoryPatterns = [
/\b(glasses|sunglasses)\b/i,
/\b(hat|cap|beanie)\b/i,
/\b(scarf)\b/i,
/\b(necklace|pendant)\b/i,
/\b(earrings?)\b/i,
/\b(bracelet|watch)\b/i,
/\b(ring)\b/i,
/\b(backpack|bag)\b/i,
/\b(gloves)\b/i,
/\b(belt)\b/i,
];
for (const pattern of accessoryPatterns) {
const match = description.match(pattern);
if (match) accessories.push(match[0]);
}
return accessories.length > 0 ? accessories.join(', ') : 'no accessories';
}
function extractDistinguishingFeatures(description: string): string {
const features: string[] = [];
const featurePatterns = [
/\b(scar|scars)\s+(?:on|across)\s+([^,.]+)/i,
/\b(tattoo|tattoos)\s+(?:on|of)\s+([^,.]+)/i,
/\b(freckles)\b/i,
/\b(mole|birthmark)\b/i,
/\b(beard|mustache|goatee)\b/i,
/\b(glasses|monocle)\b/i,
/\b(prosthetic)\b/i,
];
for (const pattern of featurePatterns) {
const match = description.match(pattern);
if (match) features.push(match[0]);
}
return features.join(', ');
}
/**
* Extract color palette from character description
* Uses simple keyword matching - can be enhanced with image analysis
*/
export function extractColorPalette(description: string): { hair: string; eyes: string; skin: string; outfit: string } {
return {
hair: extractHairColor(description),
eyes: extractEyeColor(description),
skin: extractSkinTone(description),
outfit: extractOutfit(description).split(' ')[0] || '',
};
}
/**
* Update character with parsed template from description
*/
export function updateCharacterFromDescription(character: Character): Character {
const template = parseDescriptionToTemplate(character.description);
const colorPalette = extractColorPalette(character.description);
return {
...character,
promptTemplate: template,
colorPalette,
seed: character.seed || deriveSeedFromString(character.name),
};
}

251
src/utils/layoutPatterns.ts Normal file
View File

@ -0,0 +1,251 @@
/**
* Layout Patterns and Templates
*
* Pre-defined comic page layout patterns optimized for different genres and narrative styles
*/
import type { LayoutPattern, PageSize, LayoutCell } from "@/types";
/**
* Standard comic page sizes with dimensions at 300 DPI
*/
export const PAGE_SIZES: Record<string, PageSize> = {
a4: { name: "A4", width: 2480, height: 3508, dpi: 300, unit: "px" },
letter: { name: "US Letter", width: 2550, height: 3300, dpi: 300, unit: "px" },
manga: { name: "B5 Manga", width: 2158, height: 3035, dpi: 300, unit: "px" },
webtoon: { name: "Webtoon", width: 800, height: 1280, dpi: 72, unit: "px" },
square: { name: "Instagram", width: 1080, height: 1080, dpi: 72, unit: "px" },
};
/**
* Pre-defined layout patterns for comic pages
*/
export const LAYOUT_PATTERNS: LayoutPattern[] = [
{
id: "grid-2x2",
name: "Classic Grid",
description: "2x2 equal panels — standard dialogue/page",
genre: ["all"],
maxPanels: 4,
cells: [
{ x: 0.02, y: 0.02, w: 0.47, h: 0.47, type: "panel" },
{ x: 0.51, y: 0.02, w: 0.47, h: 0.47, type: "panel" },
{ x: 0.02, y: 0.51, w: 0.47, h: 0.47, type: "panel" },
{ x: 0.51, y: 0.51, w: 0.47, h: 0.47, type: "panel" },
],
},
{
id: "manga-3-tier",
name: "Manga 3-Tier",
description: "Three horizontal tiers, Japanese manga style",
genre: ["manga", "action"],
maxPanels: 6,
cells: [
{ x: 0.02, y: 0.02, w: 0.3, h: 0.3, type: "panel" },
{ x: 0.35, y: 0.02, w: 0.3, h: 0.3, type: "panel" },
{ x: 0.68, y: 0.02, w: 0.3, h: 0.3, type: "panel" },
{ x: 0.02, y: 0.35, w: 0.47, h: 0.3, type: "panel" },
{ x: 0.51, y: 0.35, w: 0.47, h: 0.3, type: "panel" },
{ x: 0.02, y: 0.68, w: 0.96, h: 0.3, type: "panel" },
],
},
{
id: "action-dynamic",
name: "Action Dynamic",
description: "Varied sizes for action sequences with impact panels",
genre: ["action", "superhero"],
maxPanels: 5,
cells: [
{ x: 0.02, y: 0.02, w: 0.6, h: 0.35, type: "panel" },
{ x: 0.64, y: 0.02, w: 0.34, h: 0.35, type: "panel" },
{ x: 0.02, y: 0.4, w: 0.34, h: 0.28, type: "panel" },
{ x: 0.38, y: 0.4, w: 0.6, h: 0.28, type: "panel" },
{ x: 0.02, y: 0.71, w: 0.96, h: 0.27, type: "panel" },
],
},
{
id: "splash-page",
name: "Splash Page",
description: "Full-page single panel for dramatic reveals",
genre: ["all"],
maxPanels: 1,
cells: [{ x: 0.02, y: 0.02, w: 0.96, h: 0.96, type: "panel" }],
},
{
id: "webtoon-scroll",
name: "Webtoon Scroll",
description: "Tall vertical panels for mobile/webtoon format",
genre: ["webtoon", "slice-of-life"],
maxPanels: 3,
cells: [
{ x: 0.02, y: 0.01, w: 0.96, h: 0.32, type: "panel" },
{ x: 0.02, y: 0.34, w: 0.96, h: 0.32, type: "panel" },
{ x: 0.02, y: 0.67, w: 0.96, h: 0.32, type: "panel" },
],
},
{
id: "western-3x3",
name: "Western 3x3",
description: "Three rows of three panels — classic western comic",
genre: ["western-comic", "all"],
maxPanels: 9,
cells: [
{ x: 0.02, y: 0.02, w: 0.3, h: 0.29, type: "panel" },
{ x: 0.35, y: 0.02, w: 0.3, h: 0.29, type: "panel" },
{ x: 0.68, y: 0.02, w: 0.3, h: 0.29, type: "panel" },
{ x: 0.02, y: 0.35, w: 0.3, h: 0.29, type: "panel" },
{ x: 0.35, y: 0.35, w: 0.3, h: 0.29, type: "panel" },
{ x: 0.68, y: 0.35, w: 0.3, h: 0.29, type: "panel" },
{ x: 0.02, y: 0.68, w: 0.3, h: 0.29, type: "panel" },
{ x: 0.35, y: 0.68, w: 0.3, h: 0.29, type: "panel" },
{ x: 0.68, y: 0.68, w: 0.3, h: 0.29, type: "panel" },
],
},
{
id: "dialogue-heavy",
name: "Dialogue Heavy",
description: "Smaller panels optimized for conversation scenes",
genre: ["drama", "slice-of-life", "romance"],
maxPanels: 8,
cells: [
{ x: 0.02, y: 0.02, w: 0.47, h: 0.22, type: "panel" },
{ x: 0.51, y: 0.02, w: 0.47, h: 0.22, type: "panel" },
{ x: 0.02, y: 0.26, w: 0.47, h: 0.22, type: "panel" },
{ x: 0.51, y: 0.26, w: 0.47, h: 0.22, type: "panel" },
{ x: 0.02, y: 0.5, w: 0.47, h: 0.22, type: "panel" },
{ x: 0.51, y: 0.5, w: 0.47, h: 0.22, type: "panel" },
{ x: 0.02, y: 0.74, w: 0.47, h: 0.22, type: "panel" },
{ x: 0.51, y: 0.74, w: 0.47, h: 0.22, type: "panel" },
],
},
{
id: "cinematic-widescreen",
name: "Cinematic Widescreen",
description: "Wide cinematic panels for dramatic moments",
genre: ["action", "scifi", "noir"],
maxPanels: 4,
cells: [
{ x: 0.02, y: 0.02, w: 0.96, h: 0.22, type: "panel" },
{ x: 0.02, y: 0.26, w: 0.96, h: 0.22, type: "panel" },
{ x: 0.02, y: 0.5, w: 0.96, h: 0.22, type: "panel" },
{ x: 0.02, y: 0.74, w: 0.96, h: 0.22, type: "panel" },
],
},
];
/**
* Select best layout pattern based on panel count and genre preference
*/
export function selectBestPattern(
panelCount: number,
genre?: string,
preference?: string
): LayoutPattern {
// If preference provided, use it
if (preference) {
const preferred = LAYOUT_PATTERNS.find((p) => p.id === preference);
if (preferred && preferred.maxPanels >= panelCount) {
return preferred;
}
}
// Filter patterns that can accommodate the panel count
const suitable = LAYOUT_PATTERNS.filter((p) => p.maxPanels >= panelCount);
// If genre specified, prioritize patterns matching that genre
if (genre) {
const genreMatches = suitable.filter(
(p) => p.genre.includes(genre) || p.genre.includes("all")
);
if (genreMatches.length > 0) {
// Return the pattern with the smallest maxPanels that fits
return genreMatches.reduce((best, current) =>
current.maxPanels < best.maxPanels ? current : best
);
}
}
// Return the pattern with the smallest maxPanels that fits
if (suitable.length > 0) {
return suitable.reduce((best, current) =>
current.maxPanels < best.maxPanels ? current : best
);
}
// Fallback to grid-2x2
return LAYOUT_PATTERNS[0];
}
import type { PageLayout } from "@/types";
/**
* Auto-layout algorithm: distribute panels across pages using layout patterns
*/
export function autoLayoutPages(
panels: { panelId: string; panelNumber: number }[],
pageSize: PageSize,
genre?: string,
patternPreference?: string
): PageLayout[] {
const pages: PageLayout[] = [];
let remainingPanels = [...panels];
let pageNum = 1;
while (remainingPanels.length > 0) {
// Select best pattern for remaining panel count
const pattern = selectBestPattern(
remainingPanels.length,
genre,
patternPreference
);
// Get panels for this page (up to pattern.maxPanels)
const panelsForPage = remainingPanels.splice(0, pattern.maxPanels);
// Assign layout cells to panels
const panelsWithLayout = panelsForPage.map((panel, index) => ({
panelId: panel.panelId,
panelNumber: panel.panelNumber,
layoutCell: pattern.cells[index] || pattern.cells[pattern.cells.length - 1],
}));
pages.push({
pageNumber: pageNum,
patternId: pattern.id,
pattern,
panels: panelsWithLayout,
width: pageSize.width,
height: pageSize.height,
});
pageNum++;
}
return pages;
}
/**
* Get layout patterns suitable for a specific genre
*/
export function getPatternsByGenre(genre: string): LayoutPattern[] {
return LAYOUT_PATTERNS.filter(
(p) => p.genre.includes(genre) || p.genre.includes("all")
);
}
/**
* Calculate pixel position from relative layout cell
*/
export function calculatePixelPosition(
cell: LayoutCell,
pageWidth: number,
pageHeight: number
): { x: number; y: number; width: number; height: number } {
return {
x: Math.round(cell.x * pageWidth),
y: Math.round(cell.y * pageHeight),
width: Math.round(cell.w * pageWidth),
height: Math.round(cell.h * pageHeight),
};
}

60
tailwind.config.js Normal file
View File

@ -0,0 +1,60 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
}
}
},
plugins: [require("tailwindcss-animate")],
}

42
tsconfig.app.json Normal file
View File

@ -0,0 +1,42 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
"ignoreDeprecations": "6.0",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Strict mode */
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import path from 'path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

17
vitest.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})