Merge fix/auto-20260228-193351: 默认故事封面生成功能
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m20s

This commit is contained in:
repair-agent 2026-03-02 15:50:09 +08:00
commit 5fb0db5da0
10 changed files with 397 additions and 1 deletions

View File

View File

@ -0,0 +1,116 @@
"""
用新的 LLM 提炼逻辑重新生成默认故事封面并上传到 OSS
使用方法:
python manage.py generate_default_covers
python manage.py generate_default_covers --dry-run # 仅打印提炼到的描述,不生成图片
"""
import uuid
import logging
import requests
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.stories.utils import DEFAULT_STORIES
from apps.stories.services.llm_service import _extract_image_description
logger = logging.getLogger(__name__)
# 每个默认故事对应的 OSS key与 utils.py 中的 cover_url 一致)
DEFAULT_COVER_KEYS = {
"失控的魔法扫帚": "stories/defaults/失控的魔法扫帚_cover.png",
}
class Command(BaseCommand):
help = "用 LLM 提炼故事画面描述后调用 Seedream 4.5 重新生成默认故事封面"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="仅打印 LLM 提炼的画面描述,不实际生成图片",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
config = settings.LLM_CONFIG
if not config.get("API_KEY"):
self.stderr.write(self.style.ERROR("VOLCENGINE_API_KEY 未配置"))
return
try:
from volcenginesdkarkruntime import Ark
except ImportError:
self.stderr.write(self.style.ERROR("volcengine SDK 未安装"))
return
try:
from utils.oss import get_oss_client
import oss2
except ImportError:
self.stderr.write(self.style.ERROR("oss2 未安装"))
return
client = Ark(api_key=config["API_KEY"])
image_model = config.get("IMAGE_MODEL_NAME", "doubao-seedream-4-5-251128")
image_size = config.get("IMAGE_SIZE", "2560x1440")
oss_config = settings.ALIYUN_OSS
oss_base = f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}"
for story in DEFAULT_STORIES:
title = story["title"]
content = story["content"]
oss_key = DEFAULT_COVER_KEYS.get(title)
if not oss_key:
self.stdout.write(self.style.WARNING(f"[{title}] 未找到对应 OSS key跳过"))
continue
self.stdout.write(f"\n[{title}]")
# Step 1: LLM 提炼画面描述
self.stdout.write(" 正在用 LLM 提炼画面描述...")
scene_desc = _extract_image_description(
title, content, client, config["MODEL_NAME"]
)
self.stdout.write(self.style.SUCCESS(f" 画面描述({len(scene_desc)} 字):{scene_desc}"))
if dry_run:
self.stdout.write(self.style.NOTICE(" [dry-run] 跳过图片生成"))
continue
# Step 2: 文生图
image_prompt = (
f"儿童绘本封面插画,{scene_desc},卡通可爱风格,色彩明亮鲜艳,高质量插画"
)
self.stdout.write(f" 正在生成封面图({image_size}...")
result = client.images.generate(
model=image_model,
prompt=image_prompt,
size=image_size,
response_format="url",
watermark=False,
)
image_url = result.data[0].url
self.stdout.write(f" 临时图片 URL: {image_url[:80]}...")
# Step 3: 下载图片
self.stdout.write(" 正在下载图片...")
resp = requests.get(image_url, timeout=60)
resp.raise_for_status()
# Step 4: 覆盖上传到 OSS
self.stdout.write(f" 正在上传到 OSS: {oss_key}")
oss_client = get_oss_client()
oss_client.bucket.put_object(
oss_key,
resp.content,
headers={"Content-Type": "image/jpeg"},
)
final_url = f"{oss_base}/{oss_key}"
self.stdout.write(self.style.SUCCESS(f" ✓ 封面已更新: {final_url}"))
self.stdout.write(self.style.SUCCESS("\n完成。"))

View File

