feat(server): 添加图片生成服务和配置更新
This commit is contained in:
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
@@ -36,6 +36,12 @@ class Settings(BaseSettings):
|
||||
wx_appid: str = ""
|
||||
wx_secret: str = ""
|
||||
|
||||
# 微信云托管配置
|
||||
wx_cloud_env: str = ""
|
||||
|
||||
# Gemini 图片生成配置
|
||||
gemini_api_key: str = ""
|
||||
|
||||
# JWT 配置
|
||||
jwt_secret_key: str = "your-super-secret-key-change-in-production"
|
||||
jwt_expire_hours: int = 168 # 7天
|
||||
|
||||
@@ -35,8 +35,9 @@ app.include_router(drafts.router, prefix="/api", tags=["草稿箱"])
|
||||
app.include_router(upload.router, prefix="/api", tags=["上传"])
|
||||
|
||||
# 静态文件服务(用于访问上传的图片)
|
||||
os.makedirs(settings.upload_path, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=settings.upload_path), name="uploads")
|
||||
upload_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', settings.upload_path))
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
139
server/app/services/image_gen.py
Normal file
139
server/app/services/image_gen.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
图片生成服务 - 使用 Gemini API 生图,存储到本地/云托管
|
||||
"""
|
||||
import httpx
|
||||
import base64
|
||||
import time
|
||||
import hashlib
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
# 图片尺寸规范(基于前端展示尺寸 × 3倍清晰度)
|
||||
IMAGE_SIZES = {
|
||||
"cover": {"width": 240, "height": 330, "desc": "竖版封面图,3:4比例"},
|
||||
"avatar": {"width": 150, "height": 150, "desc": "正方形头像,1:1比例"},
|
||||
"background": {"width": 1120, "height": 840, "desc": "横版背景图,4:3比例"},
|
||||
"character": {"width": 512, "height": 768, "desc": "竖版角色立绘,2:3比例,透明背景"}
|
||||
}
|
||||
|
||||
|
||||
class ImageGenService:
|
||||
def __init__(self):
|
||||
settings = get_settings()
|
||||
self.api_key = settings.gemini_api_key
|
||||
self.base_url = "https://work.poloapi.com/v1beta"
|
||||
# 计算绝对路径
|
||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..', settings.upload_path))
|
||||
self.upload_dir = os.path.join(base_dir, "images")
|
||||
os.makedirs(self.upload_dir, exist_ok=True)
|
||||
|
||||
def get_size_prompt(self, image_type: str) -> str:
|
||||
"""获取尺寸描述提示词"""
|
||||
size = IMAGE_SIZES.get(image_type, IMAGE_SIZES["background"])
|
||||
return f"Image size: {size['width']}x{size['height']} pixels, {size['desc']}."
|
||||
|
||||
async def generate_image(self, prompt: str, image_type: str = "background", style: str = "anime") -> Optional[dict]:
|
||||
"""
|
||||
调用 Gemini 生成图片
|
||||
prompt: 图片描述
|
||||
image_type: 图片类型(cover/avatar/background/character)
|
||||
style: 风格(anime/realistic/illustration)
|
||||
"""
|
||||
style_prefix = {
|
||||
"anime": "anime style, high quality illustration, vibrant colors, ",
|
||||
"realistic": "photorealistic, high detail, cinematic lighting, ",
|
||||
"illustration": "digital art illustration, beautiful artwork, "
|
||||
}
|
||||
|
||||
# 组合完整提示词:风格 + 尺寸 + 内容
|
||||
size_prompt = self.get_size_prompt(image_type)
|
||||
full_prompt = f"{style_prefix.get(style, '')}{size_prompt} {prompt}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/models/gemini-3-pro-image-preview:generateContent",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": self.api_key
|
||||
},
|
||||
json={
|
||||
"contents": [{
|
||||
"parts": [{
|
||||
"text": f"Generate an image: {full_prompt}"
|
||||
}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["TEXT", "IMAGE"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
candidates = data.get("candidates", [])
|
||||
if candidates:
|
||||
parts = candidates[0].get("content", {}).get("parts", [])
|
||||
for part in parts:
|
||||
if "inlineData" in part:
|
||||
return {
|
||||
"success": True,
|
||||
"image_data": part["inlineData"]["data"],
|
||||
"mime_type": part["inlineData"].get("mimeType", "image/png")
|
||||
}
|
||||
return {"success": False, "error": "No image in response"}
|
||||
else:
|
||||
error_text = response.text[:200]
|
||||
return {"success": False, "error": f"API error: {response.status_code} - {error_text}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def save_image(self, image_data: str, filename: str) -> Optional[str]:
|
||||
"""保存图片到本地,返回访问URL"""
|
||||
try:
|
||||
image_bytes = base64.b64decode(image_data)
|
||||
file_path = os.path.join(self.upload_dir, filename)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
# 返回可访问的URL路径
|
||||
return f"/uploads/images/{filename}"
|
||||
except Exception as e:
|
||||
print(f"Save error: {e}")
|
||||
return None
|
||||
|
||||
async def generate_and_save(self, prompt: str, image_type: str = "background", style: str = "anime") -> dict:
|
||||
"""生成图片并保存"""
|
||||
result = await self.generate_image(prompt, image_type, style)
|
||||
|
||||
if not result or not result.get("success"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.get("error", "生成失败") if result else "生成失败"
|
||||
}
|
||||
|
||||
# 生成文件名
|
||||
timestamp = int(time.time() * 1000)
|
||||
hash_str = hashlib.md5(prompt.encode()).hexdigest()[:8]
|
||||
ext = "png" if "png" in result.get("mime_type", "") else "jpg"
|
||||
filename = f"{image_type}_{timestamp}_{hash_str}.{ext}"
|
||||
|
||||
url = await self.save_image(result["image_data"], filename)
|
||||
|
||||
if url:
|
||||
return {"success": True, "url": url, "filename": filename}
|
||||
else:
|
||||
return {"success": False, "error": "保存失败"}
|
||||
|
||||
|
||||
# 延迟初始化单例
|
||||
_service_instance = None
|
||||
|
||||
def get_image_gen_service():
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = ImageGenService()
|
||||
return _service_instance
|
||||
@@ -163,23 +163,7 @@ CREATE TABLE IF NOT EXISTS `story_drafts` (
|
||||
-- ============================================
|
||||
-- 8. 游玩记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `play_records` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`story_id` INT NOT NULL COMMENT '故事ID',
|
||||
`draft_id` INT DEFAULT NULL COMMENT 'AI草稿ID,原故事为空',
|
||||
`ending_name` VARCHAR(100) NOT NULL COMMENT '结局名称',
|
||||
`ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型',
|
||||
`path_history` JSON NOT NULL COMMENT '完整的选择路径',
|
||||
`play_duration` INT DEFAULT 0 COMMENT '游玩时长(秒)',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_story` (`user_id`, `story_id`),
|
||||
KEY `idx_user` (`user_id`),
|
||||
KEY `idx_story` (`story_id`),
|
||||
CONSTRAINT `play_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `play_records_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游玩记录表';
|
||||
c
|
||||
|
||||
-- ============================================
|
||||
-- 9. 故事角色表
|
||||
|
||||
Reference in New Issue
Block a user