416 lines
16 KiB
Python
416 lines
16 KiB
Python
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'))
|