diff --git a/apps/stories/management/__init__.py b/apps/stories/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stories/management/commands/__init__.py b/apps/stories/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stories/management/commands/generate_default_covers.py b/apps/stories/management/commands/generate_default_covers.py new file mode 100644 index 0000000..ad7c64d --- /dev/null +++ b/apps/stories/management/commands/generate_default_covers.py @@ -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完成。")) diff --git a/apps/stories/management/commands/upload_default_story_media.py b/apps/stories/management/commands/upload_default_story_media.py new file mode 100644 index 0000000..d7497f1 --- /dev/null +++ b/apps/stories/management/commands/upload_default_story_media.py @@ -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 一致。")) diff --git a/apps/stories/migrations/0004_story_is_default.py b/apps/stories/migrations/0004_story_is_default.py new file mode 100644 index 0000000..50284f2 --- /dev/null +++ b/apps/stories/migrations/0004_story_is_default.py @@ -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="是否默认故事"), + ), + ] diff --git a/apps/stories/models.py b/apps/stories/models.py index 8c2d651..b801165 100644 --- a/apps/stories/models.py +++ b/apps/stories/models.py @@ -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) diff --git a/apps/stories/services/llm_service.py b/apps/stories/services/llm_service.py index f9b5fdc..71a1cd4 100644 --- a/apps/stories/services/llm_service.py +++ b/apps/stories/services/llm_service.py @@ -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" diff --git a/apps/stories/utils.py b/apps/stories/utils.py new file mode 100644 index 0000000..a2bc72f --- /dev/null +++ b/apps/stories/utils.py @@ -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) diff --git a/apps/stories/views.py b/apps/stories/views.py index 358d906..fde873e 100644 --- a/apps/stories/views.py +++ b/apps/stories/views.py @@ -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 diff --git a/config/settings.py b/config/settings.py index e2920fe..66b2f1f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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