init state
This commit is contained in:
commit
ae1cae967b
8
.env.example
Normal file
8
.env.example
Normal 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
24
.gitignore
vendored
Normal 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
73
README.md
Normal 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
33
eslint.config.js
Normal 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
13
index.html
Normal 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
6719
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
package.json
Normal file
60
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
public/favicon.svg
Normal file
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
24
public/icons.svg
Normal 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
106
src/App.tsx
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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
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 |
316
src/components/export/ExportModal.tsx
Normal file
316
src/components/export/ExportModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
src/components/export/ExportSection.tsx
Normal file
232
src/components/export/ExportSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
399
src/components/layout/LayoutEngine.tsx
Normal file
399
src/components/layout/LayoutEngine.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/components/layout/NewComicModal.tsx
Normal file
65
src/components/layout/NewComicModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/layout/Sidebar.tsx
Normal file
64
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/layout/TopBar.tsx
Normal file
75
src/components/layout/TopBar.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/components/layout/Workspace.tsx
Normal file
15
src/components/layout/Workspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
367
src/components/panels/PanelGrid.tsx
Normal file
367
src/components/panels/PanelGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
544
src/components/speech/SpeechBubbleSystem.tsx
Normal file
544
src/components/speech/SpeechBubbleSystem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
414
src/components/story/CharacterManager.tsx
Normal file
414
src/components/story/CharacterManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
src/components/story/ScriptViewer.tsx
Normal file
245
src/components/story/ScriptViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
src/components/story/StoryInput.tsx
Normal file
307
src/components/story/StoryInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal 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 }
|
||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal 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
98
src/index.css
Normal 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
6
src/lib/utils.ts
Normal 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
10
src/main.tsx
Normal 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>,
|
||||
)
|
||||
286
src/services/bubbleEngine.ts
Normal file
286
src/services/bubbleEngine.ts
Normal 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}`;
|
||||
}
|
||||
295
src/services/deepseekService.ts
Normal file
295
src/services/deepseekService.ts
Normal 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');
|
||||
}
|
||||
276
src/services/exportService.ts
Normal file
276
src/services/exportService.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
289
src/services/falAiService.ts
Normal file
289
src/services/falAiService.ts
Normal 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
227
src/store/comicStore.ts
Normal 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
1
src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
387
src/types/index.ts
Normal file
387
src/types/index.ts
Normal 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);
|
||||
}
|
||||
248
src/utils/characterTemplates.ts
Normal file
248
src/utils/characterTemplates.ts
Normal 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
251
src/utils/layoutPatterns.ts
Normal 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
60
tailwind.config.js
Normal 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
42
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal 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
13
vite.config.ts
Normal 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
17
vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user