From 06ec0ee2027a70024d654f517461c9b887a61fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B1=BC=E5=BC=80=E5=8F=91?= Date: Sun, 24 May 2026 15:39:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20BGM=20=E4=BA=91=E7=AB=AF=E5=8C=96=20+?= =?UTF-8?q?=20=E6=AD=A5=E9=AA=A4=E9=A1=B5=E9=9D=A2=20UI=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 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 滤镜 --- .gitignore | 1 + docs/mixkit_bgm_catalog.md | 336 ++++++++++++++++++ python-api/alembic/env.py | 5 +- .../100366516fbd_add_url_to_bgm_music.py | 28 ++ .../7172a476e5b2_add_bgm_music_table.py | 46 +++ python-api/app/api/v1/bgm_music.py | 43 +++ python-api/app/api/v1/router.py | 4 + python-api/app/crud/__init__.py | 2 + python-api/app/crud/bgm_music.py | 37 ++ python-api/app/models/__init__.py | 2 + python-api/app/models/bgm_music.py | 50 +++ python-api/app/schemas/bgm_music.py | 29 ++ python-api/scripts/import_bgm.py | 136 +++++++ python-api/uv.lock | 60 ++++ .../src-tauri/src/commands/video_compose.rs | 71 ++++ tauri-app/src-tauri/src/ffmpeg_cmd.rs | 52 +++ tauri-app/src-tauri/src/lib.rs | 1 + tauri-app/src/api/modules/bgmMusic.ts | 32 ++ tauri-app/src/hooks/useCoverFabric.ts | 175 ++++----- .../src/pages/VideoCreation/CoverDesign.css | 190 +++++++--- .../src/pages/VideoCreation/CoverDesign.tsx | 295 +++++++++------ .../pages/VideoCreation/ScriptCreation.tsx | 12 - .../pages/VideoCreation/SubtitleBurning.tsx | 4 +- .../src/pages/VideoCreation/VideoCompose.tsx | 251 +++++++++++-- .../pages/VideoCreation/VideoGeneration.css | 76 ++++ .../pages/VideoCreation/VoiceSynthesis.tsx | 14 +- .../_components/GenerationControls.tsx | 19 +- tauri-app/src/pages/VideoGeneration/index.tsx | 1 + tauri-app/src/store/projectStore.ts | 18 + tauri-app/src/types/project.ts | 6 + tauri-app/src/utils/projectMeta.ts | 4 + 31 files changed, 1692 insertions(+), 308 deletions(-) create mode 100644 docs/mixkit_bgm_catalog.md create mode 100644 python-api/alembic/versions/100366516fbd_add_url_to_bgm_music.py create mode 100644 python-api/alembic/versions/7172a476e5b2_add_bgm_music_table.py create mode 100644 python-api/app/api/v1/bgm_music.py create mode 100644 python-api/app/crud/bgm_music.py create mode 100644 python-api/app/models/bgm_music.py create mode 100644 python-api/app/schemas/bgm_music.py create mode 100644 python-api/scripts/import_bgm.py create mode 100644 tauri-app/src/api/modules/bgmMusic.ts diff --git a/.gitignore b/.gitignore index 5ba02f4..a9f97df 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ tauri-app/src-tauri/binaries/* *.key *test*.key* .atomcode/ +mixkit_bgm/ diff --git a/docs/mixkit_bgm_catalog.md b/docs/mixkit_bgm_catalog.md new file mode 100644 index 0000000..14c9386 --- /dev/null +++ b/docs/mixkit_bgm_catalog.md @@ -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` diff --git a/python-api/alembic/env.py b/python-api/alembic/env.py index 26b542f..9ac02e9 100644 --- a/python-api/alembic/env.py +++ b/python-api/alembic/env.py @@ -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 diff --git a/python-api/alembic/versions/100366516fbd_add_url_to_bgm_music.py b/python-api/alembic/versions/100366516fbd_add_url_to_bgm_music.py new file mode 100644 index 0000000..c7fff58 --- /dev/null +++ b/python-api/alembic/versions/100366516fbd_add_url_to_bgm_music.py @@ -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') diff --git a/python-api/alembic/versions/7172a476e5b2_add_bgm_music_table.py b/python-api/alembic/versions/7172a476e5b2_add_bgm_music_table.py new file mode 100644 index 0000000..fbf7e8d --- /dev/null +++ b/python-api/alembic/versions/7172a476e5b2_add_bgm_music_table.py @@ -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 ### diff --git a/python-api/app/api/v1/bgm_music.py b/python-api/app/api/v1/bgm_music.py new file mode 100644 index 0000000..2b2f89c --- /dev/null +++ b/python-api/app/api/v1/bgm_music.py @@ -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), + ), + ) diff --git a/python-api/app/api/v1/router.py b/python-api/app/api/v1/router.py index 83a2aa5..52f6c6c 100644 --- a/python-api/app/api/v1/router.py +++ b/python-api/app/api/v1/router.py @@ -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"]) diff --git a/python-api/app/crud/__init__.py b/python-api/app/crud/__init__.py index 139a806..623fa4e 100644 --- a/python-api/app/crud/__init__.py +++ b/python-api/app/crud/__init__.py @@ -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", diff --git a/python-api/app/crud/bgm_music.py b/python-api/app/crud/bgm_music.py new file mode 100644 index 0000000..160b692 --- /dev/null +++ b/python-api/app/crud/bgm_music.py @@ -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() diff --git a/python-api/app/models/__init__.py b/python-api/app/models/__init__.py index fb21158..39da518 100644 --- a/python-api/app/models/__init__.py +++ b/python-api/app/models/__init__.py @@ -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", diff --git a/python-api/app/models/bgm_music.py b/python-api/app/models/bgm_music.py new file mode 100644 index 0000000..7c8aed4 --- /dev/null +++ b/python-api/app/models/bgm_music.py @@ -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="排序权重" + ) diff --git a/python-api/app/schemas/bgm_music.py b/python-api/app/schemas/bgm_music.py new file mode 100644 index 0000000..772e29c --- /dev/null +++ b/python-api/app/schemas/bgm_music.py @@ -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="总数") diff --git a/python-api/scripts/import_bgm.py b/python-api/scripts/import_bgm.py new file mode 100644 index 0000000..9cd171a --- /dev/null +++ b/python-api/scripts/import_bgm.py @@ -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()) diff --git a/python-api/uv.lock b/python-api/uv.lock index bdcb272..e9eaaaf 100644 --- a/python-api/uv.lock +++ b/python-api/uv.lock @@ -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" diff --git a/tauri-app/src-tauri/src/commands/video_compose.rs b/tauri-app/src-tauri/src/commands/video_compose.rs index afce05a..28f8110 100644 --- a/tauri-app/src-tauri/src/commands/video_compose.rs +++ b/tauri-app/src-tauri/src/commands/video_compose.rs @@ -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, + pub bgm_volume: Option, +} + +/// 混合背景音乐到视频(保留原音频) +#[tauri::command] +pub async fn mix_bgm_to_video( + app: AppHandle, + args: MixBgmToVideoArgs, +) -> ApiResponse { + 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")] diff --git a/tauri-app/src-tauri/src/ffmpeg_cmd.rs b/tauri-app/src-tauri/src/ffmpeg_cmd.rs index 70b3ab4..f787d3b 100644 --- a/tauri-app/src-tauri/src/ffmpeg_cmd.rs +++ b/tauri-app/src-tauri/src/ffmpeg_cmd.rs @@ -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(|_| ()) +} + /** * 音频替换 — 用配音音频替换视频中的原音 * diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 379a89d..95b1243 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -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, // 视频预览转码(统一浏览器兼容格式) diff --git a/tauri-app/src/api/modules/bgmMusic.ts b/tauri-app/src/api/modules/bgmMusic.ts new file mode 100644 index 0000000..ff48251 --- /dev/null +++ b/tauri-app/src/api/modules/bgmMusic.ts @@ -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 => { + const path = category ? `/bgm-musics?category=${encodeURIComponent(category)}` : '/bgm-musics'; + const response = await client.get<{ items: BgmMusicItem[]; total: number }>(path); + return response.items || []; + }, +}; diff --git a/tauri-app/src/hooks/useCoverFabric.ts b/tauri-app/src/hooks/useCoverFabric.ts index 954d8f0..28faa21 100644 --- a/tauri-app/src/hooks/useCoverFabric.ts +++ b/tauri-app/src/hooks/useCoverFabric.ts @@ -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 => { - try { - const img = await new Promise((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 => { + const img = await new Promise((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] diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.css b/tauri-app/src/pages/VideoCreation/CoverDesign.css index 30f150e..7e1878b 100644 --- a/tauri-app/src/pages/VideoCreation/CoverDesign.css +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.css @@ -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 { diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx index 131180e..732819c 100644 --- a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx @@ -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(arr: T[]): T[] { return result; } -function pickSix(all: BgImage[], lastIds: Set): BgImage[] { - if (all.length <= 6) {return shuffleArray(all);} +function pickSeven(all: BgImage[], lastIds: Set): 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([]); const [modalBgs, setModalBgs] = useState([]); const [lastModalBgIds, setLastModalBgIds] = useState>(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(null); + const [bgList, setBgList] = useState([]); + 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) => { + 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" >
- {modalBgs.length > 0 ? ( -
- {modalBgs.map((bg) => ( - + + {modalBgs.map((bg) => ( + - ))} -
- ) : ( -
- 暂无背景图 -
- )} + /> + + ))} +
@@ -480,26 +496,36 @@ export default function CoverDesign() { onClick={openBgModal} title="点击更换背景" > -
背景图片
- {config.backgroundImage ? ( - 背景 { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - ) : ( -
- - - - - - 选择背景 +
+
背景图片
+ {config.backgroundImage ? ( + 背景 { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + ) : ( +
+ + + + + + 选择背景 +
+ )} +
+ + + + + + 更换 +
- )} -
点击更换
+
{/* 封面形象卡片 */} @@ -508,25 +534,35 @@ export default function CoverDesign() { onClick={() => setAvatarModalOpen(true)} title="点击更换形象" > -
封面形象
- {currentAvatar ? ( - {currentAvatar.name} { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - ) : ( -
- - - - - 不使用 +
+
封面形象
+ {currentAvatar ? ( + {currentAvatar.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + ) : ( +
+ + + + + 选择形象 +
+ )} +
+ + + + + + 更换 +
- )} -
点击更换
+
@@ -538,7 +574,7 @@ export default function CoverDesign() { @@ -557,7 +593,7 @@ export default function CoverDesign() {