Initial upload of project files
This commit is contained in:
commit
d3b1600936
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.env
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.13
|
||||
106
README.md
Normal file
106
README.md
Normal file
@ -0,0 +1,106 @@
|
||||
## 团队成员与贡献
|
||||
|
||||
| 姓名 | 学号 | 主要贡献 |
|
||||
|------|------|----------|
|
||||
| 王斌 | 2411020213 | (组长) Prompt 工程优化 |
|
||||
| 翟宇轩 | 2411020215 | 后端架构设计 |
|
||||
| 吴磊 | 2411020211 | 项目的汇报和整理 |
|
||||
|
||||
---
|
||||
|
||||
## 项目简介
|
||||
|
||||
|
||||
本项目旨在打造一个**零门槛、开箱即用**的 AI 文生图创作平台,让每个人都能轻松创作专业级 AI 画作。
|
||||
|
||||
### 核心功能
|
||||
- 🎨 **智能 Prompt 生成**:基于中文描述自动优化为专业英文提示词(PydanticAI 驱动结构化生成)
|
||||
- 🖼️ **多风格支持**:写实、动漫、油画、水彩等 10+ 预设风格
|
||||
- ⚡ **实时生成**:调用云端 API,无需本地显卡,秒级出图
|
||||
- 💾 **历史记录**:自动保存生成历史,支持下载与对比
|
||||
- 🎛️ **参数调优**:图像尺寸、采样步数、引导系数自定义
|
||||
|
||||
---
|
||||
|
||||
## 如何运行
|
||||
|
||||
### 环境要求
|
||||
- Python 3.9+
|
||||
- 互联网连接(用于调用 API)
|
||||
|
||||
### 快速开始
|
||||
|
||||
#### 1. 克隆仓库
|
||||
```bash
|
||||
git clone http://hblu.top:3000/Python2025-CourseDesign/Group-TextToImage.git
|
||||
cd Group-TextToImage
|
||||
```
|
||||
|
||||
#### 2. 安装依赖
|
||||
使用 `uv`
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
#### 3. 配置 API Key
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件,填入你的 API Key
|
||||
# 支持:OpenAI DALL-E、Stability AI、Replicate 等
|
||||
```
|
||||
|
||||
`.env` 文件示例:
|
||||
```env
|
||||
# 文生图 API 配置(三选一)
|
||||
STABILITY_API_KEY=your_stability_ai_key_here
|
||||
REPLICATE_API_TOKEN=your_replicate_token_here
|
||||
OPENAI_API_KEY=your_openai_key_here
|
||||
|
||||
# 默认使用的 API 提供商
|
||||
DEFAULT_PROVIDER=stability
|
||||
```
|
||||
|
||||
#### 4. 启动应用
|
||||
```bash
|
||||
python3 backend/app.py
|
||||
```
|
||||
打开另一个终端
|
||||
```bash
|
||||
uv run streamlit run frontend/main.py
|
||||
```
|
||||
|
||||
浏览器自动打开 `http://localhost:8501`
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
Group-TextToImage/
|
||||
├── app.py # Streamlit 主应用
|
||||
├── backend/
|
||||
│ ├── api_client.py # API 调用封装
|
||||
│ ├── prompt_engineer.py # Prompt 优化引擎
|
||||
│ ├── image_processor.py # 图像后处理
|
||||
│ └── agent.py # PydanticAI 智能体定义
|
||||
├── frontend/
|
||||
│ ├── styles.css # 自定义样式
|
||||
│ └── components.py # UI 组件库
|
||||
├── .env.example # 环境变量模板
|
||||
├── .gitignore # Git 忽略配置
|
||||
├── requirements.txt # 依赖清单
|
||||
└── README.md # 项目文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发心得
|
||||
1. 单纯急躁的使用AI进行开发,没有带来什么成就感,反而是不断Debug的挫败感(国产AI真没用)
|
||||
2. 协作开发的感觉很好
|
||||
3. AI对小白很友好
|
||||
4. 做出来只是第一步,优化任重而道远
|
||||
|
||||
|
||||
|
||||
8
backend.log
Normal file
8
backend.log
Normal file
@ -0,0 +1,8 @@
|
||||
* Serving Flask app 'app'
|
||||
* Debug mode: on
|
||||
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
|
||||
* Running on http://127.0.0.1:5001
|
||||
Press CTRL+C to quit
|
||||
* Restarting with stat
|
||||
* Debugger is active!
|
||||
* Debugger PIN: 523-960-332
|
||||
81
backend/agent.py
Normal file
81
backend/agent.py
Normal file
@ -0,0 +1,81 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from pydantic_ai import Agent, RunContext
|
||||
import os
|
||||
|
||||
# --- Data Models ---
|
||||
|
||||
class Character(BaseModel):
|
||||
name: str = Field(description="The name of the character.")
|
||||
description: str = Field(description="A concise visual description of the character (e.g., 'Blonde hair, blue dress, young').")
|
||||
|
||||
class CharacterAnalysisResult(BaseModel):
|
||||
characters: List[Character] = Field(description="List of main characters identified in the text.")
|
||||
|
||||
class MangaSimplePrompt(BaseModel):
|
||||
prompt: str = Field(description="The generated English manga image prompt.")
|
||||
|
||||
# --- Wrapper Functions ---
|
||||
|
||||
async def analyze_characters_with_agent(text: str, api_key: str, base_url: Optional[str] = None, model: str = "gpt-4o") -> str:
|
||||
"""
|
||||
Uses PydanticAI Agent to analyze characters and returns a formatted string context.
|
||||
"""
|
||||
from pydantic_ai.models.openai import OpenAIModel
|
||||
|
||||
# Allow model override
|
||||
model_name = model if model else "gpt-4o"
|
||||
|
||||
# Create the model instance with the specific API Key and Base URL
|
||||
openai_model = OpenAIModel(
|
||||
model_name,
|
||||
api_key=api_key,
|
||||
base_url=base_url
|
||||
)
|
||||
|
||||
# Create a temporary agent for this run
|
||||
agent = Agent(
|
||||
openai_model,
|
||||
result_type=CharacterAnalysisResult,
|
||||
system_prompt="You are a professional manga editor. Analyze the provided novel text and extract the visual descriptions of the main characters to ensure consistency in manga adaptation."
|
||||
)
|
||||
|
||||
try:
|
||||
result = await agent.run(text)
|
||||
|
||||
# Format the result into a string context
|
||||
context_str = ""
|
||||
for char in result.data.characters:
|
||||
context_str += f"- {char.name}: {char.description}\n"
|
||||
return context_str
|
||||
except Exception as e:
|
||||
print(f"Agent Error (Characters): {e}")
|
||||
return ""
|
||||
|
||||
async def generate_single_prompt_with_agent(paragraph: str, character_context: str, api_key: str, base_url: Optional[str] = None, model: str = "gpt-4o") -> str:
|
||||
from pydantic_ai.models.openai import OpenAIModel
|
||||
|
||||
model_name = model if model else "gpt-4o"
|
||||
openai_model = OpenAIModel(
|
||||
model_name,
|
||||
api_key=api_key,
|
||||
base_url=base_url
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
openai_model,
|
||||
result_type=MangaSimplePrompt,
|
||||
deps_type=str,
|
||||
system_prompt=(
|
||||
"You are a professional manga artist assistant. Convert the novel text into a detailed manga image prompt. "
|
||||
"The prompt MUST be in English. Focus on visual details, character appearance, setting, and style (monochrome, manga style, high quality). "
|
||||
f"Maintain character consistency:\n{character_context}"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await agent.run(paragraph, deps=character_context)
|
||||
return result.data.prompt
|
||||
except Exception as e:
|
||||
print(f"Agent Error (Prompt): {e}")
|
||||
return f"Error generation prompt: {e}"
|
||||
70
backend/app.py
Normal file
70
backend/app.py
Normal file
@ -0,0 +1,70 @@
|
||||
from flask import Flask, jsonify, request
|
||||
from utils import split_text_into_paragraphs, generate_prompts, generate_image_from_prompt, save_to_history, load_history
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
return jsonify({"status": "healthy", "service": "novel-to-manga-backend"})
|
||||
|
||||
@app.route('/process_text', methods=['POST'])
|
||||
def process_text():
|
||||
data = request.json
|
||||
text = data.get('text')
|
||||
api_key = data.get('api_key') or os.getenv("OPENAI_API_KEY")
|
||||
base_url = data.get('base_url')
|
||||
model = data.get('model') or "gpt-4o"
|
||||
|
||||
if not text:
|
||||
return jsonify({"error": "No text provided"}), 400
|
||||
|
||||
try:
|
||||
paragraphs = split_text_into_paragraphs(text)
|
||||
prompts = generate_prompts(paragraphs, api_key, base_url, model)
|
||||
return jsonify({"prompts": prompts})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/generate_image', methods=['POST'])
|
||||
def generate_image():
|
||||
data = request.json
|
||||
prompt = data.get('prompt')
|
||||
# Try Image Gen specific key first, then fallback to general OpenAI key
|
||||
api_key = data.get('api_key') or os.getenv("IMAGE_GEN_API_KEY") or os.getenv("OPENAI_API_KEY")
|
||||
base_url = data.get('base_url')
|
||||
model = data.get('model') or "dall-e-3"
|
||||
|
||||
if not prompt:
|
||||
return jsonify({"error": "No prompt provided"}), 400
|
||||
|
||||
try:
|
||||
image_url = generate_image_from_prompt(prompt, api_key, base_url, model)
|
||||
if image_url:
|
||||
# Save to history
|
||||
save_to_history({
|
||||
"prompt": prompt,
|
||||
"image_url": image_url,
|
||||
"novel_text": data.get('novel_text', '') # Optional
|
||||
})
|
||||
return jsonify({"image_url": image_url})
|
||||
else:
|
||||
return jsonify({"error": "Failed to generate image"}), 500
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/history', methods=['GET'])
|
||||
def get_history():
|
||||
try:
|
||||
history = load_history()
|
||||
# Sort by timestamp desc
|
||||
history.sort(key=lambda x: x.get('timestamp', 0), reverse=True)
|
||||
return jsonify({"history": history})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, port=5001)
|
||||
119
backend/utils.py
Normal file
119
backend/utils.py
Normal file
@ -0,0 +1,119 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from openai import OpenAI
|
||||
|
||||
import asyncio
|
||||
try:
|
||||
from backend.agent import analyze_characters_with_agent, generate_single_prompt_with_agent
|
||||
except ImportError:
|
||||
from agent import analyze_characters_with_agent, generate_single_prompt_with_agent
|
||||
|
||||
def split_text_into_paragraphs(text):
|
||||
"""
|
||||
Splits the novel text into paragraphs suitable for manga panels.
|
||||
Use double newline as separator.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
return [p.strip() for p in text.split('\n\n') if p.strip()]
|
||||
|
||||
def analyze_characters(text, api_key, base_url=None, model="gpt-4o"):
|
||||
"""
|
||||
Extracts character descriptions using PydanticAI Agent.
|
||||
"""
|
||||
try:
|
||||
# Run async agent synchronously
|
||||
return asyncio.run(analyze_characters_with_agent(text, api_key, base_url, model))
|
||||
except Exception as e:
|
||||
print(f"Error analyzing characters: {e}")
|
||||
return ""
|
||||
|
||||
async def generate_prompts_async(paragraphs, api_key, base_url, model):
|
||||
full_text = "\n".join(paragraphs)
|
||||
character_context = await analyze_characters_with_agent(full_text, api_key, base_url, model)
|
||||
|
||||
tasks = []
|
||||
for p in paragraphs:
|
||||
tasks.append(generate_single_prompt_with_agent(p, character_context, api_key, base_url, model))
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
prompts = []
|
||||
for p, res in zip(paragraphs, results):
|
||||
prompts.append({"paragraph": p, "prompt": res})
|
||||
return prompts
|
||||
|
||||
def generate_prompts(paragraphs, api_key=None, base_url=None, model="gpt-4o"):
|
||||
"""
|
||||
Generates manga prompts for each paragraph using PydanticAI Agents.
|
||||
"""
|
||||
if not api_key:
|
||||
print("Warning: No API key provided for prompt generation.")
|
||||
return [{"paragraph": p, "prompt": f"Manga style panel showing: {p} (Mock generated)"} for p in paragraphs]
|
||||
|
||||
try:
|
||||
return asyncio.run(generate_prompts_async(paragraphs, api_key, base_url, model))
|
||||
except Exception as e:
|
||||
print(f"Error generating prompts: {e}")
|
||||
# Fallback
|
||||
return [{"paragraph": p, "prompt": f"Error: {str(e)}"} for p in paragraphs]
|
||||
|
||||
def generate_image_from_prompt(prompt, api_key=None, base_url=None, model="dall-e-3", size="1024x1024"):
|
||||
"""
|
||||
Generates an image from a prompt using OpenAI API.
|
||||
"""
|
||||
if not api_key:
|
||||
print("Warning: No API key for image generation.")
|
||||
# Return mock URL
|
||||
return "https://via.placeholder.com/1024x1024.png?text=Mock+Image"
|
||||
|
||||
client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
|
||||
try:
|
||||
# Note: If using a non-OpenAI provider that is compatible,
|
||||
# parameters might need adjustment (e.g. size/quality).
|
||||
response = client.images.generate(
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
quality="standard",
|
||||
n=1,
|
||||
)
|
||||
return response.data[0].url
|
||||
except Exception as e:
|
||||
print(f"Error generating image: {e}")
|
||||
return None
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data')
|
||||
HISTORY_FILE = os.path.join(DATA_DIR, 'history.json')
|
||||
|
||||
def ensure_data_dir():
|
||||
if not os.path.exists(DATA_DIR):
|
||||
os.makedirs(DATA_DIR)
|
||||
|
||||
def save_to_history(item):
|
||||
"""
|
||||
Saves a generation item to history.json.
|
||||
Item should be a dict (e.g., {prompt, image_url, timestamp}).
|
||||
"""
|
||||
ensure_data_dir()
|
||||
history = load_history()
|
||||
item['timestamp'] = item.get('timestamp', time.time())
|
||||
history.append(item)
|
||||
|
||||
with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(history, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def load_history():
|
||||
"""
|
||||
Loads history from history.json.
|
||||
"""
|
||||
ensure_data_dir()
|
||||
if not os.path.exists(HISTORY_FILE):
|
||||
return []
|
||||
try:
|
||||
with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
14
data/history.json
Normal file
14
data/history.json
Normal file
@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"prompt": "**Manga Image Prompt:** \nA monochrome manga panel in high-quality ink and screentone style. \n**Scene:** A lush garden at dusk, with dense flower bushes (peonies and chrysanthemums) swaying softly. The atmosphere is tense yet whimsical. \n**Foreground:** Fuxiao Xianchan (拂晓衔蝉) suddenly straightens from a crouching position among the flowers, mimicking a meerkat’s alert posture. Her long silver hair flows loosely, with a few strands caught on petals. Her crimson eyes widen sharply, pupils contracted in suspicion. She wears a fitted, slightly ruffled blouse and practical trousers, now slightly dirt-stained at the knees. Her body is tense, shoulders raised defensively, hands half-clenched near her chest. \n**Background:** Yu Xunge (虞寻歌) stands a few steps away, illuminated by soft twilight. His pale gold hair glimmers faintly, styled neatly. He wears a simple, elegant robe, standing upright with a calm but earnest expression. His gaze is direct yet gentle, lips parted as if mid-question. \n**Composition:** Dynamic angle from a low perspective, emphasizing Fuxiao Xianchan’s sudden movement and defensive stance. Speed lines subtly radiate from her figure to convey motion. The background is slightly blurred to focus on the characters’ interaction. \n**Style:** Classic shōjo manga aesthetic with detailed linework, expressive facial features, and dramatic contrast lighting. No dialogue bubbles or text.",
|
||||
"image_url": "https://s3.siliconflow.cn/default/outputs/2b4a3c08-71b5-4a12-81d8-fd4011174c95_4ba9bbc3fbb739679d2546eaf1764c21_ComfyUI_8e95d973_00001_.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAXXXXFILESEXAMPLE%2F20260108%2Fcn-shanghai-1%2Fs3%2Faws4_request&X-Amz-Date=20260108T065136Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Security-Token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImdyYXkiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJmYWFzOmRlcDpvcHI6ZDI5Y3UzZ2gzdnZjNzNjNWJpNWc6NjVhMzEiLCJpc3MiOiJodHRwczovL2lhbS5zaWxpY29uZmxvdy5jbiIsImlhdCI6MTc2Nzg0MzIwOSwiZXhwIjoxNzY4MDE2MDA5LCJ0eXAiOiJzZXJ2aWNlIiwiYXBsdCI6InNmOmZhYXM6ZmFicmljIiwidG50IjoiZDI5Y3UzZ2gzdnZjNzNjNWJpNWciLCJpZCI6ImQ1ZmliMmM1MG1pczczYWpubzFnIiwiYWNjZXNzIjpbeyJ0eXBlIjoiZmFhcyIsInN1YmplY3RJZCI6ImQyOWN1M2doM3Z2YzczYzViaTVnIiwiYWN0aW9ucyI6WyJmYWFzOmludm9jYXRpb246cHVsbGluZyJdfV19.pKxO5YLlQ2J_fm5RTSRyj6zYAwUeP1isOQcAtzsjtndudeKY0YpdgDPyyDG9y2MK2HaUKidY7lHpDH5i9_DvRRUxB5IMTyocTGX-TJJI1ZLFJCvjvaPklLByCWYlQD66tbVkGPoTvPUzWVyTIryj8v5qbQWZgTGlUryoqas52DtslFi9AqQ0SPKysuhppC-RY_vVZrYVYw7u4FL0qRddH56jflC7mAqj47dxAK92xpWkZGFh2ha7gUvUaISgbXfZV99R_tI6iJoHFhdm2v5dODapDVz1uvZLR7TOslSJB5Ad7kVOtdaxqBJNjFmuYiuKkrxDIGSuWZynlteIAbR1EXet8T2MOWzYEDoh59fCDAcx9FNZhU-rEwmRIjt4W0NO4fiDHqmHRK8H3x1SmnDbbG8V4Cy4t5PuDSAwnn9b7r14uUI-RKGYMMrLw1mypJHoNEEumI663ENCA-6b9y4cJ15hGeSxqR3qEqbWU7XZ5NnmOpExtyPntePLOPBLk56CG_mL8ON1OITPZrgkMFdmR0dkdQfFCoiBVfK_0GThb9trpn91oybDZ189hFrIfWQ35AGlGmk45J8pzXSJCdrdKpgSMathpnPbg5sicBTU3CbMtcNruc7oDQfk9WU8FjY4mhCn-DpIJOC2FWkK1TulN3JliFjzNxvWLlu62XThWSI&X-Amz-Signature=cf816bd0aa3cd827f4cdbca1cfcda28c119d7f133cb4ca473c2419eb39756c7d",
|
||||
"novel_text": "正弯腰在花丛中扒拉的拂晓衔蝉像狐獴一样站直身体,全身防御拉满,警惕的望着载酒寻歌:“你又想说什么让人不高兴的话,银发红瞳不美丽吗?”",
|
||||
"timestamp": 1767855097.4541745
|
||||
},
|
||||
{
|
||||
"prompt": "A manga panel in monochrome, high-quality manga style. The scene is set in a serene garden with blooming flowers. On the left, 拂晓衔蝉 is depicted with silver hair and red eyes, crouching cautiously among the flowers like a meerkat, her posture alert and defensive. On the right, 虞寻歌 stands upright with light golden hair, her expression earnest and non-confrontational as she asks a sincere question. The background features soft, detailed linework of foliage and petals, emphasizing the contrast between their postures and the peaceful setting.",
|
||||
"image_url": "https://s3.siliconflow.cn/default/outputs/22aedcfb-5b15-43f6-9e4b-1615f33d0b86_8ff8defcdfa7a4c4278e6116bf410c03_ComfyUI_b9cbc20a_00001_.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAXXXXFILESEXAMPLE%2F20260108%2Fcn-shanghai-1%2Fs3%2Faws4_request&X-Amz-Date=20260108T065148Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Security-Token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImdyYXkiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJmYWFzOmRlcDpvcHI6ZDI5Y3UzZ2gzdnZjNzNjNWJpNWc6NjVhMzEiLCJpc3MiOiJodHRwczovL2lhbS5zaWxpY29uZmxvdy5jbiIsImlhdCI6MTc2Nzg0MzIwOSwiZXhwIjoxNzY4MDE2MDA5LCJ0eXAiOiJzZXJ2aWNlIiwiYXBsdCI6InNmOmZhYXM6ZmFicmljIiwidG50IjoiZDI5Y3UzZ2gzdnZjNzNjNWJpNWciLCJpZCI6ImQ1ZmliMmM1MG1pczczYWpubzFnIiwiYWNjZXNzIjpbeyJ0eXBlIjoiZmFhcyIsInN1YmplY3RJZCI6ImQyOWN1M2doM3Z2YzczYzViaTVnIiwiYWN0aW9ucyI6WyJmYWFzOmludm9jYXRpb246cHVsbGluZyJdfV19.pKxO5YLlQ2J_fm5RTSRyj6zYAwUeP1isOQcAtzsjtndudeKY0YpdgDPyyDG9y2MK2HaUKidY7lHpDH5i9_DvRRUxB5IMTyocTGX-TJJI1ZLFJCvjvaPklLByCWYlQD66tbVkGPoTvPUzWVyTIryj8v5qbQWZgTGlUryoqas52DtslFi9AqQ0SPKysuhppC-RY_vVZrYVYw7u4FL0qRddH56jflC7mAqj47dxAK92xpWkZGFh2ha7gUvUaISgbXfZV99R_tI6iJoHFhdm2v5dODapDVz1uvZLR7TOslSJB5Ad7kVOtdaxqBJNjFmuYiuKkrxDIGSuWZynlteIAbR1EXet8T2MOWzYEDoh59fCDAcx9FNZhU-rEwmRIjt4W0NO4fiDHqmHRK8H3x1SmnDbbG8V4Cy4t5PuDSAwnn9b7r14uUI-RKGYMMrLw1mypJHoNEEumI663ENCA-6b9y4cJ15hGeSxqR3qEqbWU7XZ5NnmOpExtyPntePLOPBLk56CG_mL8ON1OITPZrgkMFdmR0dkdQfFCoiBVfK_0GThb9trpn91oybDZ189hFrIfWQ35AGlGmk45J8pzXSJCdrdKpgSMathpnPbg5sicBTU3CbMtcNruc7oDQfk9WU8FjY4mhCn-DpIJOC2FWkK1TulN3JliFjzNxvWLlu62XThWSI&X-Amz-Signature=c3cf0da228e27e055efd520637f72be11aa85e102a3436a6cc500700c7ceb28d",
|
||||
"novel_text": "虞寻歌尽可能让自己的语气不要带上任何攻击性,因为她是真的好奇而不是为了挑衅,她站直身体,认真的问道:“那你头发变成浅金色后…岂不是很不适应?”",
|
||||
"timestamp": 1767855109.414846
|
||||
}
|
||||
]
|
||||
415
frontend/main.py
Normal file
415
frontend/main.py
Normal file
@ -0,0 +1,415 @@
|
||||
import streamlit as st
|
||||
import requests
|
||||
import os
|
||||
import datetime
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
# Configuration
|
||||
ST_BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:5001")
|
||||
|
||||
st.set_page_config(page_title="Novel to Manga", layout="wide", page_icon="🎨")
|
||||
|
||||
# Constants
|
||||
MODEL_OPTIONS = {
|
||||
"OpenAI": ["gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"],
|
||||
"SiliconFlow": ["deepseek-ai/DeepSeek-V2.5", "Qwen/Qwen2.5-72B-Instruct", "meta-llama/Meta-Llama-3.1-405B-Instruct"],
|
||||
"AIHubMix": ["gpt-4o", "claude-3-5-sonnet-20240620", "gemini-1.5-pro", "gemini-2.0-flash-exp"],
|
||||
"DeepSeek": ["deepseek-chat", "deepseek-coder"],
|
||||
"Custom": []
|
||||
}
|
||||
|
||||
IMAGE_MODEL_OPTIONS = {
|
||||
"OpenAI": ["dall-e-3", "dall-e-2"],
|
||||
"AIHubMix": ["gpt-image-1", "g-nano-banana-pro", "imagen-4.0-generate-001", "flux-pro", "midjourney"],
|
||||
"SiliconFlow": ["black-forest-labs/FLUX.1-schnell", "black-forest-labs/FLUX.1-dev", "stabilityai/stable-diffusion-3-5-large"],
|
||||
"DeepSeek": ["deepseek-chat"],
|
||||
"Custom": []
|
||||
}
|
||||
|
||||
# Load decorative images
|
||||
images_dir = Path(__file__).parent.parent / "images"
|
||||
|
||||
# Load character image for top-right corner
|
||||
char_path = images_dir / "微信图片_20260108172035_101_32.jpg"
|
||||
char_encoded = ""
|
||||
if char_path.exists():
|
||||
with open(char_path, "rb") as f:
|
||||
char_encoded = base64.b64encode(f.read()).decode()
|
||||
|
||||
# Load decorative image for bottom-left corner
|
||||
bottom_left_path = images_dir / "Pasted image (3).png"
|
||||
bottom_left_encoded = ""
|
||||
if bottom_left_path.exists():
|
||||
with open(bottom_left_path, "rb") as f:
|
||||
bottom_left_encoded = base64.b64encode(f.read()).decode()
|
||||
|
||||
# Load decorative image for sidebar accent
|
||||
sidebar_accent_path = images_dir / "Pasted image (2).png"
|
||||
sidebar_accent_encoded = ""
|
||||
if sidebar_accent_path.exists():
|
||||
with open(sidebar_accent_path, "rb") as f:
|
||||
sidebar_accent_encoded = base64.b64encode(f.read()).decode()
|
||||
|
||||
# Custom CSS with Light Transparent Background
|
||||
st.markdown(f"""
|
||||
<style>
|
||||
/* Global Background - Semi-transparent White to reveal images */
|
||||
html, body {{
|
||||
background: linear-gradient(135deg, rgba(245, 247, 250, 0.9) 0%, rgba(228, 233, 242, 0.9) 100%) !important;
|
||||
background-attachment: fixed !important;
|
||||
color: #1a1a2e !important;
|
||||
}}
|
||||
|
||||
.stApp {{
|
||||
background: transparent !important;
|
||||
}}
|
||||
|
||||
[data-testid="stAppViewContainer"] {{
|
||||
background: transparent !important;
|
||||
}}
|
||||
|
||||
/* Headers - Dark Blue/Black */
|
||||
h1, h2, h3, .stHeading {{
|
||||
color: #0d47a1 !important;
|
||||
text-shadow: none;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}}
|
||||
|
||||
/* Standard Text - Dark Gray */
|
||||
p, div, span, label, li {{
|
||||
color: #424242 !important;
|
||||
}}
|
||||
|
||||
/* Inputs - White Glassmorphism - Strong Override */
|
||||
/* Inputs - White Glassmorphism - Strong Override */
|
||||
.stTextInput input, .stTextArea textarea, .stSelectbox select, div[data-baseweb="select"] > div {{
|
||||
background-color: rgba(255, 255, 255, 0.7) !important;
|
||||
color: #1a1a2e !important;
|
||||
caret-color: #0d47a1;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
||||
border-radius: 8px !important;
|
||||
}}
|
||||
|
||||
/* Ensure placeholder text is visible */
|
||||
::placeholder {{
|
||||
color: rgba(0, 0, 0, 0.5) !important;
|
||||
opacity: 1 !important;
|
||||
}}
|
||||
|
||||
.stTextInput input:focus, .stTextArea textarea:focus, .stSelectbox select:focus, div[data-baseweb="select"] > div:focus-within {{
|
||||
border-color: #00d4ff !important;
|
||||
background-color: rgba(255, 255, 255, 0.95) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.25);
|
||||
}}
|
||||
|
||||
/* Code blocks */
|
||||
code {{
|
||||
color: #d81b60 !important;
|
||||
background-color: rgba(0, 0, 0, 0.05) !important;
|
||||
}}
|
||||
|
||||
/* Buttons */
|
||||
.stButton button {{
|
||||
background: linear-gradient(135deg, #00d4ff 0%, #00a8cc 100%) !important;
|
||||
color: white !important;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
|
||||
}}
|
||||
|
||||
.stButton button:hover {{
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 15px rgba(0, 212, 255, 0.4);
|
||||
}}
|
||||
|
||||
/* Sidebar - Glassy White */
|
||||
section[data-testid="stSidebar"] {{
|
||||
background-color: rgba(255, 255, 255, 0.7) !important;
|
||||
border-right: 1px solid rgba(0,0,0,0.05);
|
||||
backdrop-filter: blur(15px);
|
||||
}}
|
||||
|
||||
/* Dropdown menu items */
|
||||
ul[data-testid="stSelectboxVirtualDropdown"] li {{
|
||||
background-color: white !important;
|
||||
color: #333 !important;
|
||||
}}
|
||||
ul[data-testid="stSelectboxVirtualDropdown"] li:hover {{
|
||||
background-color: #e3f2fd !important;
|
||||
}}
|
||||
|
||||
/* Expanders */
|
||||
.streamlit-expanderHeader {{
|
||||
background-color: rgba(255,255,255,0.6) !important;
|
||||
color: #1a1a2e !important;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
/* Tabs */
|
||||
.stTabs [data-baseweb="tab-list"] {{
|
||||
background-color: rgba(255,255,255,0.5);
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
.stTabs [data-baseweb="tab"] {{
|
||||
color: #666;
|
||||
}}
|
||||
|
||||
.stTabs [data-baseweb="tab"][aria-selected="true"] {{
|
||||
background-color: white;
|
||||
color: #00d4ff;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||
}}
|
||||
|
||||
/* Bottom-left decorative image */
|
||||
/* Bottom decorative image - Moved to RIGHT */
|
||||
/* Background decorative image - Full Screen Cover */
|
||||
.stApp::before {{
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/png;base64,{bottom_left_encoded}');
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
opacity: 0.85; /* Much more visible */
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}}
|
||||
|
||||
/* Sidebar decorative accent */
|
||||
section[data-testid="stSidebar"]::after {{
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background-image: url('data:image/png;base64,{sidebar_accent_encoded}');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
opacity: 0.15;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}}
|
||||
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# Add top-right character image - No whitespace
|
||||
if char_encoded:
|
||||
st.markdown(f"""
|
||||
<div style="position: fixed; top: 60px; right: 20px; z-index: 999;">
|
||||
<img src='data:image/jpeg;base64,{char_encoded}' style='display: block; width: auto; height: 100px; border-radius: 12px; border: 2px solid rgba(255, 255, 255, 0.9); box-shadow: 0 4px 15px rgba(0,0,0,0.2);'>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
st.title("📚 Novel to Manga Converter")
|
||||
st.markdown("Transform your stories into visual manga panels with AI.")
|
||||
|
||||
# Sidebar for Settings
|
||||
with st.sidebar:
|
||||
st.header("⚙️ Settings")
|
||||
|
||||
# Provider Selection
|
||||
provider = st.selectbox(
|
||||
"API Provider",
|
||||
["OpenAI", "SiliconFlow", "AIHubMix", "DeepSeek", "Custom"],
|
||||
help="Select your API provider to auto-fill Base URL"
|
||||
)
|
||||
|
||||
# Defaults based on provider
|
||||
default_base_url = "https://api.openai.com/v1"
|
||||
default_model = "gpt-4o"
|
||||
|
||||
if provider == "SiliconFlow":
|
||||
default_base_url = "https://api.siliconflow.cn/v1"
|
||||
default_model = "deepseek-ai/DeepSeek-V2.5"
|
||||
elif provider == "AIHubMix":
|
||||
default_base_url = "https://api.aihubmix.com/v1"
|
||||
default_model = "gpt-4o"
|
||||
elif provider == "DeepSeek":
|
||||
default_base_url = "https://api.deepseek.com"
|
||||
default_model = "deepseek-chat"
|
||||
|
||||
api_key_openai = st.text_input(f"{provider} API Key", type="password", help="Required for generating prompts")
|
||||
base_url = st.text_input("Base URL", value=default_base_url)
|
||||
|
||||
# Model Selection (Text)
|
||||
if provider == "Custom":
|
||||
model_name = st.text_input("Model Name", value=default_model)
|
||||
else:
|
||||
available_models = MODEL_OPTIONS.get(provider, [default_model])
|
||||
model_name = st.selectbox("Model Name", available_models, index=0 if default_model in available_models else 0)
|
||||
|
||||
st.divider()
|
||||
st.subheader("Image Generation")
|
||||
|
||||
image_provider = st.selectbox(
|
||||
"Image Provider",
|
||||
["OpenAI", "AIHubMix", "SiliconFlow", "DeepSeek", "Custom"],
|
||||
index=0,
|
||||
help="Select provider for Image Generation"
|
||||
)
|
||||
|
||||
# Image Gen Defaults
|
||||
default_img_base = "https://api.openai.com/v1"
|
||||
default_img_model = "dall-e-3"
|
||||
|
||||
if image_provider == "AIHubMix":
|
||||
default_img_base = "https://api.aihubmix.com/v1"
|
||||
default_img_model = "gpt-image-1"
|
||||
elif image_provider == "SiliconFlow":
|
||||
default_img_base = "https://api.siliconflow.cn/v1"
|
||||
default_img_model = "black-forest-labs/FLUX.1-schnell"
|
||||
|
||||
api_key_image = st.text_input("Image Gen API Key (Optional)", type="password", help="Leave blank to use above key")
|
||||
image_base_url = st.text_input("Image Base URL", value=default_img_base)
|
||||
|
||||
# Image Model Selection
|
||||
if image_provider == "Custom":
|
||||
image_model_name = st.text_input("Image Model", value=default_img_model, help="e.g. dall-e-3, gpt-image-1")
|
||||
else:
|
||||
available_img_models = IMAGE_MODEL_OPTIONS.get(image_provider, [default_img_model])
|
||||
# Try to find default index, else 0
|
||||
idx = 0
|
||||
if default_img_model in available_img_models:
|
||||
idx = available_img_models.index(default_img_model)
|
||||
image_model_name = st.selectbox("Image Model", available_img_models, index=idx)
|
||||
|
||||
st.divider()
|
||||
st.info(f"Backend expected at: `{ST_BACKEND_URL}`")
|
||||
st.markdown("Ensuring backend is running...")
|
||||
|
||||
# Main Area
|
||||
tab1, tab2 = st.tabs(["✨ Create", "📜 History"])
|
||||
|
||||
with tab1:
|
||||
st.subheader("1. Input Novel Text")
|
||||
novel_text = st.text_area("Paste your novel text here... (Paragraphs separated by double newlines)", height=200, placeholder="The hero stood on the cliff edge...")
|
||||
|
||||
if st.button("🚀 Analyze & Generate Prompts", type="primary"):
|
||||
if not novel_text:
|
||||
st.error("Please enter some text.")
|
||||
elif not api_key_openai and not os.getenv("OPENAI_API_KEY"):
|
||||
st.warning("Please provide an OpenAI API Key.")
|
||||
else:
|
||||
with st.spinner("Analyzing text and generating prompts..."):
|
||||
try:
|
||||
payload = {
|
||||
"text": novel_text,
|
||||
"api_key": api_key_openai,
|
||||
"base_url": base_url if base_url else None,
|
||||
"model": model_name
|
||||
}
|
||||
response = requests.post(f"{ST_BACKEND_URL}/process_text", json=payload)
|
||||
if response.status_code == 200:
|
||||
prompts = response.json().get("prompts", [])
|
||||
st.session_state['prompts'] = prompts
|
||||
st.session_state['novel_text'] = novel_text
|
||||
st.success(f"Generated {len(prompts)} prompts!")
|
||||
else:
|
||||
st.error(f"Backend Error: {response.text}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
st.error(f"Cannot connect to backend at {ST_BACKEND_URL}. Is the Flask app running?")
|
||||
except Exception as e:
|
||||
st.error(f"Error: {e}")
|
||||
|
||||
# Display Prompts and Image Gen
|
||||
if 'prompts' in st.session_state and st.session_state['prompts']:
|
||||
st.divider()
|
||||
st.subheader("2. Review Prompts & Generate Images")
|
||||
|
||||
# Grid layout for panels
|
||||
for i, item in enumerate(st.session_state['prompts']):
|
||||
with st.container():
|
||||
st.markdown(f"### Panel {i+1}")
|
||||
col1, col2 = st.columns([1, 1])
|
||||
|
||||
with col1:
|
||||
st.caption("Original Text")
|
||||
st.info(item['paragraph'])
|
||||
|
||||
with col2:
|
||||
st.caption("Manga Prompt (Editable)")
|
||||
prompt_key = f"prompt_{i}"
|
||||
# Initialize default prompt if not edited
|
||||
if prompt_key not in st.session_state:
|
||||
st.session_state[prompt_key] = item['prompt']
|
||||
|
||||
prompt_text = st.text_area("Prompt", key=prompt_key, label_visibility="collapsed", height=100)
|
||||
|
||||
if st.button(f"🎨 Generate Image for Panel {i+1}", key=f"btn_gen_{i}"):
|
||||
with st.spinner("Drawing..."):
|
||||
try:
|
||||
# Use the edited prompt
|
||||
payload = {
|
||||
"prompt": prompt_text,
|
||||
"api_key": api_key_image or api_key_openai, # Fallback
|
||||
"base_url": image_base_url if image_base_url else None,
|
||||
"model": image_model_name,
|
||||
"novel_text": item['paragraph'] # Save context
|
||||
}
|
||||
res = requests.post(f"{ST_BACKEND_URL}/generate_image", json=payload)
|
||||
if res.status_code == 200:
|
||||
img_url = res.json().get("image_url")
|
||||
st.session_state[f"image_{i}"] = img_url
|
||||
# Fetch bytes for download
|
||||
try:
|
||||
img_data = requests.get(img_url).content
|
||||
st.session_state[f"image_data_{i}"] = img_data
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
st.error(f"Backend Error: {res.text}")
|
||||
except Exception as e:
|
||||
st.error(f"Error: {e}")
|
||||
|
||||
if f"image_{i}" in st.session_state:
|
||||
st.image(st.session_state[f"image_{i}"], use_container_width=True)
|
||||
if f"image_data_{i}" in st.session_state:
|
||||
st.download_button(
|
||||
label="⬇️ Download Panel",
|
||||
data=st.session_state[f"image_data_{i}"],
|
||||
file_name=f"panel_{i+1}.png",
|
||||
mime="image/png",
|
||||
key=f"dl_{i}"
|
||||
)
|
||||
|
||||
with tab2:
|
||||
st.header("History")
|
||||
if st.button("🔄 Refresh History"):
|
||||
try:
|
||||
res = requests.get(f"{ST_BACKEND_URL}/history")
|
||||
if res.status_code == 200:
|
||||
history = res.json().get("history", [])
|
||||
st.session_state['history'] = history
|
||||
if not history:
|
||||
st.info("No history found.")
|
||||
else:
|
||||
st.error("Failed to fetch history")
|
||||
except requests.exceptions.ConnectionError:
|
||||
st.error(f"Cannot connect to backend.")
|
||||
|
||||
if 'history' in st.session_state:
|
||||
for item in st.session_state['history']:
|
||||
ts = item.get('timestamp')
|
||||
date_str = datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S') if ts else "Unknown Date"
|
||||
with st.expander(f"Generations from {date_str} - {item.get('prompt')[:30]}..."):
|
||||
col_h1, col_h2 = st.columns([1, 2])
|
||||
with col_h1:
|
||||
st.image(item.get('image_url'), caption="Generated Image")
|
||||
with col_h2:
|
||||
st.markdown("**Prompt:**")
|
||||
st.code(item.get('prompt'))
|
||||
if item.get('novel_text'):
|
||||
st.markdown("**Original Text:**")
|
||||
st.write(item.get('novel_text'))
|
||||
BIN
images/2757b99c0d29b9ae59a61f1bb88ea001a2ad1393.jpg
Normal file
BIN
images/2757b99c0d29b9ae59a61f1bb88ea001a2ad1393.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
BIN
images/Pasted image (2).png
Normal file
BIN
images/Pasted image (2).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
images/Pasted image (3).png
Normal file
BIN
images/Pasted image (3).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
BIN
images/Pasted image.png
Normal file
BIN
images/Pasted image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
images/background.png
Normal file
BIN
images/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
BIN
images/微信图片_20260108172035_101_32.jpg
Normal file
BIN
images/微信图片_20260108172035_101_32.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 707 KiB |
6
main.py
Normal file
6
main.py
Normal file
@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from novel-to-manga!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
14
pyproject.toml
Normal file
14
pyproject.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "novel-to-manga"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"flask>=3.1.2",
|
||||
"openai>=2.14.0",
|
||||
"pydantic-ai>=1.40.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"requests>=2.32.5",
|
||||
"streamlit>=1.52.2",
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user