@ -0,0 +1,106 @@
"""
上传默认故事媒体资源到 OSS
使用方法:
python manage.py upload_default_story_media
python manage.py upload_default_story_media --dry-run # 仅检查,不上传
上传内容:
- 视频: rtc_prd/动态绘本/失控的魔法扫帚.mp4 stories/defaults/失控的魔法扫帚.mp4
- 封面: rtc_prd/故事书封面图/卡皮巴拉的奇幻漂流.png stories/defaults/失控的魔法扫帚_cover.png
"""
import os
from django.conf import settings
from django.core.management.base import BaseCommand
try:
import oss2
OSS_AVAILABLE = True
except ImportError:
OSS_AVAILABLE = False
# 上传目标 OSS key固定路径与 utils.py 中的 URL 对应)
_PRD_ROOT = os.path.expanduser("~/Desktop/zyc/qiyuan_gitea/rtc_prd")
UPLOAD_ITEMS = [
{
"desc": "绘本视频",
"local": os.path.join(_PRD_ROOT, "动态绘本/失控的魔法扫帚.mp4"),
"oss_key": "stories/defaults/失控的魔法扫帚.mp4",
"content_type": "video/mp4",
},
{
"desc": "故事封面",
"local": os.path.join(_PRD_ROOT, "故事书封面图/卡皮巴拉的奇幻漂流.png"),
"oss_key": "stories/defaults/失控的魔法扫帚_cover.png",
"content_type": "image/png",
},
]
class Command(BaseCommand):
help = "上传默认故事的视频和封面到 OSS"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="仅打印计划,不实际上传",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
if not OSS_AVAILABLE:
self.stderr.write(self.style.ERROR("oss2 未安装,请先 pip install oss2"))
return
oss_config = settings.ALIYUN_OSS
if not oss_config.get("ACCESS_KEY_ID"):
self.stderr.write(self.style.ERROR("OSS 未配置,请检查 .env 中的 ALIYUN_OSS 设置"))
return
auth = oss2.Auth(oss_config["ACCESS_KEY_ID"], oss_config["ACCESS_KEY_SECRET"])
bucket = oss2.Bucket(auth, oss_config["ENDPOINT"], oss_config["BUCKET_NAME"])
oss_base = f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}"
for item in UPLOAD_ITEMS:
local_path = os.path.normpath(item["local"])
oss_key = item["oss_key"]
target_url = f"{oss_base}/{oss_key}"
self.stdout.write(f"\n[{item['desc']}]")
self.stdout.write(f" 本地文件: {local_path}")
self.stdout.write(f" OSS 目标: {oss_key}")
self.stdout.write(f" 访问 URL: {target_url}")
if not os.path.isfile(local_path):
self.stderr.write(self.style.WARNING(f" ⚠ 本地文件不存在,跳过"))
continue
file_size = os.path.getsize(local_path)
self.stdout.write(f" 文件大小: {file_size / 1024 / 1024:.1f} MB")
# 检查 OSS 是否已存在
try:
bucket.get_object_meta(oss_key)
self.stdout.write(self.style.WARNING(" ✓ OSS 已存在,跳过上传"))
continue
except oss2.exceptions.NoSuchKey:
pass # 不存在,需要上传
if dry_run:
self.stdout.write(self.style.NOTICE(" [dry-run] 将会上传此文件"))
continue
self.stdout.write(" 上传中...")
with open(local_path, "rb") as f:
bucket.put_object(
oss_key,
f,
headers={"Content-Type": item["content_type"]},
)
self.stdout.write(self.style.SUCCESS(f" ✓ 上传成功: {target_url}"))
self.stdout.write(self.style.SUCCESS("\n完成。请确认 utils.py 中的 URL 与上述 OSS URL 一致。"))

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("stories", "0003_story_shelf_nullable"),
]
operations = [
migrations.AddField(
model_name="story",
name="is_default",
field=models.BooleanField(default=False, verbose_name="是否默认故事"),
),
]

View File

@ -61,6 +61,7 @@ class Story(models.Model):
choices=GENERATION_MODE_CHOICES, default='ai' choices=GENERATION_MODE_CHOICES, default='ai'
) )
prompt = models.TextField('生成提示词', blank=True, default='') prompt = models.TextField('生成提示词', blank=True, default='')
is_default = models.BooleanField('是否默认故事', default=False)
created_at = models.DateTimeField('创建时间', auto_now_add=True) created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True) updated_at = models.DateTimeField('更新时间', auto_now=True)

