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'
)
prompt = models.TextField('生成提示词', blank=True, default='')
is_default = models.BooleanField('是否默认故事', default=False)
created_at = models.DateTimeField('创建时间', auto_now_add=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)
# ── 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', {
'stage': 'done',
'progress': 100,
'message': '大功告成!',
'title': result['title'],
'content': result['content'],
'cover_url': cover_url,
})
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):
"""格式化 SSE 事件"""
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 apps.admins.authentication import AppJWTAuthentication
from .models import StoryShelf, Story
from .utils import ensure_default_stories
from .serializers import (
StoryShelfSerializer,
CreateShelfSerializer,
@ -41,6 +42,7 @@ class StoryViewSet(viewsets.ViewSet):
获取故事列表
GET /api/v1/stories/?shelf_id=1&page=1&page_size=20
"""
ensure_default_stories(request.user)
queryset = Story.objects.filter(user=request.user)
shelf_id = request.query_params.get('shelf_id')
@ -172,7 +174,7 @@ class ShelfViewSet(viewsets.ViewSet):
书架列表
GET /api/v1/stories/shelves/
"""
ensure_default_shelf(request.user)
ensure_default_stories(request.user)
shelves = StoryShelf.objects.filter(
user=request.user

View File

@ -198,6 +198,8 @@ ALIYUN_PHONE_AUTH = {
LLM_CONFIG = {
'API_KEY': os.environ.get('VOLCENGINE_API_KEY', ''),
'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