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:
@@ -30,3 +30,4 @@ tauri-app/src-tauri/binaries/*
|
||||
*.key
|
||||
*test*.key*
|
||||
.atomcode/
|
||||
mixkit_bgm/
|
||||
|
||||
@@ -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`
|
||||
@@ -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 ###
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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="排序权重"
|
||||
)
|
||||
@@ -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="总数")
|
||||
@@ -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())
|
||||
Generated
+60
@@ -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")]
|
||||
|
||||
@@ -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(|_| ())
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频替换 — 用配音音频替换视频中的原音
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
// 视频预览转码(统一浏览器兼容格式)
|
||||
|
||||
@@ -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 || [];
|
||||
},
|
||||
};
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -121,6 +121,12 @@ export interface ProjectMeta {
|
||||
coverPath?: string;
|
||||
coverConfig?: CoverConfig;
|
||||
|
||||
// === BGM ===
|
||||
bgmMusicId?: number;
|
||||
bgmMusicTitle?: string;
|
||||
bgmMusicPath?: string;
|
||||
bgmVolume?: number;
|
||||
|
||||
// === 成品 ===
|
||||
finalVideoPath?: string;
|
||||
finalVideoDuration?: number;
|
||||
|
||||
@@ -167,6 +167,10 @@ export const BLANK_META_OVERRIDES: MetaOverrides = {
|
||||
captionPreset: undefined,
|
||||
userUploadedMaterials: undefined,
|
||||
stepDirtyFlags: undefined,
|
||||
bgmMusicId: undefined,
|
||||
bgmMusicTitle: undefined,
|
||||
bgmMusicPath: undefined,
|
||||
bgmVolume: undefined,
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user