View File

@ -122,12 +122,28 @@ def generate_story_stream(characters, scenes, props):
result = _parse_story_json(full_content) result = _parse_story_json(full_content)
# ── Generate cover image ──
yield _sse_event('stage', {
'stage': 'cover',
'progress': 90,
'message': '正在绘制故事封面...',
})
cover_url = ''
try:
cover_url = _generate_and_upload_cover(
result['title'], result['content'], config
)
except Exception as cover_err:
logger.warning(f'Cover generation failed (non-fatal): {cover_err}')
yield _sse_event('done', { yield _sse_event('done', {
'stage': 'done', 'stage': 'done',
'progress': 100, 'progress': 100,
'message': '大功告成!', 'message': '大功告成!',
'title': result['title'], 'title': result['title'],
'content': result['content'], 'content': result['content'],
'cover_url': cover_url,
}) })
except Exception as e: except Exception as e:
@ -157,6 +173,83 @@ def _parse_story_json(text):
} }
def _extract_image_description(title, content, client, model_name):
"""
LLM 从故事内容中提炼 50 字的画面描述主体 + 场景 + 事件
返回纯文本描述字符串
"""
system = (
"你是图像提示词专家。从给定的儿童故事中,提取主体、场景与核心事件,"
"串联成一幅画的中文描述。要求:\n"
"1. 不超过50个汉字\n"
"2. 只输出描述本身,不加任何解释、前缀或多余标点\n"
"3. 描述需具体生动,适合儿童绘本插画"
)
user = f"故事标题:{title}\n故事内容:{content[:800]}"
resp = client.chat.completions.create(
model=model_name,
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=80,
stream=False,
)
return resp.choices[0].message.content.strip()
def _generate_and_upload_cover(title, content, config):
"""
使用豆包文生图模型生成故事封面上传到 OSS 并返回 URL
失败时抛出异常由调用方捕获不影响主流程
"""
import uuid
import requests as req_lib
from datetime import datetime
from django.conf import settings
from volcenginesdkarkruntime import Ark
client = Ark(api_key=config['API_KEY'])
# 用 LLM 从故事内容提炼 ≤50 字画面描述
scene_desc = _extract_image_description(
title, content, client, config['MODEL_NAME']
)
logger.info(f'Cover image description: {scene_desc}')
image_prompt = f"儿童绘本封面插画,{scene_desc},卡通可爱风格,色彩明亮鲜艳,高质量插画"
image_model = config.get('IMAGE_MODEL_NAME', 'doubao-seedream-4-5-251128')
image_size = config.get('IMAGE_SIZE', '2560x1440')
result = client.images.generate(
model=image_model,
prompt=image_prompt,
size=image_size,
response_format='url',
watermark=False,
)
image_url = result.data[0].url
# Download from temporary URL and upload to OSS
resp = req_lib.get(image_url, timeout=60)
resp.raise_for_status()
from utils.oss import get_oss_client
oss_client = get_oss_client()
key = f"stories/covers/{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.jpg"
oss_client.bucket.put_object(
key, resp.content,
headers={'Content-Type': 'image/jpeg'},
)
oss_config = settings.ALIYUN_OSS
if oss_config.get('CUSTOM_DOMAIN'):
return f"https://{oss_config['CUSTOM_DOMAIN']}/{key}"
return f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}/{key}"
def _sse_event(event, data): def _sse_event(event, data):
"""格式化 SSE 事件""" """格式化 SSE 事件"""
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"

60
apps/stories/utils.py Normal file
View File

