feat: BGM 云端化 + 步骤页面 UI 统一重构

后端:
- 新增 BGM 数据库模型、Schema、CRUD、API 路由
- BgmMusic 增加 url 字段存储七牛云地址
- Alembic 迁移: 创建 BGM 表 + 添加 url 字段
- import_bgm.py 导入时自动上传七牛云 (meijiaka-zy/bgm/...)

前端:
- VideoCompose BGM 选择改为卡片弹窗 (系统BGM + 本地上传)
- 去掉 BGM 硬编码本地路径, 直接使用云端 URL
- CoverDesign 视觉重构: 绿色边框卡片、角标、hover 遮罩
- CoverDesign 去掉预选背景, 默认空白需手动选择
- 所有步骤按钮规范统一: 左=重新生成(主色), 右=导出/预览(次色)
- 预览按钮状态统一: 文字变为'视频预览中...', 保持 btn-secondary
- 去掉所有步骤按钮的 svg/emoji 图标

Rust:
- mix_bgm_to_video 支持临时文件保护 (输入输出同路径时自动中转)
- FFmpeg BGM 混合使用 aloop 循环 + amix 滤镜
This commit is contained in:
小鱼开发
2026-05-24 15:39:54 +08:00
parent 616649c872
commit 06ec0ee202
31 changed files with 1692 additions and 308 deletions
+1
View File
@@ -30,3 +30,4 @@ tauri-app/src-tauri/binaries/*
*.key
*test*.key*
.atomcode/
mixkit_bgm/
+336
View File
@@ -0,0 +1,336 @@
# Mixkit 免版权音乐清单(装修行业口播短视频)
> 来源: Mixkit.co(免版税、无需署名、可商用)
> 下载时间: 2026-05-23
> 总计: 129 首 / 616 MB
---
## 分类说明
| 分类 | 适用场景 | 数量 |
|------|----------|------|
| **知识科普** | 装修避坑、材料选择、流程科普 | 66 首 |
| **案例展示** | 完工验收、前后对比、实景展示 | 49 首 |
| **促销活动** | 开业促销、团购活动、限时优惠 | 49 首 |
| **家居生活** | 软装搭配、生活 vlog、温馨家庭 | 54 首 |
| **智能家居** | 全屋智能、现代设计、灯光系统 | 48 首 |
---
## 一、知识科普(专业可信,不抢戏)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 22 | Piano Reflections | Ahjay Stelino |
| 105 | See Line Funk | Alejandro Magaña |
| 113 | House Fest | Alejandro Magaña (A. M.) |
| 114 | Kodama Night Town | Alejandro Magaña (A. M.) |
| 1167 | Close Up | Michael Ramir C. |
| 124 | Techno Fest Vibes | Alejandro Magaña (A. M.) |
| 127 | Valley Sunset | Alejandro Magaña (A. M.) |
| 132 | Hazy After Hours | Alejandro Magaña (A. M.) |
| 134 | Deep Techno Ambience | Alejandro Magaña (A. M.) |
| 138 | Forest Treasure | Alejandro Magaña (A. M.) |
| 139 | Spirit in the Woods | Alejandro Magaña (A. M.) |
| 147 | Spirit in the Woods 2 | Alejandro Magaña (A. M.) |
| 160 | Minimal Emotion | Alejandro Magaña (A. M.) |
| 162 | Minimal Techno 01 | Alejandro Magaña (A. M.) |
| 168 | Staring at the Night Sky | Alejandro Magaña (A. M.) |
| 169 | Zanarkand Forest | Alejandro Magaña (A. M.) |
| 175 | Digital Clouds | Alejandro Magaña (A. M.) |
| 184 | Vastness | Andrew Ev |
| 251 | Ambient | Arulo |
| 292 | Relax Beat | Arulo |
| 324 | Smooth Meditation | Arulo |
| 340 | Nap Time | Arulo |
| 371 | Cat Walk | Arulo |
| 416 | Young Trizzy | Arulo |
| 441 | Meditation | Arulo |
| 443 | Serene View | Arulo |
| 470 | Golden Storm | Diego Nava |
| 471 | Rising Forest | Diego Nava |
| 480 | Curiosity | Diego Nava |
| 493 | Beautiful Dream | Diego Nava |
| 568 | Focus on Yourself | Eugenio Mininni |
| 584 | Rest Now | Eugenio Mininni |
| 593 | Opalescent | Eugenio Mininni |
| 594 | River Flow | Eugenio Mininni |
| 616 | What it Takes | Eugenio Mininni |
| 617 | Wind Leaves | Eugenio Mininni |
| 620 | B.O.R.N | Eugenio Mininni |
| 623 | Deep Urban | Eugenio Mininni |
| 628 | Summer Dream | Eugenio Mininni |
| 629 | Skeeomaver Sound | Eugenio Mininni |
| 633 | Xanthos | Eugenio Mininni |
| 652 | Soul Jazz | Francisco Alvear |
| 664 | Pop One | Francisco Alvear |
| 695 | Pop 05 | Grigoriy Nuzhny |
| 700 | Pop 03 | Grigoriy Nuzhny |
| 713 | Classical 6 | Jonny S. |
| 720 | New Bass 01 | Lily J |
| 726 | Uplifting Bass | Lily J |
| 729 | Pop Track 03 | Lily J |
| 738 | Hip Hop 02 | Lily J |
| 744 | House 02 | Lily J |
| 749 | Relaxation 05 | Lily J |
| 759 | Romantic 05 | Lily J |
| 770 | Autofahren | Mauro Urbina |
| 779 | Oh | Michael Ramir C. |
| 801 | Happy Home | Michael Ramir C. |
| 802 | Here Comes The Train | Michael Ramir C. |
| 804 | I Love You Grandma | Michael Ramir C. |
| 813 | Magical moment | Michael Ramir C. |
| 816 | Please | Michael Ramir C. |
| 821 | At the Playhouse | Michael Ramir C. |
| 832 | I'm Going Home | Michael Ramir C. |
| 834 | It's Love | Michael Ramir C. |
| 837 | Life is a Dream | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 840 | That's the Way of Life | Michael Ramir C. |
| 847 | It's April | Michael Ramir C. |
| 852 | Music and Life | Michael Ramir C. |
| 856 | Salty and Sweet | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 963 | Just Keep Walking | Michael Ramir C. |
| 970 | Night Sky Hip Hop | Michael Ramir C. |
| 993 | Finding Myself | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1081 | We'll Be Okay | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
## 二、案例展示(有成就感、积极)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 3 | Dance with Me | Ahjay Stelino |
| 4 | Delightful | Ahjay Stelino |
| 5 | Feeling Happy | Ahjay Stelino |
| 8 | Jumping Around | Ahjay Stelino |
| 11 | Just Kidding | Ahjay Stelino |
| 12 | Playground Fun | Ahjay Stelino |
| 13 | Summer Fun | Ahjay Stelino |
| 31 | Dreaming Big | Ahjay Stelino |
| 32 | Driving Ambition | Ahjay Stelino |
| 34 | Raising Me Higher | Ahjay Stelino |
| 91 | Summer's Here | Ahjay Stelino |
| 288 | One More Dance | Arulo |
| 339 | Villa Penthouse | Arulo |
| 350 | Follow Me Home | Arulo |
| 528 | You Got Jazz | Diego Nava |
| 529 | Walking in the Park | Diego Nava |
| 532 | A Happy Child | Diego Nava |
| 621 | BRIDGE No 98 | Eugenio Mininni |
| 684 | Classical vibes 4 | Grigoriy Nuzhny |
| 801 | Happy Home | Michael Ramir C. |
| 823 | Be Happy 2 | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
## 三、促销活动(轻快、有能量)
同案例展示分类,推荐节奏更欢快的:
- 3.mp3, 4.mp3, 5.mp3, 8.mp3, 11.mp3, 12.mp3, 13.mp3, 31.mp3, 32.mp3, 34.mp3, 91.mp3
- 288.mp3, 339.mp3, 350.mp3, 528.mp3, 529.mp3, 532.mp3, 621.mp3
- 801.mp3, 823.mp3, 839.mp3, 872.mp3, 897.mp3, 953.mp3
- 1000.mp3, 1001.mp3, 1052.mp3, 1140.mp3, 1183.mp3, 1210.mp3
## 四、家居生活(温馨、治愈)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 22 | Piano Reflections | Ahjay Stelino |
| 127 | Valley Sunset | Alejandro Magaña (A. M.) |
| 138 | Forest Treasure | Alejandro Magaña (A. M.) |
| 139 | Spirit in the Woods | Alejandro Magaña (A. M.) |
| 147 | Spirit in the Woods 2 | Alejandro Magaña (A. M.) |
| 168 | Staring at the Night Sky | Alejandro Magaña (A. M.) |
| 169 | Zanarkand Forest | Alejandro Magaña (A. M.) |
| 199 | Loner | Arulo |
| 250 | Island Beat | Arulo |
| 282 | Sweet September | Arulo |
| 292 | Relax Beat | Arulo |
| 322 | Life's a Movie | Arulo |
| 324 | Smooth Meditation | Arulo |
| 340 | Nap Time | Arulo |
| 345 | Nature Meditation | Arulo |
| 350 | Follow Me Home | Arulo |
| 416 | Young Trizzy | Arulo |
| 441 | Meditation | Arulo |
| 442 | Nature Yoga | Arulo |
| 443 | Serene View | Arulo |
| 444 | Yoga Song | Arulo |
| 493 | Beautiful Dream | Diego Nava |
| 528 | You Got Jazz | Diego Nava |
| 529 | Walking in the Park | Diego Nava |
| 532 | A Happy Child | Diego Nava |
| 568 | Focus on Yourself | Eugenio Mininni |
| 584 | Rest Now | Eugenio Mininni |
| 593 | Opalescent | Eugenio Mininni |
| 594 | River Flow | Eugenio Mininni |
| 617 | Wind Leaves | Eugenio Mininni |
| 620 | B.O.R.N | Eugenio Mininni |
| 623 | Deep Urban | Eugenio Mininni |
| 628 | Summer Dream | Eugenio Mininni |
| 629 | Skeeomaver Sound | Eugenio Mininni |
| 633 | Xanthos | Eugenio Mininni |
| 652 | Soul Jazz | Francisco Alvear |
| 664 | Pop One | Francisco Alvear |
| 695 | Pop 05 | Grigoriy Nuzhny |
| 700 | Pop 03 | Grigoriy Nuzhny |
| 713 | Classical 6 | Jonny S. |
| 720 | New Bass 01 | Lily J |
| 726 | Uplifting Bass | Lily J |
| 729 | Pop Track 03 | Lily J |
| 738 | Hip Hop 02 | Lily J |
| 744 | House 02 | Lily J |
| 749 | Relaxation 05 | Lily J |
| 759 | Romantic 05 | Lily J |
| 770 | Autofahren | Mauro Urbina |
| 779 | Oh | Michael Ramir C. |
| 801 | Happy Home | Michael Ramir C. |
| 802 | Here Comes The Train | Michael Ramir C. |
| 804 | I Love You Grandma | Michael Ramir C. |
| 813 | Magical moment | Michael Ramir C. |
| 816 | Please | Michael Ramir C. |
| 821 | At the Playhouse | Michael Ramir C. |
| 832 | I'm Going Home | Michael Ramir C. |
| 834 | It's Love | Michael Ramir C. |
| 837 | Life is a Dream | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 840 | That's the Way of Life | Michael Ramir C. |
| 847 | It's April | Michael Ramir C. |
| 852 | Music and Life | Michael Ramir C. |
| 856 | Salty and Sweet | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 963 | Just Keep Walking | Michael Ramir C. |
| 970 | Night Sky Hip Hop | Michael Ramir C. |
| 993 | Finding Myself | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1081 | We'll Be Okay | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1167 | Close Up | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
## 五、智能家居(科技感、高级感)
| ID | 音乐名 | 艺术家 |
|----|--------|--------|
| 105 | See Line Funk | Alejandro Magaña |
| 113 | House Fest | Alejandro Magaña (A. M.) |
| 114 | Kodama Night Town | Alejandro Magaña (A. M.) |
| 122 | Slow Rain | Alejandro Magaña (A. M.) |
| 124 | Techno Fest Vibes | Alejandro Magaña (A. M.) |
| 130 | Tech House vibes | Alejandro Magaña (A. M.) |
| 132 | Hazy After Hours | Alejandro Magaña (A. M.) |
| 134 | Deep Techno Ambience | Alejandro Magaña (A. M.) |
| 136 | Infected Mushroom Vibes | Alejandro Magaña (A. M.) |
| 137 | Goa Trance Mantra | Alejandro Magaña (A. M.) |
| 140 | Cyberpunk City | Alejandro Magaña (A. M.) |
| 157 | Infected Vibes | Alejandro Magaña (A. M.) |
| 160 | Minimal Emotion | Alejandro Magaña (A. M.) |
| 162 | Minimal Techno 01 | Alejandro Magaña (A. M.) |
| 166 | Trance Party | Alejandro Magaña (A. M.) |
| 173 | Better Times are Coming | Alejandro Magaña (A. M.) |
| 175 | Digital Clouds | Alejandro Magaña (A. M.) |
| 180 | Gear | Andrew Ev |
| 181 | Pop | Andrew Ev |
| 184 | Vastness | Andrew Ev |
| 199 | Loner | Arulo |
| 251 | Ambient | Arulo |
| 292 | Relax Beat | Arulo |
| 324 | Smooth Meditation | Arulo |
| 340 | Nap Time | Arulo |
| 371 | Cat Walk | Arulo |
| 416 | Young Trizzy | Arulo |
| 441 | Meditation | Arulo |
| 442 | Nature Yoga | Arulo |
| 443 | Serene View | Arulo |
| 444 | Yoga Song | Arulo |
| 464 | Sci-Fi Score | Arulo |
| 470 | Golden Storm | Diego Nava |
| 471 | Rising Forest | Diego Nava |
| 480 | Curiosity | Diego Nava |
| 517 | Jungle Voices | Diego Nava |
| 568 | Focus on Yourself | Eugenio Mininni |
| 584 | Rest Now | Eugenio Mininni |
| 593 | Opalescent | Eugenio Mininni |
| 594 | River Flow | Eugenio Mininni |
| 609 | Moon Walk | Eugenio Mininni |
| 616 | What it Takes | Eugenio Mininni |
| 617 | Wind Leaves | Eugenio Mininni |
| 620 | B.O.R.N | Eugenio Mininni |
| 623 | Deep Urban | Eugenio Mininni |
| 628 | Summer Dream | Eugenio Mininni |
| 629 | Skeeomaver Sound | Eugenio Mininni |
| 633 | Xanthos | Eugenio Mininni |
| 652 | Soul Jazz | Francisco Alvear |
| 664 | Pop One | Francisco Alvear |
| 695 | Pop 05 | Grigoriy Nuzhny |
| 700 | Pop 03 | Grigoriy Nuzhny |
| 713 | Classical 6 | Jonny S. |
| 720 | New Bass 01 | Lily J |
| 726 | Uplifting Bass | Lily J |
| 729 | Pop Track 03 | Lily J |
| 738 | Hip Hop 02 | Lily J |
| 744 | House 02 | Lily J |
| 749 | Relaxation 05 | Lily J |
| 759 | Romantic 05 | Lily J |
| 770 | Autofahren | Mauro Urbina |
| 779 | Oh | Michael Ramir C. |
| 801 | Happy Home | Michael Ramir C. |
| 802 | Here Comes The Train | Michael Ramir C. |
| 804 | I Love You Grandma | Michael Ramir C. |
| 813 | Magical moment | Michael Ramir C. |
| 816 | Please | Michael Ramir C. |
| 821 | At the Playhouse | Michael Ramir C. |
| 832 | I'm Going Home | Michael Ramir C. |
| 834 | It's Love | Michael Ramir C. |
| 837 | Life is a Dream | Michael Ramir C. |
| 839 | Tears of Joy | Michael Ramir C. |
| 840 | That's the Way of Life | Michael Ramir C. |
| 847 | It's April | Michael Ramir C. |
| 852 | Music and Life | Michael Ramir C. |
| 856 | Salty and Sweet | Michael Ramir C. |
| 872 | Gimme that Groove! | Michael Ramir C. |
| 897 | A Very Happy Christmas | Michael Ramir C. |
| 953 | Feel Alive | Michael Ramir C. |
| 963 | Just Keep Walking | Michael Ramir C. |
| 970 | Night Sky Hip Hop | Michael Ramir C. |
| 993 | Finding Myself | Michael Ramir C. |
| 1000 | I Can Hear Your Heartbeat | Michael Ramir C. |
| 1001 | I Do! | Michael Ramir C. |
| 1052 | Baby Yohan | Michael Ramir C. |
| 1081 | We'll Be Okay | Michael Ramir C. |
| 1140 | Funkee Monkeee | Michael Ramir C. |
| 1167 | Close Up | Michael Ramir C. |
| 1183 | Karma | Michael Ramir C. |
| 1210 | Can't Get You Off My Mind | Michael Ramir C. |
---
## 使用说明
1. 所有音乐文件在 `mixkit_bgm/` 目录下,文件名为 `{ID}.mp3`
2. 运营人员可试听挑选,将选中的音乐上传到七牛云 CDN
3. 上传后通过 `POST /api/v1/update/releases` 或直接写 SQL 入库
4. 分类字段建议: `knowledge` | `showcase` | `promotion` | `lifestyle` | `tech`
+3 -2
View File
@@ -20,17 +20,18 @@ load_dotenv()
# 导入模型
from app.db.session import Base
from app.models.bgm_music import BgmMusic # noqa
from app.models.broll_category import BrollCategory # noqa
from app.models.broll_material import BrollMaterial # noqa
from app.models.broll_tag import BrollTag # noqa
from app.models.cover_background import CoverBackground # noqa
from app.models.point_batch import PointBatch # noqa
from app.models.point_recharge_order import PointRechargeOrder # noqa
from app.models.point_transaction import PointTransaction # noqa
from app.models.update import AppRelease, ReleasePackage # noqa
from app.models.user import User # noqa
from app.models.user_device import UserDevice # noqa
from app.models.user_point import UserPoint # noqa
from app.models.cover_background import CoverBackground # noqa
from app.models.update import AppRelease, ReleasePackage # noqa
# this is the Alembic Config object
config = context.config
@@ -0,0 +1,28 @@
"""add_url_to_bgm_music
Revision ID: 100366516fbd
Revises: 7172a476e5b2
Create Date: 2026-05-24 15:24:11.076162
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '100366516fbd'
down_revision: Union[str, Sequence[str], None] = '7172a476e5b2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('mjk_bgm_musics', sa.Column('url', sa.String(length=1024), nullable=True, comment='七牛云 URL'))
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('mjk_bgm_musics', 'url')
@@ -0,0 +1,46 @@
"""add_bgm_music_table
Revision ID: 7172a476e5b2
Revises: d8f4912d7a52
Create Date: 2026-05-23 13:56:46.013156
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7172a476e5b2'
down_revision: Union[str, Sequence[str], None] = 'd8f4912d7a52'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mjk_bgm_musics',
sa.Column('title', sa.String(length=255), nullable=False, comment='音乐名称'),
sa.Column('artist', sa.String(length=255), nullable=True, comment='艺术家'),
sa.Column('category', sa.String(length=32), nullable=False, comment='场景分类'),
sa.Column('file_path', sa.String(length=512), nullable=False, comment='相对文件路径'),
sa.Column('duration', sa.Float(), nullable=True, comment='时长(秒)'),
sa.Column('status', sa.String(length=16), nullable=False, comment='状态: active/inactive'),
sa.Column('sort_order', sa.Integer(), nullable=False, comment='排序权重'),
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mjk_bgm_musics_category'), 'mjk_bgm_musics', ['category'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_mjk_bgm_musics_category'), table_name='mjk_bgm_musics')
op.drop_table('mjk_bgm_musics')
# ### end Alembic commands ###
+43
View File
@@ -0,0 +1,43 @@
"""
背景音乐 API
===========
提供装修行业场景化 BGM 列表查询。
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.crud.bgm_music import bgm_music
from app.schemas.bgm_music import BgmMusicItem, BgmMusicListResponse
from app.schemas.common import ApiResponse
router = APIRouter()
@router.get("/bgm-musics", response_model=ApiResponse[BgmMusicListResponse])
async def list_bgm_musics(
category: str | None = Query(None, description="场景分类筛选"),
db: AsyncSession = Depends(get_db),
) -> ApiResponse[BgmMusicListResponse]:
"""
获取背景音乐列表
按场景分类返回可用的背景音乐列表。
分类说明:
- knowledge: 知识科普(极简、低频)
- showcase: 案例展示(积极、有成就感)
- promotion: 促销活动(轻快、有能量)
- lifestyle: 家居生活(温馨、治愈)
- tech: 智能家居(科技感、高级感)
"""
items = await bgm_music.get_active_by_category(db, category=category)
return ApiResponse(
code=200,
message="success",
data=BgmMusicListResponse(
items=[BgmMusicItem.model_validate(item) for item in items],
total=len(items),
),
)
+4
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter
from app.api.v1 import (
auth,
bgm_music,
caption,
cover_background,
events,
@@ -63,5 +64,8 @@ api_router.include_router(points.router, tags=["Points"])
# 图片处理模块(上传 + 抠图)
api_router.include_router(image.router, tags=["Image"])
# 背景音乐模块
api_router.include_router(bgm_music.router, tags=["BGM Music"])
# 应用更新模块
api_router.include_router(update.router, prefix="/update", tags=["Update"])
+2
View File
@@ -10,6 +10,7 @@ CRUD 模块
user_obj = await user.get(db, id="xxx")
"""
from app.crud.bgm_music import bgm_music
from app.crud.broll_category import broll_category
from app.crud.broll_material import broll_material
from app.crud.cover_background import cover_background
@@ -17,6 +18,7 @@ from app.crud.user import user
__all__ = [
"user",
"bgm_music",
"broll_category",
"broll_material",
"cover_background",
+37
View File
@@ -0,0 +1,37 @@
"""
背景音乐 CRUD
============
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud.base import CRUDBase
from app.models.bgm_music import BgmMusic
class BgmMusicCRUD(CRUDBase[BgmMusic]):
"""背景音乐数据访问"""
def __init__(self) -> None:
super().__init__(BgmMusic)
async def get_active_by_category(
self, db: AsyncSession, *, category: str | None = None
) -> list[BgmMusic]:
"""
获取指定分类下状态为 active 的音乐列表
Args:
category: 场景分类,None 表示获取全部
"""
query = select(BgmMusic).where(BgmMusic.status == "active")
if category:
query = query.where(BgmMusic.category == category)
query = query.order_by(BgmMusic.sort_order.asc(), BgmMusic.id.asc())
result = await db.execute(query)
return list(result.scalars().all())
# 导出实例
bgm_music = BgmMusicCRUD()
+2
View File
@@ -7,6 +7,7 @@
"""
from app.models.base import BaseModel, BaseModelBigInt
from app.models.bgm_music import BgmMusic
from app.models.broll_category import BrollCategory
from app.models.broll_material import BrollMaterial
from app.models.broll_tag import BrollTag
@@ -21,6 +22,7 @@ from app.models.user_point import UserPoint
__all__ = [
"BaseModel",
"BaseModelBigInt",
"BgmMusic",
"User",
"UserDevice",
"UserPoint",
+50
View File
@@ -0,0 +1,50 @@
"""
背景音乐模型
==========
装修行业场景化 BGM 库,按知识科普/案例展示/促销活动/家居生活/智能家居
五个场景分类管理。
"""
from sqlalchemy import Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BaseModelBigInt
class BgmMusic(BaseModelBigInt):
"""
背景音乐表
Attributes:
title: 音乐名称
artist: 艺术家
category: 场景分类 (knowledge/showcase/promotion/lifestyle/tech)
file_path: 相对文件路径(如 knowledge/3_Dance_with_Me.mp3
duration: 时长(秒)
status: 状态 (active/inactive)
sort_order: 排序权重(越小越靠前)
"""
__tablename__ = "mjk_bgm_musics"
title: Mapped[str] = mapped_column(String(255), nullable=False, comment="音乐名称")
artist: Mapped[str] = mapped_column(String(255), nullable=True, comment="艺术家")
category: Mapped[str] = mapped_column(
String(32), nullable=False, index=True, comment="场景分类"
)
file_path: Mapped[str] = mapped_column(
String(512), nullable=False, comment="相对文件路径"
)
url: Mapped[str | None] = mapped_column(
String(1024), nullable=True, comment="七牛云 URL"
)
duration: Mapped[float] = mapped_column(
Float, nullable=True, comment="时长(秒)"
)
status: Mapped[str] = mapped_column(
String(16), default="active", nullable=False, comment="状态: active/inactive"
)
sort_order: Mapped[int] = mapped_column(
Integer, default=0, nullable=False, comment="排序权重"
)
+29
View File
@@ -0,0 +1,29 @@
"""
背景音乐 Schema
==============
"""
from pydantic import BaseModel, Field
class BgmMusicItem(BaseModel):
"""背景音乐项"""
id: int = Field(description="音乐ID")
title: str = Field(description="音乐名称")
artist: str | None = Field(default=None, description="艺术家")
category: str = Field(description="场景分类")
file_path: str = Field(description="相对文件路径")
url: str | None = Field(default=None, description="七牛云 URL")
duration: float | None = Field(default=None, description="时长(秒)")
sort_order: int = Field(default=0, description="排序权重")
class Config:
from_attributes = True
class BgmMusicListResponse(BaseModel):
"""背景音乐列表响应"""
items: list[BgmMusicItem] = Field(description="音乐列表")
total: int = Field(description="总数")
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
导入 Mixkit BGM 音频文件到数据库并上传七牛云
扫描 mixkit_bgm/ 目录下的分类文件夹,读取音频元数据并插入 mjk_bgm_musics 表
同时上传音频到七牛云视频/音频 bucket,保存 URL。
"""
import asyncio
import os
import sys
from pathlib import Path
# 将项目根目录加入 Python 路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# 加载环境变量
from dotenv import load_dotenv
load_dotenv()
from mutagen.mp3 import MP3
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.bgm_music import BgmMusic
from app.services.qiniu_service import get_qiniu_service
BGM_BASE_DIR = Path("/Users/0fun/work/meijiaka-zy/mixkit_bgm")
def parse_title(filename: str) -> str:
"""从文件名解析标题,如 '105_See_Line_Funk.mp3' -> 'See Line Funk'"""
# 去掉扩展名
name = filename.rsplit(".", 1)[0]
# 去掉开头的编号前缀(如 105_
parts = name.split("_", 1)
if len(parts) > 1 and parts[0].isdigit():
title_part = parts[1]
else:
title_part = name
# 将下划线替换为空格
return title_part.replace("_", " ")
def get_duration(filepath: Path) -> float | None:
"""获取音频文件时长(秒)"""
try:
audio = MP3(str(filepath))
return audio.info.length
except Exception as e:
print(f" 无法读取时长: {filepath.name} ({e})")
return None
async def import_bgm():
"""扫描目录并导入数据库,同时上传七牛云"""
if not BGM_BASE_DIR.exists():
print(f"错误: BGM 目录不存在: {BGM_BASE_DIR}")
return
categories = [d.name for d in BGM_BASE_DIR.iterdir() if d.is_dir()]
print(f"发现分类: {categories}")
# 初始化七牛云服务
try:
qiniu = get_qiniu_service()
print("七牛云服务初始化成功")
except ValueError as e:
print(f"七牛云配置错误: {e}")
return
async with AsyncSessionLocal() as session:
# 清空现有数据(可选)
result = await session.execute(select(BgmMusic))
existing = result.scalars().all()
if existing:
print(f"数据库中已有 {len(existing)} 条 BGM 记录,将删除后重新导入")
for row in existing:
await session.delete(row)
await session.commit()
total = 0
upload_ok = 0
upload_fail = 0
for category in sorted(categories):
cat_dir = BGM_BASE_DIR / category
files = sorted([f for f in cat_dir.iterdir() if f.suffix.lower() in (".mp3", ".wav", ".m4a")])
print(f"\n分类 [{category}]: {len(files)}")
for idx, filepath in enumerate(files):
title = parse_title(filepath.name)
relative_path = f"{category}/{filepath.name}"
duration = get_duration(filepath)
# 上传七牛云
qiniu_key = f"meijiaka-zy/bgm/{relative_path}"
url = None
try:
upload_result = qiniu.upload_file(
local_path=str(filepath),
key=qiniu_key,
file_type="audio",
check_duplicate=True,
)
url = upload_result.get("url")
if upload_result.get("isDuplicate"):
print(f" + {title} (复用已有文件)")
else:
print(f" + {title} (上传成功)")
upload_ok += 1
except Exception as e:
print(f" ! {title} (上传失败: {e})")
upload_fail += 1
# 上传失败也继续入库,只是 url 为空
bgm = BgmMusic(
title=title,
artist=None,
category=category,
file_path=relative_path,
url=url,
duration=duration,
status="active",
sort_order=idx,
)
session.add(bgm)
total += 1
await session.commit()
print(f"\n导入完成,共 {total}")
print(f" 上传成功: {upload_ok}")
print(f" 上传失败: {upload_fail}")
if __name__ == "__main__":
asyncio.run(import_bgm())
+60
View File
@@ -957,6 +957,7 @@ dependencies = [
{ name = "openai" },
{ name = "orjson" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
@@ -999,6 +1000,7 @@ requires-dist = [
{ name = "openai", specifier = "~=1.58.0" },
{ name = "orjson", specifier = ">=3.11.0" },
{ name = "passlib", extras = ["bcrypt"], specifier = "~=1.7.4" },
{ name = "pillow", specifier = ">=11.0.0" },
{ name = "pip-audit", marker = "extra == 'dev'", specifier = "~=2.7.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = "~=4.0.0" },
{ name = "psycopg2-binary", specifier = "~=2.9.10" },
@@ -1280,6 +1282,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "pip"
version = "26.0.1"
@@ -174,6 +174,77 @@ pub async fn generate_empty_shot_clip(
}
}
/// 混合 BGM 请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MixBgmToVideoArgs {
pub video_path: String,
pub bgm_path: String,
pub output_path: String,
pub video_volume: Option<f64>,
pub bgm_volume: Option<f64>,
}
/// 混合背景音乐到视频(保留原音频)
#[tauri::command]
pub async fn mix_bgm_to_video(
app: AppHandle,
args: MixBgmToVideoArgs,
) -> ApiResponse<String> {
let safe_output = match sanitize_output_path(&args.output_path) {
Ok(p) => p,
Err(e) => return ApiResponse { code: 500, message: e, data: None },
};
let video_vol = args.video_volume.unwrap_or(1.0);
let bgm_vol = args.bgm_volume.unwrap_or(0.25);
// 如果输出路径和输入路径相同,使用临时文件避免 FFmpeg 报错
let use_temp = args.video_path == safe_output;
let temp_output = if use_temp {
format!("{}.tmp_bgm.mp4", safe_output)
} else {
safe_output.clone()
};
match crate::ffmpeg_cmd::mix_bgm_to_video(
&app,
&args.video_path,
&args.bgm_path,
&temp_output,
video_vol,
bgm_vol,
).await {
Ok(_) => {
if use_temp {
// 用混合后的文件替换原文件
let _ = std::fs::remove_file(&safe_output);
if let Err(e) = std::fs::rename(&temp_output, &safe_output) {
let _ = std::fs::remove_file(&temp_output);
return ApiResponse {
code: 500,
message: format!("替换原文件失败: {}", e),
data: None,
};
}
}
ApiResponse {
code: 200,
message: "BGM 混合成功".to_string(),
data: Some(safe_output),
}
}
Err(e) => {
let _ = std::fs::remove_file(&temp_output);
ApiResponse {
code: 500,
message: format!("BGM 混合失败: {}", e),
data: None,
}
}
}
}
/// 截取视频片段请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
+52
View File
@@ -385,6 +385,7 @@ pub async fn burn_ass_subtitle(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "veryfast".to_string(),
"-crf".to_string(), "18".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
temp_output.clone(),
@@ -403,6 +404,7 @@ pub async fn burn_ass_subtitle(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output.clone(),
@@ -424,6 +426,7 @@ pub async fn burn_ass_subtitle(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output.clone(),
@@ -445,6 +448,7 @@ pub async fn burn_ass_subtitle(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output,
@@ -485,6 +489,7 @@ pub async fn burn_ass_subtitle_with_fonts(
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "medium".to_string(),
"-crf".to_string(), "23".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-c:a".to_string(), "copy".to_string(),
"-y".to_string(),
safe_output
@@ -493,6 +498,53 @@ pub async fn burn_ass_subtitle_with_fonts(
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 混合背景音乐到视频(保留原视频音频)
*
* 使用 FFmpeg amix 滤镜将 BGM 与原视频音频混合。
* 如果 BGM 短于视频,会自动循环;如果长于视频,会截断到视频长度。
*
* 注意:bgm_path 来自应用资源目录,不做 validate_safe_path 检查。
*/
pub async fn mix_bgm_to_video(
app: &AppHandle,
video_path: &str,
bgm_path: &str,
output_path: &str,
video_volume: f64,
bgm_volume: f64,
) -> Result<(), String> {
let safe_video = validate_safe_path(video_path)?;
// BGM 来自应用资源目录,直接传递(路径由前端通过 Tauri path API 解析)
let safe_bgm = bgm_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
// 构建 filter_complex:
// [0:a]volume=1.0[a0]; — 原视频音频调整音量
// [1:a]volume=0.25,aloop=loop=-1:size=2e+09[bgm]; — BGM 调整音量后无限循环
// [a0][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout] — 混合,以第一个输入时长为准
let filter_complex = format!(
"[0:a]volume={:.2}[a0];[1:a]volume={:.2},aloop=loop=-1:size=2e+09[bgm];[a0][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout]",
video_volume, bgm_volume
);
let args = vec![
"-i".to_string(), safe_video,
"-i".to_string(), safe_bgm,
"-filter_complex".to_string(), filter_complex,
"-map".to_string(), "0:v:0".to_string(),
"-map".to_string(), "[aout]".to_string(),
"-c:v".to_string(), "copy".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-b:a".to_string(), "192k".to_string(),
"-ar".to_string(), "44100".to_string(),
"-ac".to_string(), "2".to_string(),
"-y".to_string(),
safe_output,
];
run_ffmpeg(app, args).await.map(|_| ())
}
/**
* 音频替换 — 用配音音频替换视频中的原音
*
+1
View File
@@ -322,6 +322,7 @@ pub fn run() {
commands::video_compose::generate_empty_shot_clip,
commands::video_compose::upload_video_file,
commands::video_compose::download_file,
commands::video_compose::mix_bgm_to_video,
// 视频元数据读取(ffprobe
get_video_metadata_cmd,
// 视频预览转码(统一浏览器兼容格式)
+32
View File
@@ -0,0 +1,32 @@
import { client } from '../client';
export interface BgmMusicItem {
id: number;
title: string;
artist: string | null;
category: string;
filePath: string;
url: string | null;
duration: number | null;
sortOrder: number;
}
export interface BgmMusicListResponse {
items: BgmMusicItem[];
total: number;
}
/**
* BGM 音乐 API
*/
export const bgmMusicApi = {
/**
* 获取背景音乐列表
* @param category 场景分类筛选(可选)
*/
list: async (category?: string): Promise<BgmMusicItem[]> => {
const path = category ? `/bgm-musics?category=${encodeURIComponent(category)}` : '/bgm-musics';
const response = await client.get<{ items: BgmMusicItem[]; total: number }>(path);
return response.items || [];
},
};
+95 -80
View File
@@ -99,16 +99,24 @@ function wrapTextByWidth(
): string[] {
if (!text.trim()) {return [];}
// 把中英文逗号、顿号替换为换行(语义断句)
text = text
.replace(//g, '\n')
.replace(/,/g, '\n')
.replace(/、/g, '\n');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
ctx.font = `${fontSize}px ${FONT_FAMILY}`;
// 先按用户手动换行分割,再对每行按宽度自动换行
// 换行分割,再对每行按宽度自动换行
const paragraphs = text.split('\n');
const lines: string[] = [];
for (const paragraph of paragraphs) {
const chars = paragraph.split('');
const trimmed = paragraph.trim();
if (!trimmed) {continue;}
const chars = trimmed.split('');
let currentLine = '';
for (const char of chars) {
@@ -221,43 +229,41 @@ export function useCoverFabric() {
// 加载封面形象(叠加在背景之上,文字之下)
const loadAvatarImage = useCallback(
async (canvas: Canvas, imageUrl: string): Promise<void> => {
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = (e) => reject(e);
image.src = imageUrl;
});
async (canvas: Canvas, imageUrl: string): Promise<number> => {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = (e) => reject(e);
image.src = imageUrl;
});
const fabricImg = new FabricImage(img);
// 计算缩放:宽度不超过画布的 70%,高度不超过画布的 40%
const maxWidth = CANVAS_WIDTH * 0.7;
const maxHeight = CANVAS_HEIGHT * 0.4;
const scale = Math.min(
maxWidth / (fabricImg.width || 1),
maxHeight / (fabricImg.height || 1),
1 // 不放大原图
);
fabricImg.scale(scale);
const fabricImg = new FabricImage(img);
// 计算缩放:宽度/高度最大占画布的 68%
const maxWidth = CANVAS_WIDTH * 0.68;
const maxHeight = CANVAS_HEIGHT * 0.68;
const scale = Math.min(
maxWidth / (fabricImg.width || 1),
maxHeight / (fabricImg.height || 1)
);
fabricImg.scale(scale);
const scaledWidth = (fabricImg.width || 1) * scale;
const scaledHeight = (fabricImg.height || 1) * scale;
// 居中偏下定位(约 55% 处)
fabricImg.set({
left: (CANVAS_WIDTH - scaledWidth) / 2,
top: CANVAS_HEIGHT * 0.55,
originX: 'left',
originY: 'top',
selectable: false,
evented: false,
});
canvas.add(fabricImg);
canvas.renderAll();
} catch {
// no-op
}
// 左下区域定位:左侧留边,底部与背景对齐
const leftMargin = 40;
fabricImg.set({
left: leftMargin,
top: CANVAS_HEIGHT - scaledHeight,
originX: 'left',
originY: 'top',
selectable: false,
evented: false,
});
canvas.add(fabricImg);
canvas.renderAll();
return scaledHeight;
},
[]
);
@@ -285,9 +291,11 @@ export function useCoverFabric() {
}
// 2. 封面形象(叠加在背景之上)
let avatarTop = CANVAS_HEIGHT;
if (config.avatarImage) {
try {
await loadAvatarImage(canvas, config.avatarImage);
const scaledHeight = await loadAvatarImage(canvas, config.avatarImage);
avatarTop = CANVAS_HEIGHT - scaledHeight;
} catch {
// no-op: 封面形象加载失败不影响主流程
}
@@ -295,52 +303,38 @@ export function useCoverFabric() {
const template = TEMPLATES[config.template];
// 3. 主标题(自动换行,最多2行,居中)
if (config.mainTitle.trim()) {
const maxWidth = CANVAS_WIDTH - 120; // 左右留边
const lines = wrapTextByWidth(
config.mainTitle.trim(),
maxWidth,
template.mainTitle.fontSize
);
const displayLines = lines.slice(0, 2); // 最多2行
const lineHeight = template.mainTitle.fontSize * 1.2;
// 预计算主副标题行数和高度,用于动态定位
const mainTitleLines = config.mainTitle.trim()
? wrapTextByWidth(config.mainTitle.trim(), CANVAS_WIDTH - 120, template.mainTitle.fontSize).slice(0, 2)
: [];
const subtitleLines = config.subtitle.trim()
? wrapTextByWidth(config.subtitle.trim(), CANVAS_WIDTH - 120, template.subtitle.fontSize).slice(0, 2)
: [];
displayLines.forEach((line, i) => {
const mainTitleLineHeight = template.mainTitle.fontSize * 1.2;
const subtitleLineHeight = template.subtitle.fontSize * 1.5;
const mainTitleHeight = mainTitleLines.length * mainTitleLineHeight;
const subtitleHeight = subtitleLines.length * subtitleLineHeight;
// 间距配置
const gapAvatarToMain = 50; // 人物与主标题间距
const gapMainToSub = 24; // 主标题与副标题间距
// 从人物头顶往上计算文字位置
// 主标题底部 = 人物顶部 - 间距
const mainTitleBottom = avatarTop - gapAvatarToMain;
const mainTitleTop = mainTitleBottom - mainTitleHeight;
// 副标题底部 = 主标题顶部 - 间距
const subtitleBottom = mainTitleTop - gapMainToSub;
const subtitleTop = subtitleBottom - subtitleHeight;
// 3. 副标题(放在人物上方,主标题上方)
if (subtitleLines.length > 0) {
subtitleLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top: template.mainTitle.top + i * lineHeight,
fontSize: template.mainTitle.fontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: FONT_FAMILY,
textAlign: 'center',
originX: 'center',
originY: 'top',
selectable: false,
evented: false,
shadow: template.mainTitle.shadow,
});
canvas.add(text);
});
}
// 4. 副标题(底部居中,加粗)
if (config.subtitle.trim()) {
const maxWidth = CANVAS_WIDTH - 120;
const lines = wrapTextByWidth(
config.subtitle.trim(),
maxWidth,
template.subtitle.fontSize
);
const displayLines = lines.slice(0, 2);
const lineHeight = template.subtitle.fontSize * 1.5;
const totalHeight = displayLines.length * lineHeight;
displayLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top: template.subtitle.top - totalHeight / 2 + i * lineHeight,
top: subtitleTop + i * subtitleLineHeight,
fontSize: template.subtitle.fontSize,
fill: template.subtitle.fill,
fontWeight: 'bold',
@@ -359,6 +353,27 @@ export function useCoverFabric() {
});
}
// 4. 主标题(放在人物上方,副标题下方)
if (mainTitleLines.length > 0) {
mainTitleLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top: mainTitleTop + i * mainTitleLineHeight,
fontSize: template.mainTitle.fontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: FONT_FAMILY,
textAlign: 'center',
originX: 'center',
originY: 'top',
selectable: false,
evented: false,
shadow: template.mainTitle.shadow,
});
canvas.add(text);
});
}
canvas.renderAll();
},
[loadBackground]
+142 -48
View File
@@ -187,18 +187,6 @@
font-weight: 600;
}
/* 生成按钮 */
.cover-generate-btn {
width: 100%;
font-size: var(--font-md);
font-weight: 600;
margin-top: var(--spacing-md);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
}
/* 右侧预览区 - 与视频生成页面统一 */
.video-gen-right {
display: flex;
@@ -377,7 +365,7 @@
min-height: 64px;
resize: none;
line-height: 1.5;
padding: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
}
/* 智能生成按钮 */
@@ -392,49 +380,101 @@
/* 视觉素材横向卡片 */
.visual-assets-row {
display: flex;
gap: var(--spacing-md);
gap: var(--spacing-2xl);
justify-content: center;
flex-wrap: wrap;
}
.visual-asset-card {
flex: 0 0 auto;
width: 250px;
width: 200px;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
align-items: stretch;
border-radius: 14px;
background: #fff;
cursor: pointer;
transition: all var(--transition-fast);
transition: all 0.2s ease;
position: relative;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.visual-asset-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.14);
transform: translateY(-2px);
}
.visual-asset-label {
font-size: var(--font-xs);
font-weight: 500;
color: var(--text-secondary);
.visual-asset-card:hover .visual-asset-overlay {
opacity: 1;
}
.visual-asset-image-wrap {
position: relative;
width: 100%;
aspect-ratio: 9 / 16;
overflow: hidden;
border-radius: 10px;
margin: 8px;
width: calc(100% - 16px);
}
.visual-asset-card img {
width: 100%;
aspect-ratio: 9 / 16;
height: 100%;
object-fit: cover;
border-radius: var(--radius-sm);
display: block;
border-radius: 8px;
transition: transform 0.3s ease;
}
.visual-asset-card:hover img {
transform: scale(1.03);
}
.visual-asset-label {
position: absolute;
top: 8px;
left: 8px;
font-size: 10px;
font-weight: 600;
color: #fff;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
padding: 3px 8px;
border-radius: 6px;
letter-spacing: 0.4px;
pointer-events: none;
z-index: 2;
}
.visual-asset-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.35);
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 8px;
pointer-events: none;
}
.visual-asset-overlay-text {
color: #fff;
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.visual-asset-placeholder {
width: 100%;
aspect-ratio: 9 / 16;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
@@ -442,19 +482,16 @@
gap: 6px;
color: var(--text-tertiary);
font-size: var(--font-xs);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
border-radius: 8px;
}
.visual-asset-hint {
font-size: 11px;
color: var(--text-tertiary);
}
/* .visual-asset-hint removed - using hover overlay instead */
/* 弹窗背景图网格 */
.modal-bg-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-sm);
}
@@ -465,7 +502,7 @@
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
background: none;
background: var(--bg-secondary);
aspect-ratio: 9 / 16;
transition: all var(--transition-fast);
}
@@ -480,6 +517,22 @@
box-shadow: 0 0 0 3px var(--primary-light);
}
/* 选中态勾选标记 */
.modal-bg-thumb.active::after {
content: '';
position: absolute;
top: var(--spacing-sm);
right: var(--spacing-sm);
width: 20px;
height: 20px;
background: var(--primary);
border-radius: var(--radius-full);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
box-shadow: var(--shadow-sm);
}
.modal-bg-thumb img {
width: 100%;
height: 100%;
@@ -502,7 +555,7 @@
overflow: hidden;
cursor: pointer;
background: var(--bg-secondary);
aspect-ratio: 1;
aspect-ratio: 9 / 16;
transition: all var(--transition-fast);
}
@@ -533,19 +586,60 @@
color: #999;
}
/* 加载失败 */
.modal-bg-error {
background: var(--bg-secondary, #f5f5f5);
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
}
.modal-bg-error::after {
content: '图片加载失败';
font-size: 12px;
color: #999;
padding: 0 8px;
text-align: center;
content: '加载失败';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-xs);
color: var(--text-tertiary);
pointer-events: none;
}
/* 本地上传卡片 */
.modal-bg-upload {
border-style: dashed;
border-color: var(--border-color);
background: transparent;
}
.modal-bg-upload:hover {
border-style: solid;
border-color: var(--primary);
background: var(--primary-light);
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
width: 100%;
height: 100%;
color: var(--text-secondary);
font-size: var(--font-sm);
pointer-events: none;
}
.upload-placeholder svg {
color: var(--text-tertiary);
transition: color var(--transition-fast);
}
.modal-bg-upload:hover .upload-placeholder {
color: var(--primary);
}
.modal-bg-upload:hover .upload-placeholder svg {
color: var(--primary);
}
.avatar-thumb-error {
+182 -113
View File
@@ -9,9 +9,10 @@
* - PNG
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useCoverAvatarStore } from '../../store';
import { invoke } from '@tauri-apps/api/core';
import { save } from '@tauri-apps/plugin-dialog';
import { useProjectStore } from '../../store';
import { getCurrentProjectId } from '../../api/modules/localStorage';
import { useCoverFabric } from '../../hooks/useCoverFabric';
@@ -43,20 +44,20 @@ function shuffleArray<T>(arr: T[]): T[] {
return result;
}
function pickSix(all: BgImage[], lastIds: Set<string>): BgImage[] {
if (all.length <= 6) {return shuffleArray(all);}
function pickSeven(all: BgImage[], lastIds: Set<string>): BgImage[] {
if (all.length <= 7) {return shuffleArray(all);}
let shuffled = shuffleArray(all);
for (let i = 0; i < 10; i++) {
const picked = shuffled.slice(0, 6);
const picked = shuffled.slice(0, 7);
const pickedIds = new Set(picked.map((b) => b.id));
const allSame =
pickedIds.size === 6 &&
lastIds.size === 6 &&
pickedIds.size === 7 &&
lastIds.size === 7 &&
[...pickedIds].every((id) => lastIds.has(id));
if (!allSame) {return picked;}
shuffled = shuffleArray(all);
}
return shuffled.slice(0, 6);
return shuffled.slice(0, 7);
}
export default function CoverDesign() {
@@ -94,11 +95,8 @@ export default function CoverDesign() {
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
// 背景图配置
const categoryCode = useProjectStore((state) => state.categoryCode);
const [bgList, setBgList] = useState<BgImage[]>([]);
const [modalBgs, setModalBgs] = useState<BgImage[]>([]);
const [lastModalBgIds, setLastModalBgIds] = useState<Set<string>>(new Set());
const [, setBgLoadStatus] = useState<'idle' | 'loading' | 'empty' | 'error' | 'no_category'>('idle');
const { canvasRef, initCanvas, renderCover, exportPng } = useCoverFabric();
@@ -152,42 +150,47 @@ export default function CoverDesign() {
}
};
// 加载背景图
// 加载背景图 — 系统图库 + 本地上传
const localBgRef = useRef<HTMLInputElement | null>(null);
const [bgList, setBgList] = useState<BgImage[]>([]);
useEffect(() => {
const loadBackgrounds = async () => {
if (!categoryCode) {
setBgLoadStatus('no_category');
return;
}
setBgLoadStatus('loading');
try {
const backgrounds = await coverApi.getBackgrounds(categoryCode);
const backgrounds = await coverApi.getBackgrounds('bk');
const list: BgImage[] = backgrounds.map((bg: CoverBackground) => ({
id: String(bg.id),
src: bg.url,
name: bg.title || '背景图',
}));
setBgList(list);
if (list.length === 0) {
setBgLoadStatus('empty');
return;
}
// 初始随机选一张作为默认背景
const defaultBg = list[Math.floor(Math.random() * list.length)];
setConfig(prev => ({ ...prev, backgroundImage: defaultBg.src }));
setBgLoadStatus('idle');
} catch (err) {
console.error('[CoverDesign] 加载背景图失败:', err);
setBgLoadStatus('error');
}
};
loadBackgrounds();
}, [categoryCode]);
}, []);
// 弹窗展示 6 张背景图
// 本地上传背景图
const handleLocalBgUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const path = (file as any).path || (file as any).webkitRelativePath || '';
if (!path) {
// Tauri 文件选择器通常有 path,如果没有则使用 Object URL
const url = URL.createObjectURL(file);
setConfig(prev => ({ ...prev, backgroundImage: url }));
} else {
setConfig(prev => ({ ...prev, backgroundImage: path }));
}
setBgModalOpen(false);
// 清空 input 以便下次选择同一文件
e.target.value = '';
};
// 弹窗展示 7 张背景图 + 1 个上传入口
const openBgModal = () => {
if (bgList.length === 0) {return;}
const picked = pickSix(bgList, lastModalBgIds);
const picked = pickSeven(bgList, lastModalBgIds);
setModalBgs(picked);
setLastModalBgIds(new Set(picked.map(b => b.id)));
setBgModalOpen(true);
@@ -195,7 +198,7 @@ export default function CoverDesign() {
// 弹窗换一组
const shuffleModalBgs = () => {
const picked = pickSix(bgList, lastModalBgIds);
const picked = pickSeven(bgList, lastModalBgIds);
setModalBgs(picked);
setLastModalBgIds(new Set(picked.map(b => b.id)));
};
@@ -356,48 +359,61 @@ export default function CoverDesign() {
open={bgModalOpen}
onClose={() => setBgModalOpen(false)}
title="选择背景图片"
width="560px"
width="440px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{modalBgs.length > 0 ? (
<div className="modal-bg-grid">
{modalBgs.map((bg) => (
<button
key={bg.id}
className={`modal-bg-thumb ${config.backgroundImage === bg.src ? 'active' : ''}`}
onClick={() => {
setConfig((prev) => ({ ...prev, backgroundImage: bg.src }));
setBgModalOpen(false);
<div className="modal-bg-grid">
{/* 第 1 个卡片:本地上传 */}
<button
className="modal-bg-thumb modal-bg-upload"
onClick={() => localBgRef.current?.click()}
title="使用本地图片作为背景"
>
<div className="upload-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17,8 12,3 7,8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span></span>
</div>
</button>
<input
ref={localBgRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleLocalBgUpload}
/>
{modalBgs.map((bg) => (
<button
key={bg.id}
className={`modal-bg-thumb ${config.backgroundImage === bg.src ? 'active' : ''}`}
onClick={() => {
setConfig((prev) => ({ ...prev, backgroundImage: bg.src }));
setBgModalOpen(false);
}}
title={bg.name}
>
<img
src={bg.src}
alt={bg.name}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {parent.classList.add('modal-bg-error');}
}}
title={bg.name}
>
<img
src={bg.src}
alt={bg.name}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {parent.classList.add('modal-bg-error');}
}}
/>
</button>
))}
</div>
) : (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-secondary)' }}>
</div>
)}
/>
</button>
))}
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button
className="btn btn-secondary btn-sm"
onClick={shuffleModalBgs}
disabled={bgList.length <= 6}
disabled={bgList.length <= 3}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: 4, verticalAlign: 'middle' }}>
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setBgModalOpen(false)}></button>
@@ -480,26 +496,36 @@ export default function CoverDesign() {
onClick={openBgModal}
title="点击更换背景"
>
<div className="visual-asset-label"></div>
{config.backgroundImage ? (
<img
src={config.backgroundImage}
alt="背景"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="visual-asset-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span></span>
<div className="visual-asset-image-wrap">
<div className="visual-asset-label"></div>
{config.backgroundImage ? (
<img
src={config.backgroundImage}
alt="背景"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="visual-asset-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span></span>
</div>
)}
<div className="visual-asset-overlay">
<span className="visual-asset-overlay-text">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</span>
</div>
)}
<div className="visual-asset-hint"></div>
</div>
</button>
{/* 封面形象卡片 */}
@@ -508,25 +534,35 @@ export default function CoverDesign() {
onClick={() => setAvatarModalOpen(true)}
title="点击更换形象"
>
<div className="visual-asset-label"></div>
{currentAvatar ? (
<img
src={currentAvatar.imageUrl}
alt={currentAvatar.name}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="visual-asset-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>使</span>
<div className="visual-asset-image-wrap">
<div className="visual-asset-label"></div>
{currentAvatar ? (
<img
src={currentAvatar.imageUrl}
alt={currentAvatar.name}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="visual-asset-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span></span>
</div>
)}
<div className="visual-asset-overlay">
<span className="visual-asset-overlay-text">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</span>
</div>
)}
<div className="visual-asset-hint"></div>
</div>
</button>
</div>
</div>
@@ -538,7 +574,7 @@ export default function CoverDesign() {
<input
type="text"
className="input"
placeholder="输入封面主标题文案"
placeholder="输入主标题"
value={config.mainTitle}
maxLength={6}
onChange={(e) =>
@@ -557,7 +593,7 @@ export default function CoverDesign() {
<div className="title-input-row" style={{ marginTop: 8 }}>
<textarea
className="input subtitle-textarea"
placeholder="输入底部文案"
placeholder="输入副标题"
value={config.subtitle}
maxLength={52}
rows={2}
@@ -577,15 +613,48 @@ export default function CoverDesign() {
</div>
</div>
{/* 生成按钮 */}
<button
className="btn btn-primary cover-generate-btn"
disabled={isDesigning || !canGenerate}
onClick={handleGenerate}
style={{ marginTop: 'auto', flexShrink: 0 }}
>
{coverPath ? '重新设计封面' : '立即设计封面'}{usePointStore.getState().getRule('cover_design')?.points || 2}
</button>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 12, marginTop: 'auto', flexShrink: 0 }}>
<button
className="btn btn-primary"
disabled={isDesigning || !canGenerate}
onClick={handleGenerate}
style={{ flex: 1 }}
>
{isDesigning ? '生成中...' : `重新生成(${usePointStore.getState().getRule('cover_design')?.points || 2}积分)`}
</button>
<button
className="btn btn-secondary"
onClick={async () => {
if (!coverPath) {
toast.info('请先生成封面');
return;
}
try {
const filename = coverPath.split(/[\\/]/).pop() || 'cover.png';
const targetPath = await save({
defaultPath: filename,
filters: [{ name: '图片', extensions: ['png'] }],
});
if (!targetPath) {return;}
const res = await invoke<{ code: number; data?: string; message: string }>('export_product', {
sourcePath: coverPath,
targetPath,
});
if (res.code === 200) {
toast.success('封面导出成功');
} else {
toast.error(res.message || '导出失败');
}
} catch (e) {
toast.error('导出失败');
}
}}
style={{ flex: 1 }}
>
</button>
</div>
</div>
{/* 右侧:竖屏预览 */}
@@ -377,18 +377,6 @@ export default function ScriptCreation() {
onClick={handleGenerate}
disabled={generating}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
{generated ? `重新生成脚本(${usePointStore.getState().getRule('script')?.points || 5} 积分)` : `生成脚本(${usePointStore.getState().getRule('script')?.points || 5} 积分)`}
</button>
@@ -629,11 +629,11 @@ export default function SubtitleBurning() {
{usePointStore.getState().getRule('subtitle_burn')?.points || 2}
</button>
<button
className={`btn burn-btn ${previewMode === 'result' ? 'btn-primary' : 'btn-secondary'}`}
className="btn btn-secondary burn-btn"
onClick={() => setPreviewMode('result')}
disabled={!burnedVideoUrl}
>
{previewMode === 'result' ? '效果预览中' : '效果预览'}
{previewMode === 'result' ? '视频预览中...' : '视频预览'}
</button>
</div>
) : (
@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { save } from '@tauri-apps/plugin-dialog';
import { compositeApi } from '../../api/modules/videoComposite';
import { bgmMusicApi, type BgmMusicItem } from '../../api/modules/bgmMusic';
import { pointsApi } from '../../api/modules/points';
import { useProjectStore, saveMetaToLocalFile } from '../../store';
import { getCurrentProjectId } from '../../api/modules/localStorage';
@@ -12,11 +13,20 @@ import { useProgressStore } from '../../store/progressStore';
import { useLocalVideo } from '../../hooks/useLocalVideo';
import { toast } from '../../store/uiStore';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import Modal from '../../components/Modal/Modal';
import RechargeModal from '../../components/RechargeModal/RechargeModal';
import './VideoCreation.css';
import './VideoGeneration.css';
const CATEGORY_LABELS: Record<string, string> = {
knowledge: '知识科普',
showcase: '案例展示',
promotion: '促销活动',
lifestyle: '家居生活',
tech: '智能家居',
};
export default function VideoCompose() {
const segments = useProjectStore(state => state.segments);
const coverPath = useProjectStore(state => state.coverPath);
@@ -36,6 +46,18 @@ export default function VideoCompose() {
const progress = useProgressStore(state => state.progress);
const [resultPath, setResultPath] = useState(finalVideoPath || '');
// BGM
const [bgmList, setBgmList] = useState<BgmMusicItem[]>([]);
const bgmMusicId = useProjectStore(state => state.bgmMusicId);
const bgmMusicPath = useProjectStore(state => state.bgmMusicPath);
const bgmMusicTitle = useProjectStore(state => state.bgmMusicTitle);
const bgmVolume = useProjectStore(state => state.bgmVolume);
const setBgmMusic = useProjectStore(state => state.setBgmMusic);
const setBgmVolume = useProjectStore(state => state.setBgmVolume);
const [, setBgmLoading] = useState(false);
const [bgmModalOpen, setBgmModalOpen] = useState(false);
const localBgmRef = useRef<HTMLInputElement | null>(null);
// 积分不足弹窗
const [showPointsModal, setShowPointsModal] = useState(false);
const showRechargeModal = usePointStore((state) => state.showRechargeModal);
@@ -48,6 +70,36 @@ export default function VideoCompose() {
setResultPath(finalVideoPath || '');
}, [finalVideoPath]);
// 加载 BGM 列表
useEffect(() => {
let canceled = false;
setBgmLoading(true);
bgmMusicApi.list().then(list => {
if (!canceled) {
setBgmList(list);
}
}).catch(err => {
console.error('加载 BGM 列表失败:', err);
}).finally(() => {
if (!canceled) {
setBgmLoading(false);
}
});
return () => { canceled = true; };
}, []);
// 本地上传 BGM
const handleLocalBgmUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const path = (file as any).path || (file as any).webkitRelativePath || '';
if (path) {
setBgmMusic(-1, file.name, path);
}
setBgmModalOpen(false);
e.target.value = '';
};
const shots = segments || [];
const hasResult = done && !!resultPath;
const { videoUrl: previewUrl } = useLocalVideo(hasResult ? resultPath : undefined);
@@ -139,12 +191,28 @@ export default function VideoCompose() {
const resolvedCoverPath = coverPath;
// 3. 调用 Rust 合成,直接输出到 products 目录
const result = await compositeApi.synthesis({
let result = await compositeApi.synthesis({
video_paths: videoPaths,
cover_path: resolvedCoverPath,
output_path: outputPath,
});
// 4. 如果选择了 BGM,混合背景音乐
if (bgmMusicPath) {
useProgressStore.getState().update('正在混合背景音乐...');
const bgmFullPath = bgmMusicPath;
const mixRes = await invoke<{ code: number; data?: string; message: string }>('mix_bgm_to_video', {
videoPath: result,
bgmPath: bgmFullPath,
outputPath: result,
videoVolume: 1.0,
bgmVolume: (bgmVolume ?? 0.25),
});
if (mixRes.code !== 200) {
console.warn('BGM 混合失败,使用无 BGM 版本:', mixRes.message);
}
}
setResultPath(result);
setFinalVideoPath(result);
setExportedAt(Date.now());
@@ -218,6 +286,95 @@ export default function VideoCompose() {
}}
>
<label className="panel-label"></label>
{/* BGM 选择 */}
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<label style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)', marginBottom: 'var(--spacing-xs)', display: 'block' }}>
</label>
{bgmMusicId ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 'var(--spacing-sm) var(--spacing-md)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
background: 'var(--bg-secondary)',
cursor: 'pointer',
}}
onClick={() => setBgmModalOpen(true)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
background: 'var(--primary-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--primary)',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
<div>
<div style={{ fontSize: 'var(--font-sm)', fontWeight: 500 }}>{bgmMusicTitle || '背景音乐'}</div>
<div style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)' }}>
{Math.round((bgmVolume ?? 0.25) * 100)}%
</div>
</div>
</div>
<button className="btn btn-ghost btn-sm" onClick={e => { e.stopPropagation(); setBgmModalOpen(true); }}>
</button>
</div>
) : (
<div
style={{
padding: 'var(--spacing-md)',
border: '1px dashed var(--border-color)',
borderRadius: '8px',
textAlign: 'center',
color: 'var(--text-secondary)',
fontSize: 'var(--font-sm)',
cursor: 'pointer',
}}
onClick={() => setBgmModalOpen(true)}
>
<p></p>
<p style={{ marginTop: '4px', fontSize: 'var(--font-xs)', opacity: 0.7 }}>
</p>
</div>
)}
{bgmMusicId && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginTop: 'var(--spacing-xs)' }}>
<span style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
BGM
</span>
<input
type="range"
min="0"
max="1"
step="0.05"
value={bgmVolume ?? 0.25}
onChange={e => setBgmVolume(parseFloat(e.target.value))}
disabled={compositing}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)', minWidth: '36px', textAlign: 'right' }}>
{Math.round(((bgmVolume ?? 0.25)) * 100)}%
</span>
</div>
)}
</div>
<div className="script-editor" style={{ flex: 1, overflowY: 'auto', paddingRight: '4px' }}>
{shots.length === 0 ? (
<div
@@ -336,18 +493,6 @@ export default function VideoCompose() {
onClick={handleStart}
disabled={compositing || !coverPath || !burnedVideoPath}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
{usePointStore.getState().getRule('compose')?.points || 5}
</button>
) : (
@@ -355,6 +500,14 @@ export default function VideoCompose() {
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={handleStart}
disabled={compositing || !coverPath || !burnedVideoPath}
>
{usePointStore.getState().getRule('compose')?.points || 5}
</button>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={async () => {
if (!resultPath) {return;}
try {
@@ -378,22 +531,8 @@ export default function VideoCompose() {
}
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={handleStart}
disabled={compositing || !coverPath || !burnedVideoPath}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{usePointStore.getState().getRule('compose')?.points || 5}
</button>
</div>
)}
</div>
@@ -453,6 +592,62 @@ export default function VideoCompose() {
onClose={() => setShowRechargeModal(false)}
onRechargeSuccess={() => { fetchBalance(); setShowPointsModal(false); }}
/>
{/* BGM 选择弹窗 */}
<Modal
open={bgmModalOpen}
onClose={() => setBgmModalOpen(false)}
title="选择背景音乐"
width="440px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="modal-bgm-grid">
{/* 本地上传 */}
<button
className="modal-bgm-card modal-bgm-upload"
onClick={() => localBgmRef.current?.click()}
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17,8 12,3 7,8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span></span>
</button>
<input
ref={localBgmRef}
type="file"
accept="audio/*"
style={{ display: 'none' }}
onChange={handleLocalBgmUpload}
/>
{/* 系统 BGM */}
{bgmList.map((item) => (
<button
key={item.id}
className={`modal-bgm-card ${bgmMusicId === item.id ? 'active' : ''}`}
onClick={() => {
setBgmMusic(item.id, item.title, item.url || item.filePath);
setBgmModalOpen(false);
}}
>
<div className="modal-bgm-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
<div className="modal-bgm-title">{item.title}</div>
<div className="modal-bgm-category">{CATEGORY_LABELS[item.category] || item.category}</div>
</button>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setBgmModalOpen(false)}></button>
</div>
</div>
</Modal>
</div>
</>
);
@@ -419,3 +419,79 @@
transform: rotate(360deg);
}
}
/* ============================================
BGM 选择弹窗
============================================ */
.modal-bgm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-sm);
max-height: 320px;
overflow-y: auto;
}
.modal-bgm-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: var(--spacing-md);
border: 2px solid transparent;
border-radius: var(--radius-md);
background: var(--bg-secondary);
cursor: pointer;
aspect-ratio: 1;
transition: all var(--transition-fast);
}
.modal-bgm-card:hover {
border-color: var(--border-color);
transform: scale(1.02);
}
.modal-bgm-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.modal-bgm-upload {
color: var(--text-tertiary);
}
.modal-bgm-upload:hover {
color: var(--primary);
border-color: var(--primary);
}
.modal-bgm-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary-light);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
}
.modal-bgm-title {
font-size: var(--font-xs);
font-weight: 500;
color: var(--text-primary);
text-align: center;
line-height: 1.3;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-bgm-category {
font-size: 10px;
color: var(--text-tertiary);
text-align: center;
}
@@ -97,9 +97,9 @@ export default function VoiceSynthesis() {
const s = validSegments[i];
const next = validSegments[i + 1];
if (s.type !== next?.type) {
pauseSeconds += 0.5; // segment ↔ empty_shot 停顿
pauseSeconds += 0.1; // segment ↔ empty_shot 轻微停顿
} else {
pauseSeconds += 0.3; // 同类型短停顿
pauseSeconds += 0.05; // 同类型短停顿
}
}
@@ -292,12 +292,12 @@ export default function VoiceSynthesis() {
const text = s.voiceover!.trim();
if (i === arr.length - 1) {return text;}
const next = arr[i + 1];
// segment ↔ empty_shot 切换:停顿,让观众看清画面
// segment ↔ empty_shot 切换:轻微停顿,让观众看清画面
if (s.type !== next?.type) {
return text + '<#0.5#>';
return text + '<#0.1#>';
}
// 同类型之间(segment→segment / empty_shot→empty_shot):短停顿,保持节奏
return text + '<#0.3#>';
// 同类型之间(segment→segment / empty_shot→empty_shot):短停顿,保持节奏
return text + '<#0.05#>';
})
.join('\n');
if (!realText) { toast.warning('没有需要合成的旁白文本'); return; }
@@ -586,7 +586,7 @@ export default function VoiceSynthesis() {
className="btn btn-primary generate-btn"
onClick={handleToggleGeneratedAudio}
>
{isPlayingGenerated ? '暂停播放' : '试听播放'}
{isPlayingGenerated ? '暂停播放' : '试听播放'}
</button>
</div>
)}
@@ -3,6 +3,7 @@ interface GenerationControlsProps {
isComposing: boolean;
videoMultiplierLoaded: boolean;
estimatedVideoPoints: number;
isPreviewing: boolean;
onGenerate: () => void;
onPreview: () => void;
}
@@ -17,21 +18,10 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
isComposing,
videoMultiplierLoaded,
estimatedVideoPoints,
isPreviewing,
onGenerate,
onPreview,
}) => {
const generateIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
);
const refreshIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" />
</svg>
);
const isDisabled = isComposing || !videoMultiplierLoaded;
if (composedVideoPath) {
@@ -43,7 +33,6 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
onClick={onGenerate}
disabled={isDisabled}
>
{refreshIcon}
{estimatedVideoPoints}
</button>
<button
@@ -51,8 +40,7 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
style={{ flex: 1, marginTop: 0, flexShrink: 0 }}
onClick={onPreview}
>
{generateIcon}
{isPreviewing ? '视频预览中...' : '视频预览'}
</button>
</div>
);
@@ -66,7 +54,6 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
onClick={onGenerate}
disabled={isDisabled}
>
{generateIcon}
{estimatedVideoPoints}
</button>
</div>
@@ -385,6 +385,7 @@ export default function VideoGeneration() {
isComposing={isComposing}
videoMultiplierLoaded={videoMultiplierLoaded}
estimatedVideoPoints={estimatedVideoPoints}
isPreviewing={isComposedPreview}
onGenerate={handleGenerate}
onPreview={() => setIsComposedPreview(true)}
/>
+18
View File
@@ -31,6 +31,8 @@ interface ProjectActions {
setVoiceSpeed: (_speed: number) => void;
setVoiceVolume: (_volume: number) => void;
setVoicePitch: (_pitch: number) => void;
setBgmMusic: (_id: number | undefined, _title: string | undefined, _path: string | undefined) => void;
setBgmVolume: (_volume: number) => void;
setCategoryCode: (_code: string) => void;
setSubcategoryCode: (_code: string) => void;
setIsLoading: (_loading: boolean) => void;
@@ -235,6 +237,22 @@ export const useProjectStore = create<ProjectStore>()(
state.voicePitch = pitch;
state.updatedAt = Date.now();
}),
setBgmMusic: (id, title, path) => {
set(state => {
state.bgmMusicId = id;
state.bgmMusicTitle = title;
state.bgmMusicPath = path;
state.updatedAt = Date.now();
});
saveMetaToLocalFile({ bgmMusicId: id, bgmMusicTitle: title, bgmMusicPath: path });
},
setBgmVolume: volume => {
set(state => {
state.bgmVolume = volume;
state.updatedAt = Date.now();
});
saveMetaToLocalFile({ bgmVolume: volume });
},
setCategoryCode: code => {
set(state => {
state.categoryCode = code;
+6
View File
@@ -121,6 +121,12 @@ export interface ProjectMeta {
coverPath?: string;
coverConfig?: CoverConfig;
// === BGM ===
bgmMusicId?: number;
bgmMusicTitle?: string;
bgmMusicPath?: string;
bgmVolume?: number;
// === 成品 ===
finalVideoPath?: string;
finalVideoDuration?: number;
+4
View File
@@ -167,6 +167,10 @@ export const BLANK_META_OVERRIDES: MetaOverrides = {
captionPreset: undefined,
userUploadedMaterials: undefined,
stepDirtyFlags: undefined,
bgmMusicId: undefined,
bgmMusicTitle: undefined,
bgmMusicPath: undefined,
bgmVolume: undefined,
};