@ -0,0 +1,60 @@
"""
故事模块工具函数
"""
OSS_BASE = "https://qy-rtc.oss-cn-beijing.aliyuncs.com"
DEFAULT_STORIES = [
{
"title": "失控的魔法扫帚",
"content": (
"魔法学院的期末考试正在进行中小女巫艾米紧张地握着她的新扫帚「光轮2026」。"
"考试题目是:平稳飞越学校的钟楼并且不撞到任何一只鸽子。\n\n"
"「起飞!」艾米念出咒语。可是,扫帚似乎有了自己的想法,它没有飞向钟楼,"
"而是像火箭一样冲向了食堂的窗户!\n\n"
"「糟糕!那是校长的草莓蛋糕!」艾米惊呼。就在千钧一发之际,扫帚突然一个急转弯,"
"稳稳地停在了蛋糕前——原来它只是饿了。\n\n"
"虽然考试不及格,但艾米发明了全校最快的「外卖配送术」。"
"从此以后,魔法学院的学生们再也不用担心吃不到热乎乎的披萨了。"
),
"cover_url": f"{OSS_BASE}/stories/defaults/失控的魔法扫帚_cover.png",
"has_video": True,
"video_url": f"{OSS_BASE}/stories/defaults/失控的魔法扫帚.mp4",
"generation_mode": "ai",
"prompt": "角色=[小女巫],场景=[魔法学院],道具=[魔法扫帚]",
},
]
def ensure_default_stories(user):
"""确保用户书架有默认故事,没有则创建。
逻辑与 music.utils.ensure_default_tracks 保持一致
- 若用户已有默认故事则跳过
- 先确保默认书架存在再批量写入
"""
from .models import Story, StoryShelf
if Story.objects.filter(user=user, is_default=True).exists():
return
# 确保默认书架存在
shelf, _ = StoryShelf.objects.get_or_create(
user=user,
defaults={"name": "我的书架"},
)
stories = []
for item in DEFAULT_STORIES:
stories.append(Story(
user=user,
shelf=shelf,
title=item["title"],
content=item["content"],
cover_url=item["cover_url"],
has_video=item["has_video"],
video_url=item["video_url"],
generation_mode=item["generation_mode"],
prompt=item["prompt"],
is_default=True,
))
Story.objects.bulk_create(stories)

View File

@ -13,6 +13,7 @@ from utils.response import success, error
from utils.exceptions import ErrorCode from utils.exceptions import ErrorCode
from apps.admins.authentication import AppJWTAuthentication from apps.admins.authentication import AppJWTAuthentication
from .models import StoryShelf, Story from .models import StoryShelf, Story
from .utils import ensure_default_stories
from .serializers import ( from .serializers import (
StoryShelfSerializer, StoryShelfSerializer,
CreateShelfSerializer, CreateShelfSerializer,
@ -41,6 +42,7 @@ class StoryViewSet(viewsets.ViewSet):
获取故事列表 获取故事列表
GET /api/v1/stories/?shelf_id=1&page=1&page_size=20 GET /api/v1/stories/?shelf_id=1&page=1&page_size=20
""" """
ensure_default_stories(request.user)
queryset = Story.objects.filter(user=request.user) queryset = Story.objects.filter(user=request.user)
shelf_id = request.query_params.get('shelf_id') shelf_id = request.query_params.get('shelf_id')
@ -172,7 +174,7 @@ class ShelfViewSet(viewsets.ViewSet):
书架列表 书架列表
GET /api/v1/stories/shelves/ GET /api/v1/stories/shelves/
""" """
ensure_default_shelf(request.user) ensure_default_stories(request.user)
shelves = StoryShelf.objects.filter( shelves = StoryShelf.objects.filter(
user=request.user user=request.user

View File

@ -198,6 +198,8 @@ ALIYUN_PHONE_AUTH = {
LLM_CONFIG = { LLM_CONFIG = {
'API_KEY': os.environ.get('VOLCENGINE_API_KEY', ''), 'API_KEY': os.environ.get('VOLCENGINE_API_KEY', ''),
'MODEL_NAME': os.environ.get('VOLCENGINE_MODEL_NAME', 'doubao-seed-1-6-lite-251015'), 'MODEL_NAME': os.environ.get('VOLCENGINE_MODEL_NAME', 'doubao-seed-1-6-lite-251015'),
'IMAGE_MODEL_NAME': os.environ.get('VOLCENGINE_IMAGE_MODEL_NAME', 'doubao-seedream-4-5-251128'),
'IMAGE_SIZE': os.environ.get('VOLCENGINE_IMAGE_SIZE', '2560x1440'),
} }
# Swagger/OpenAPI Settings # Swagger/OpenAPI Settings