Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5f1098831 | |||
| 11a85bfee7 | |||
| 603650cfb3 | |||
| 15dc5df12c | |||
| 4659f4536e | |||
| 784c4faa55 | |||
| 5b804e9d79 | |||
| 00f0088c2a | |||
| 4a295e6e0d | |||
| 63e0ffeaea | |||
| 2797583d81 | |||
| 10fc4092b2 | |||
| cc2e3f639c | |||
| 6c64189c70 | |||
| d84a4e9d65 | |||
| 7f522f5b83 | |||
| d2220ac176 |
@@ -31,3 +31,5 @@ tauri-app/src-tauri/binaries/*
|
||||
*test*.key*
|
||||
.atomcode/
|
||||
mixkit_bgm/
|
||||
*.exe
|
||||
*.exe.sig
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
**美家卡智影**是一款面向桌面端的 AI 视频创作应用,采用"Python 后端 API + Tauri 桌面前端"的混合架构。
|
||||
|
||||
- **产品标识**: `cn.meijiaka.ai-video` / `cn.meijiaka.ai-zy`
|
||||
- **版本**: `1.6.4`
|
||||
- **版本**: `1.6.7`
|
||||
- **核心功能**: AI 脚本生成、AI 配音合成(TTS)、声音复刻、视频生成(Vidu)、视频字幕生成、压制成片(FFmpeg)、项目本地持久化
|
||||
|
||||
### 技术栈总览
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# === 基础配置 ===
|
||||
APP_NAME=美家卡智影 API
|
||||
APP_VERSION=1.6.4
|
||||
APP_VERSION=1.6.7
|
||||
# ⚠️ 生产环境必须设为 false
|
||||
DEBUG=true
|
||||
ENV=development
|
||||
@@ -79,4 +79,4 @@ SMS_BASE_URL=https://bjksmtn.b2m.cn/inter/sendSingleSMS
|
||||
|
||||
# === 日志配置 ===
|
||||
# 生产环境建议 INFO
|
||||
LOG_LEVEL=DEBUG
|
||||
LOG_LEVEL=ERROR
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:media-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1778077071}, "http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:img-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1776433218}}
|
||||
{"http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:media-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1779898302}, "http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:img-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1776433218}}
|
||||
@@ -46,4 +46,4 @@ COPY pyproject.toml .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add filename to release_package unique constraint
|
||||
|
||||
Revision ID: 7d855b38fe83
|
||||
Revises: 8d901bc90e67
|
||||
Create Date: 2026-05-26 22:55:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7d855b38fe83'
|
||||
down_revision: Union[str, Sequence[str], None] = '8d901bc90e67'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# 删除旧约束(release_id + platform + architecture)
|
||||
op.drop_constraint('uix_app_pkg_platform_arch', 'mjk_app_release_packages', type_='unique')
|
||||
# 创建新约束(release_id + platform + architecture + filename)
|
||||
op.create_unique_constraint(
|
||||
'uix_app_pkg_platform_arch_filename',
|
||||
'mjk_app_release_packages',
|
||||
['release_id', 'platform', 'architecture', 'filename']
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# 删除新约束
|
||||
op.drop_constraint('uix_app_pkg_platform_arch_filename', 'mjk_app_release_packages', type_='unique')
|
||||
# 恢复旧约束
|
||||
op.create_unique_constraint(
|
||||
'uix_app_pkg_platform_arch',
|
||||
'mjk_app_release_packages',
|
||||
['release_id', 'platform', 'architecture']
|
||||
)
|
||||
@@ -218,24 +218,35 @@ class ViduAdapter(PlatformAdapter, SyncCapable, TaskCapable, CallbackCapable):
|
||||
callback_url: str | None = None,
|
||||
) -> bool:
|
||||
"""验证 Vidu 回调 HMAC-SHA256 签名"""
|
||||
signature = headers.get("X-HMAC-SIGNATURE")
|
||||
algorithm = headers.get("X-HMAC-ALGORITHM")
|
||||
access_key = headers.get("X-HMAC-ACCESS-KEY")
|
||||
signed_headers_str = headers.get("X-HMAC-SIGNED-HEADERS")
|
||||
date = headers.get("Date")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP 头大小写不敏感:建立小写 key 的查找表
|
||||
headers_lower = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
signature = headers_lower.get("x-hmac-signature")
|
||||
algorithm = headers_lower.get("x-hmac-algorithm")
|
||||
access_key = headers_lower.get("x-hmac-access-key")
|
||||
signed_headers_str = headers_lower.get("x-hmac-signed-headers")
|
||||
date = headers_lower.get("date")
|
||||
|
||||
if not all([signature, algorithm, access_key, signed_headers_str, date]):
|
||||
logger.warning(f"[Vidu] 签名验证失败: 缺少必要头, headers={list(headers.keys())}")
|
||||
return False
|
||||
if algorithm != "hmac-sha256":
|
||||
logger.warning(f"[Vidu] 签名验证失败: 不支持的算法 {algorithm}")
|
||||
return False
|
||||
if access_key != "vidu":
|
||||
logger.warning(f"[Vidu] 签名验证失败: access_key 不匹配 {access_key}")
|
||||
return False
|
||||
|
||||
header_names = [h.strip() for h in signed_headers_str.split(";") if h.strip()]
|
||||
header_values: dict[str, str] = {}
|
||||
for name in header_names:
|
||||
value = headers.get(name)
|
||||
# 签名头名也可能大小写不一致,统一用小写查找
|
||||
value = headers_lower.get(name.lower())
|
||||
if value is None:
|
||||
logger.warning(f"[Vidu] 签名验证失败: 缺少签名头 {name}")
|
||||
return False
|
||||
header_values[name] = value
|
||||
|
||||
@@ -258,7 +269,15 @@ class ViduAdapter(PlatformAdapter, SyncCapable, TaskCapable, CallbackCapable):
|
||||
hmac.new(secret.encode("utf-8"), signing_string.encode("utf-8"), hashlib.sha256).digest()
|
||||
).decode("utf-8")
|
||||
|
||||
return hmac.compare_digest(signature, expected)
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
logger.warning(
|
||||
f"[Vidu] 签名验证失败: callback_url={callback_url}, "
|
||||
f"signing_string={repr(signing_string)}, "
|
||||
f"expected={expected[:20]}..., received={signature[:20]}..."
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def verify_nonce(
|
||||
self,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,把书面提问话术改成抖音接地气口播大白话,不改变每个环节询问的项目、品牌、工艺、收费、责任划分等核心信息,全部细节原样保留。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 400-480 字,按每秒 4 个纯文字计算,对应时长 100-120s,讲解环节完整、节奏适中,不啰嗦不拖沓,适配短视频完播习惯。
|
||||
内容适配性:十大问题衔接自然,每个施工环节独立成段适配空镜分镜,直击半包业主不会询价、容易被低价套路、后期增项扯皮的核心痛点,逐条给到可直接照着问的实用话术。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修报价注意事项、评论区扣关键词领资料的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修报价注意事项、评论区回复关键词领资料的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇沿用原文真实吐槽语气,3 秒抓眼球,点破半包业主盲目报面积询价、被装修公司当成新手宰割的现状,瞬间引发准备半包装修业主共鸣。
|
||||
全程口语化大白话,小白一听就懂、可直接照搬拿去问装修公司,站业主立场拆解半包询价所有关键点,条理清晰干货满满,不生硬说教,贴合口播传播节奏。
|
||||
@@ -38,7 +38,7 @@
|
||||
第四,吊顶,问用的是木龙骨还是轻钢龙骨?石膏板是什么牌子的?做单层还是做双层?七字拐、八字缝有没有做?
|
||||
第五,砌墙,问墙固用什么牌子,是油工刷还是开工就刷?挂网是局部还是全屋挂网?全挂要不要加钱?腻子的话,我只认国产一线品牌,其他我都不要。墙顶面我只要顺平就好,柜子后面、踢脚线、门口、窗口局部都要找平就行。乳胶漆用的是什么牌子,有没有刷底漆?是刷几遍,都要给我备注上。
|
||||
最后,装修用的材料,如果发现是以次充好,该怎么赔?工人安全是谁来负责?工期耽误了又该怎么赔?施工不达标,要不要整改?整改费用谁出?
|
||||
这些问题你不搞清楚,后期肯定扯皮。我整理了装修报价注意事项,评论区抠报价,拿去用
|
||||
这些问题你不搞清楚,后期肯定扯皮。我整理了装修报价注意事项,评论区回复报价,拿去用
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
文案调整要求:仅做口语化精简优化,把直白问句改成顺口口播表述,不替换、不删减任何品牌,保持每类主材三个推荐品牌完整不变,原意丝毫不改。
|
||||
字数与时长控制:纯文字 + 数字扣除标点,严格控制在 170-190 字,按每秒 4 个字计算,对应时长 42.5-47.5s,内容精炼、节奏紧凑,适配短平快知识口播。
|
||||
内容适配性:打乱顺序后语句依然衔接自然,每条独立清晰,直接给到可照搬的主材品牌清单,解决业主选材纠结、怕踩坑的核心痛点,实用性拉满。
|
||||
结尾范式:完整保留原文结尾引导原话,仅可轻微优化口语流畅度,不改动评论区扣关键词、领取材料推荐清单的核心引流逻辑。
|
||||
结尾范式:完整保留原文结尾引导原话,仅可轻微优化口语流畅度,不改动评论区回复关键词、领取材料推荐清单的核心引流逻辑。
|
||||
【开篇 & 语言要求】
|
||||
无开篇铺垫,直接切入主材品牌推荐干货;全程短句口语化、接地气,直白罗列品牌,简单好记、业主可直接收藏对照选材。
|
||||
可微调句式语序,严禁替换、删减任意主材品牌,不改变推荐逻辑和原意,语句简短利落,适配短时长口播节奏。
|
||||
@@ -44,7 +44,7 @@
|
||||
瓷砖胶买谁家?德高、大禹、神工。
|
||||
乳胶漆买谁家?立邦、多乐士、三棵树。
|
||||
玻璃胶买谁家?瓦克、西卡、百得。
|
||||
记不住的,我这里有材料推荐清单,评论区扣材料,直接拿走。
|
||||
记不住的,我这里有材料推荐清单,评论区回复材料,直接拿走。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
文案调整要求:仅做口语化精简优化,把问句改成顺口口播表述,不删减、不替换任何一个品牌名称,完整保留每品类三大推荐品牌,原意丝毫不变。
|
||||
字数与时长控制:纯文字 + 数字扣除标点,严格控制在 220-240 字,按每秒 4 个字核算,对应时长 55-60s,内容精炼紧凑、节奏适中,适配短平快知识口播。
|
||||
内容适配性:打乱顺序后语句衔接自然,逐条清晰罗列,业主可直接对照抄作业选品牌,解决选材纠结、怕踩坑、不会分辨好坏的核心痛点,实用性极强。
|
||||
结尾范式:完整保留原文结尾引导原话,仅轻微优化口语流畅度,不改动新房装修人群定位、评论区扣关键词领取装修避坑手册的核心引流逻辑。
|
||||
结尾范式:完整保留原文结尾引导原话,仅轻微优化口语流畅度,不改动新房装修人群定位、评论区回复关键词领取装修避坑手册的核心引流逻辑。
|
||||
【开篇 & 语言要求】
|
||||
无开篇铺垫,直接切入品牌推荐干货;全程短句大白话、接地气,直白罗列靠谱品牌,简单好记、装修可直接照搬参考。
|
||||
可微调句式语序,严禁改动、删减、替换任意品类及对应品牌,不改变推荐逻辑与原意,语句简短利落,适配中短时长口播节奏。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
第六,材料假一罚十,品牌型号对好,你确认后再施工。防止装修公司以次充好,偷换材料。
|
||||
第七,甲醛检测不合格,装修公司整改并承担所有费用。避免入住后甲醛超标,维权无门。
|
||||
第八,违约责任划清楚,违约金和逾期赔付金额写明白。保障自己权益,让装修公司不敢随意违约。
|
||||
准备装修的,我整理了合同模板,评论区扣装修就能领!帮你装修少踩坑、省麻烦!
|
||||
准备装修的,我整理了合同模板,评论区回复装修就能领!帮你装修少踩坑、省麻烦!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
全程口语化大白话,小白易懂,不生硬说教,站业主共情立场,贴合原文口语化风格。
|
||||
可微调句式,不得篡改原文中工期、赔偿金比例、付款节点、材料条款等核心数字数据,每句必须带标点断句。
|
||||
【细节固定要求】
|
||||
结尾必须固定话术:我整理了装修全流程避坑指南,抠合同直接拿走。同时保留原文结尾“记不住的,我整理了装修合同样本,评论区抠合同,直接拿着对照检查,少踩坑!”
|
||||
结尾必须固定话术:我整理了装修全流程避坑指南,抠合同直接拿走。同时保留原文结尾“记不住的,我整理了装修合同样本,评论区回复合同,直接拿着对照检查,少踩坑!”
|
||||
总分镜数量固定12–20个,每个分镜时长3–8秒,可保留两位小数。
|
||||
【内置固定原文案】
|
||||
新房装修签合同千万注意这6个点,玩的都是文字游戏,耐心听我讲完,少踩一个坑等于多赚一笔钱。
|
||||
@@ -31,7 +31,7 @@
|
||||
第四,材料调换坑。很多公司条款上面写着,当材料断货时,可用同等价钱调换,但有这条,偷工减料就成了理所当然。同价产品很难界定,同价的杂牌你敢用吗?这条必须划掉。
|
||||
第五,安全责任。有80%的公司只写按安全标准施工,但别不提出事谁负责?一旦发生安全事故,就是扯不完的皮。合同里必须注明工人人身安全及财产损失全部由装修公司承担。
|
||||
第六,也是最恶心的一点,很多公司把单方面解约违约金写得很高,他们根本不会主动解约,这条就是为了绑死你。违约金超过20%,你发现问题也不敢换人,所以超过20%直接拉黑,别犹豫。
|
||||
记不住的,我整理了装修合同样本,评论区抠合同,直接拿走对照检查,少踩坑!
|
||||
记不住的,我整理了装修合同样本,评论区回复合同,直接拿走对照检查,少踩坑!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,把书面合同话术改成抖音口播接地气大白话,不改变违约金比例、付款节点金额、备注 5 条硬性约定等所有核心数字和规则,完整保留原文原意。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 400-480 字,按每秒 4 个纯文字计算,对应时长 100-120s,讲解条款细致不啰嗦,节奏适中,适配短视频完播率。
|
||||
内容适配性:三大要点及备注条款衔接自然,每部分独立适配空镜分镜,直击业主签约被套路、后期加价维权难的核心痛点,每一条都讲清陷阱、整改方法和保障作用,实用性极强。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修合同模板、评论区扣关键词引导的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修合同模板、评论区回复关键词引导的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇沿用原文扎心吐槽语气,3 秒抓眼球,点破装修签合同前后身份反差、低价全包套路深坑,瞬间引发准备装修业主共鸣。
|
||||
全程口语化大白话,通俗易懂、接地气,站业主立场拆解合同陷阱,条理清晰、干货满满,不生硬说教,适配口播传播节奏。
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
第五,贴砖时要在场,检查平整度空鼓率,阴阳角方正、缝隙均匀才合格。
|
||||
第六,木工吊顶必在场,拐角整板、接缝做 V 型槽,杜绝后期乳胶漆开裂。
|
||||
第七,刮腻子一定要在场,严禁往腻子加胶水,不然甲醛超标变毒气房。
|
||||
准备装修的朋友,我整理了避坑手册,评论区扣避坑直接领取参考!
|
||||
准备装修的朋友,我整理了避坑手册,评论区回复避坑直接领取参考!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,把书面表述改成抖音接地气口播大白话,不改变每个节点的施工要求、到场必要性、后期隐患,所有细节完整保留。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 360-440 字,按每秒 4 个纯文字计算,对应时长 90-110s,内容精炼不啰嗦,节奏适中符合短视频完播习惯。
|
||||
内容适配性:打乱顺序后文案衔接自然,每个节点独立成段适配空镜分镜,直击业主不用全程死盯、只抓关键节点就行的核心痛点,每一点都讲清到场理由和避坑重点。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区扣关键词、福利引导的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区回复关键词、福利引导的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇完整沿用原文开头朴实话术,3 秒抓眼球,点破全程监工又累又没用的现实,引出只盯关键节点的核心观点。
|
||||
全程口语化大白话,小白易懂、接地气实在,站普通业主视角共情讲解,不生硬说教,语气真诚接地气。
|
||||
@@ -34,7 +34,7 @@
|
||||
第四,吊顶时,你必须在场,确认好使用的是轻钢龙骨,别让师傅偷换用木龙骨,再直接封上石膏板,后期变形发霉,等你发现那就晚了。
|
||||
第五,全屋定制安装,你必须在场,通过五金孔检查板材品质,还要叮嘱师傅做好封边,少做一步,你家都可能甲醛超标。
|
||||
第六,房子做完闭水试验,你必须亲自去楼下邻居家看看有没有漏水,如果只让师傅拍照片,你根本不知道他是什么时候拍的。真出了问题还得你来赔付。
|
||||
记不住的,我整理了装修全流程避坑手册。评论区抠避坑,拿去用。
|
||||
记不住的,我整理了装修全流程避坑手册。评论区回复避坑,拿去用。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,改成抖音口播接地气大白话,不改变每个要点的施工场景、业主行为、带来的影响,完整保留原意不变。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 400-480 字,按每秒 4 个纯文字计算,对应时长 100-120s,讲解饱满不拖沓,符合短视频完播习惯。
|
||||
内容适配性:6 个要点讲解衔接自然,每点独立成段适配空镜分镜,聚焦业主不懂行乱指挥、盲目加活的通病,既讲做法又讲背后利弊,真实接地气、容易引发共鸣。
|
||||
结尾范式:以 “如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区抠避坑,拿去用。” 为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动核心逻辑。
|
||||
结尾范式:以 “如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区回复避坑,拿去用。” 为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇严格遵循核心强制规则原句,3 秒抓眼球不拖沓,用真实行业视角吐槽业主盲目干预施工的通病,贴合装修受众共情点,不偏离范式结构。
|
||||
全程口语化大白话,小白易懂、不生硬说教,站客观中立角度讲解,语气接地气有真实感,贴合口播传播特点。
|
||||
@@ -34,7 +34,7 @@
|
||||
第四,木工师傅高高兴兴来了,你却告诉他,所有接缝处都要做 V 字型槽,转角处要做到 T 字型。师傅一听就知道你是懂行的。后期墙面是不容易开裂了,又给师傅增加好多活儿。
|
||||
第五,瓦工师傅来了,懂行的业主要求把卫生间先找坡度,地漏做成回形地漏,这样不仅下水快,还好看,可这又得浪费师傅半天时间,重新找坡度。
|
||||
第六,瓦工还没结束,部分业主已经提前买好了地漏和油烟止逆阀,要求师傅一并装上。这下好了,之后安装电器的师傅想赚点外快都不行。
|
||||
如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区抠避坑,拿去用。
|
||||
如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区回复避坑,拿去用。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
【核心强制规则】
|
||||
开头范式:保留原文完整开头结构与核心原意,仅微调口语语气,不篡改句意,直击全屋定制合同签完仍乱加价、套路多的痛点,引出3个必看避坑要点。
|
||||
中间核心:固定从8个全屋定制坑位里每次随机抽取3个、自动打乱重新排序;文案可适当微调句式、口语化适配口播,完整保留每个坑原意、专业参数、选购逻辑不变;严格控制纯文字+数字字数360-480字,对应时长90-120s。
|
||||
结尾范式:完整保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区扣关键词的核心逻辑。
|
||||
结尾范式:完整保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区回复关键词的核心逻辑。
|
||||
【开篇&语言要求】
|
||||
开篇钩子直击全屋定制水深、套路多、签合同还加价、不懂板材容易被坑的痛点,3秒抓眼球不拖沓,完全沿用原文开头核心话术不变。
|
||||
全程口语化大白话,小白易懂、不生硬说教,站业主共情立场,贴合原文接地气口播风格。
|
||||
@@ -24,7 +24,7 @@
|
||||
第六就是铰链,你问他什么品牌,但凡跟你说是他们自有品牌,直接让他有多远滚多远。他又不是生产队的驴,啥都能生产。多半是找小工厂代工的,别为了省那点钱,铰链就认准汉高、东泰、德蒂,每天都要开关,咱们可不能马虎。
|
||||
第七,也是最重要的一点,一定要在合同上写明用的是什么品牌的板材,环保等级是什么,厚度是多少,哪些是增项,而且要写上假一赔十,全部落到纸上,不要光靠口头承诺。
|
||||
第八,全屋定制,不管是橱柜也好,衣柜也好,一线品牌和六线品牌做出来都是一模一样的。说白了,所有全屋定制都是板材的二道贩子,咱们就找本地工厂,关键看设计和安装。
|
||||
要是还有不懂的、近期准备新房装修的朋友,我整理了一份装修避坑手册供你参考,评论区抠避坑,拿去用。
|
||||
要是还有不懂的、近期准备新房装修的朋友,我整理了一份装修避坑手册供你参考,评论区回复避坑,拿去用。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
3. 厨卫水电必走顶,漏水易发现好维修,其他地方走地省材料。
|
||||
4. 验收必做水管打压30分钟无渗漏,电路测通断再签字。
|
||||
水电是隐蔽工程,紧盯施工别偷懒,别等返工才追悔莫及!
|
||||
近期准备装修的可以找我领装修避坑手册,评论区扣避坑,直接拿走。
|
||||
近期准备装修的可以找我领装修避坑手册,评论区回复避坑,直接拿走。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
文案调整要求:仅做口语化句式微调,把书面表述改成接地气口播大白话,不改动任何施工细节、工艺要求、禁忌标准,完整保留 10 条话术核心原意。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 440-480 字,按每秒 4 个纯文字计算,对应时长 110-120s,讲解饱满不拖沓,符合短视频用户完播习惯。
|
||||
内容适配性:打乱顺序后文案衔接自然,每条话术独立成点、逻辑通顺,贴合业主瓦工进场监工刚需,直击无效送礼不如专业话术管用的核心痛点,每一条都明确施工标准和避坑要点。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区扣关键词、福利引导的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区回复关键词、福利引导的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇完整沿用原文开头句式和吐槽语气,3 秒抓眼球,直击业主花钱送礼无效监工的通病,引出专业监工话术。
|
||||
全程口语化大白话,接地气、通俗易懂,站装修业主视角共情讲解,不生硬说教。
|
||||
@@ -42,7 +42,7 @@
|
||||
第八句,师傅,所有的转角都要海棠角,后期我要做美缝,千万别给我做阳角条。
|
||||
第九句,师傅需要贴止逆阀的地方一定要帮我贴一块整砖。我的止逆阀也买回来,你按这个开孔以后,顺手帮我装上吧。
|
||||
第十句,师傅,我家橱柜和浴室柜不打算装挡水条,所以对墙面阴阳角的垂直度要求比较高,麻烦你上点心啊。
|
||||
准备新房装修的朋友,我整理了装修全流程避坑手册。评论区抠避坑,拿去用。
|
||||
准备新房装修的朋友,我整理了装修全流程避坑手册。评论区回复避坑,拿去用。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
2. 文案调整要求:微调仅针对句式口语化优化,比如将书面化表述改为抖音/视频号口播常用的接地气语气,补充轻微危害提示(结合美缝反碱脱落、水电标识撕毁的隐患),不改变每个坑的核心信息——如验收等待五六天、美缝等待一周、禁止洒水、保留水电标识、定制复尺周期一个月等核心时间节点和禁忌,所有细节完全保留,贴合原文原意。
|
||||
3. 字数与时长控制:纯文字+数字(扣除标点)严格控制在400-480字,按每秒4个纯文字计算,对应时长100-120s,既保证每个避坑点讲解透彻,补充必要危害提示,又不拖沓,符合短视频用户观看习惯,避免用户划走。
|
||||
4. 内容适配性:5个避坑要点讲解时需衔接自然,每个坑独立成段(分镜对应空镜),不重复、不冗余,重点突出“停工避坑”核心,贴合业主担心被装修公司催促、怕后期出问题自己担责、想合理利用停工时间的核心痛点,每段讲解都紧扣“为什么不能做、怎么做才对”的逻辑,与原文保持一致,结合参考内容完善危害提示,增强说服力。
|
||||
结尾范式:以“如果你们也在准备新房装修,不知道还有哪些坑要避,评论区扣 ‘装修’,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!”为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区扣关键词、关注引导的核心逻辑。
|
||||
结尾范式:以“如果你们也在准备新房装修,不知道还有哪些坑要避,评论区回复 ‘装修’,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!”为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区回复关键词、关注引导的核心逻辑。
|
||||
【开篇&语言要求】
|
||||
开篇严格遵循核心强制规则的警示性句式,3秒抓眼球不拖沓,用犀利语气点出瓷砖铺贴后被催工期、盲目施工后期担责的痛点,贴合装修业主避坑需求,不偏离范式结构。
|
||||
全程口语化大白话,小白易懂、不生硬说教,站业主共情立场,用警示性语气讲解,贴合口播传播特点,增强代入感,补充的危害提示通俗易懂,让业主清晰了解违规操作的后果。
|
||||
@@ -32,7 +32,7 @@
|
||||
第三,瓷砖铺完后千万不要洒水,你洒水养护的是下面的水泥砂浆,那活儿,瓦工铺的时候就应该把墙面地面打湿再贴,铺完了再打扫干净,盖好保护膜就可以了,别多此一举。
|
||||
第四,墙面的水电标识贴不要撕,这是给后期安装师傅看的。你一撕,人家打孔打到水管电线,你就等着哭吧,不仅维修麻烦,还可能引发安全隐患。
|
||||
最后,停工这几天也别闲着。闲着你就可以让定制商家上门复尺,提前下单,定制周期差不多一个月,到时候你家油工结束了,这些东西正好能装,一点儿不耽误工期。
|
||||
如果你们也在准备新房装修,不知道还有哪些坑要避,评论区扣 “装修”,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!
|
||||
如果你们也在准备新房装修,不知道还有哪些坑要避,评论区回复 “装修”,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,把直白叙述话术改成抖音口播接地气大白话,不改变每一步施工做法、选材建议、隐患危害等所有核心信息,完整保留原文原意。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 440-480 字,按每秒 4 个纯文字计算,对应时长 110-120s,讲解收尾细节细致不啰嗦,节奏适中,适配短视频完播率。
|
||||
内容适配性:7 个收尾要点衔接自然,每一条独立适配空镜分镜,直击业主硬装完工急于入住、忽略隐蔽收尾细节,后期返工闹心的核心痛点,每一条都讲清做法、原因和避坑作用,实用性极强。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修全流程避坑手册、评论区扣关键词引导的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修全流程避坑手册、评论区回复关键词引导的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇沿用原文警示吐槽语气,3 秒抓眼球,点破硬装刚结束着急搬软装、忽略收尾细节入住就留隐患闹矛盾的真实痛点,瞬间引发装修完工业主共鸣。
|
||||
全程口语化大白话,通俗易懂、接地气,站业主立场拆解装修收尾细节,条理清晰、干货满满,不生硬说教,适配口播传播节奏。
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
第四、门口、踢脚线、衣柜周围重点找平,别留难看缝隙。
|
||||
第五、吊顶钉子眼一定要人工刷防锈漆,防止后期生锈难看。
|
||||
第六、油工验收合格再给钱,面子工程必须把好质量关。
|
||||
准备装修的朋友,评论区扣避坑直接领取装修流程避坑手册!直接拿着对照参考,少踩坑!
|
||||
准备装修的朋友,评论区回复避坑直接领取装修流程避坑手册!直接拿着对照参考,少踩坑!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -50,15 +50,20 @@ async def check_update(
|
||||
if latest.version == version:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# 查询对应平台的包
|
||||
# 查询对应平台的包(优先返回 updater 用的包:有 signature 的 .app.tar.gz / .exe)
|
||||
result = await db.execute(
|
||||
select(ReleasePackage).where(
|
||||
ReleasePackage.release_id == latest.id,
|
||||
ReleasePackage.platform == target,
|
||||
ReleasePackage.architecture == arch,
|
||||
).order_by(
|
||||
# 有 signature 的排前面(updater 包),空 signature 的排后面(dmg 安装包)
|
||||
ReleasePackage.signature.desc()
|
||||
)
|
||||
)
|
||||
pkg: ReleasePackage | None = result.scalar_one_or_none()
|
||||
pkgs = result.scalars().all()
|
||||
# 取第一个:优先有 signature 的 updater 包;如果没有则取任意一个
|
||||
pkg: ReleasePackage | None = pkgs[0] if pkgs else None
|
||||
|
||||
if not pkg:
|
||||
# 该平台无包,返回 204(避免报错阻断用户)
|
||||
|
||||
@@ -43,7 +43,11 @@ async def vidu_callback(request: Request):
|
||||
body_bytes = await request.body()
|
||||
headers_dict = dict(request.headers)
|
||||
|
||||
logger.info(f"[Vidu] 收到回调: url={request.url}, body={body_bytes.decode('utf-8', errors='replace')[:500]}")
|
||||
# 使用 APP_BASE_URL 构建 callback_url,确保与提交任务时传给 Vidu 的一致
|
||||
#(Nginx 反向代理可能导致 request.url 的 scheme 为 http,与 Vidu 签名时的 https 不一致)
|
||||
app_base_url = get_settings().app_base_url
|
||||
callback_url = f"{app_base_url}/api/v1/vidu/callback" if app_base_url else str(request.url)
|
||||
logger.info(f"[Vidu] 收到回调: request_url={request.url}, callback_url={callback_url}, body={body_bytes.decode('utf-8', errors='replace')[:500]}")
|
||||
|
||||
try:
|
||||
task_status = await gateway.handle_webhook(
|
||||
@@ -51,7 +55,7 @@ async def vidu_callback(request: Request):
|
||||
headers=headers_dict,
|
||||
body=body_bytes,
|
||||
secret=get_settings().VIDU_API_KEY,
|
||||
callback_url=str(request.url),
|
||||
callback_url=callback_url,
|
||||
)
|
||||
except PlatformError as e:
|
||||
logger.warning(f"[Vidu] 回调验证失败: {e}")
|
||||
|
||||
@@ -24,7 +24,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# 应用基础配置
|
||||
APP_NAME: str = Field(default="美家卡智影 API", description="应用名称")
|
||||
APP_VERSION: str = Field(default="1.6.4", description="应用版本")
|
||||
APP_VERSION: str = Field(default="1.6.7", description="应用版本")
|
||||
DEBUG: bool = Field(default=False, description="调试模式")
|
||||
ENV: Literal["development", "staging", "production"] = Field(
|
||||
default="development", description="运行环境"
|
||||
|
||||
@@ -361,6 +361,7 @@ def main():
|
||||
workers=settings.WORKERS if not settings.DEBUG else 1,
|
||||
reload=settings.DEBUG,
|
||||
log_level=settings.LOG_LEVEL.lower(),
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -60,5 +60,5 @@ class ReleasePackage(Base):
|
||||
release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="packages")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("release_id", "platform", "architecture", name="uix_app_pkg_platform_arch"),
|
||||
UniqueConstraint("release_id", "platform", "architecture", "filename", name="uix_app_pkg_platform_arch_filename"),
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
- ~/Documents/Meijiaka-zy:/root/Documents/Meijiaka-zy
|
||||
ports:
|
||||
- "8081:8000"
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --no-access-log
|
||||
networks:
|
||||
- meijiaka-network
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
volumes:
|
||||
# 仅持久化日志到宿主机,其他数据走对象存储
|
||||
- /opt/meijiaka-zy/logs:/root/Documents/Meijiaka-zy/logs
|
||||
command: alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
command: alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: >
|
||||
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"
|
||||
networks:
|
||||
- meijiaka-zy
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "meijiaka-ai-api"
|
||||
version = "1.6.4"
|
||||
version = "1.6.7"
|
||||
description = "美家卡智影 - AI 视频创作后端 API"
|
||||
authors = [{ name = "Meijiaka Team" }]
|
||||
readme = "README.md"
|
||||
|
||||
@@ -105,6 +105,29 @@ def find_packages(bundle_dir: Path) -> list[dict]:
|
||||
"file_size": pkg_file.stat().st_size,
|
||||
})
|
||||
|
||||
# macOS DMG: 给新用户首次安装用(无签名文件)
|
||||
for dmg_file in bundle_dir.rglob("*.dmg"):
|
||||
filename = dmg_file.name
|
||||
|
||||
# 判断文件名是否包含架构标识
|
||||
has_arch_marker = any(marker in filename for marker in ["aarch64", "arm64", "x86_64"])
|
||||
if has_arch_marker:
|
||||
arch = "aarch64" if "aarch64" in filename or "arm64" in filename else "x86_64"
|
||||
archs = [arch]
|
||||
else:
|
||||
# Universal DMG:同时支持 x86_64 和 aarch64
|
||||
archs = ["x86_64", "aarch64"]
|
||||
|
||||
for arch in archs:
|
||||
packages.append({
|
||||
"platform": "darwin",
|
||||
"architecture": arch,
|
||||
"filename": filename,
|
||||
"local_path": str(dmg_file),
|
||||
"signature": "", # DMG 无签名(非 updater 包)
|
||||
"file_size": dmg_file.stat().st_size,
|
||||
})
|
||||
|
||||
return packages
|
||||
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -944,7 +944,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meijiaka-ai-api"
|
||||
version = "1.6.4"
|
||||
version = "1.6.7"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_mobile TEXT := '13800138000'; -- ← 修改:手机号
|
||||
v_nickname TEXT := '新用户昵称'; -- ← 修改:昵称(可为空)
|
||||
v_mobile TEXT := '13950003857'; -- ← 修改:手机号
|
||||
v_nickname TEXT := '怀松'; -- ← 修改:昵称(可为空)
|
||||
v_source TEXT := 'manual'; -- ← 修改:注册来源:manual / invite / promotion
|
||||
v_invited_by UUID := NULL; -- ← 修改:邀请人 user_id(没有则留 NULL)
|
||||
v_gift_points INT := 2000; -- ← 修改:赠送初始积分(0 表示不赠送)
|
||||
@@ -108,7 +108,7 @@ END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_mobile TEXT := '13860199646'; -- ← 修改:目标用户手机号
|
||||
v_mobile TEXT := '18750556093'; -- ← 修改:目标用户手机号
|
||||
v_gift_points INT := 5000; -- ← 修改:赠送积分数量
|
||||
v_gift_days INT := 180; -- ← 修改:有效期(天)
|
||||
v_reason TEXT := '运营活动赠送'; -- ← 修改:赠送原因(写入流水描述)
|
||||
|
||||
+16
-1
@@ -224,10 +224,25 @@ def main():
|
||||
print(" - python-api/.env.example")
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
# 自动提交版本更新,确保 tag 落在正确的 commit 上
|
||||
subprocess.run(
|
||||
["git", "add", "-A"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"bump version to {version}"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
print(f"✅ 已提交: bump version to {version}")
|
||||
create_git_tag(version)
|
||||
print("\n下一步:")
|
||||
print(f" git add -A && git commit -m 'bump version to {version}'")
|
||||
print(f" git push && git push origin v{version}")
|
||||
print(f" # 如果使用 GitHub Actions,同时推送到 GitHub remote:")
|
||||
print(f" git push github-new && git push github-new v{version}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
**美家卡智影**(产品名)是一款基于 Tauri v2 + React 19 + TypeScript 的桌面端 AI 视频创作应用。
|
||||
|
||||
- **产品标识**: `cn.meijiaka.ai-video`
|
||||
- **版本**: `1.6.4`
|
||||
- **版本**: `1.6.7`
|
||||
- **窗口尺寸**: 1200×800,不可缩放(`resizable: false`)
|
||||
- **核心功能**: AI 脚本生成、AI 配音合成、视频生成、压制成片(FFmpeg)、项目本地持久化
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ export default tseslint.config(
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/prop-types': 'off', // TypeScript 已有类型检查,无需 PropTypes
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/incompatible-library': 'off', // TanStack Virtual 等常见库误报
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
'react/no-unescaped-entities': 'warn',
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tauri-app",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.7",
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"private": true,
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -4219,7 +4219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-app"
|
||||
version = "1.6.4"
|
||||
version = "1.6.7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tauri-app"
|
||||
version = "1.6.4"
|
||||
version = "1.6.7"
|
||||
description = "美家卡智影 - AI 视频创作桌面应用"
|
||||
authors = ["美家卡科技"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"dialog:allow-open",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"updater:default"
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -560,7 +560,13 @@ pub async fn mix_bgm_to_video(
|
||||
bgm_volume: f64,
|
||||
) -> Result<(), String> {
|
||||
let safe_video = validate_safe_path(video_path)?;
|
||||
let safe_bgm = validate_safe_path(bgm_path)?;
|
||||
// BGM 可以是用户通过系统文件选择器选取的任意本地文件,
|
||||
// 验证文件存在且不含路径遍历字符
|
||||
let safe_bgm = if !bgm_path.contains("..") && std::path::Path::new(bgm_path).exists() {
|
||||
bgm_path.to_string()
|
||||
} else {
|
||||
return Err(format!("BGM 文件不存在或路径非法: {}", bgm_path));
|
||||
};
|
||||
let safe_output = sanitize_output_path(output_path)?;
|
||||
|
||||
// 构建 filter_complex:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "美家卡智影",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.7",
|
||||
"identifier": "cn.meijiaka.ai-zy",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' asset: http://asset.localhost; img-src 'self' https: data: blob: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; connect-src 'self' https: ws://localhost:* wss://localhost:* ipc://localhost http://ipc.localhost;",
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' asset: http://asset.localhost; img-src 'self' https: data: blob: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; connect-src 'self' https: ws://localhost:* wss://localhost:* ipc://localhost ipc://localhost/* http://ipc.localhost;",
|
||||
"capabilities": [
|
||||
"default"
|
||||
],
|
||||
|
||||
@@ -94,7 +94,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
initProjectStore().catch(console.error);
|
||||
usePointStore.getState().loadRules();
|
||||
usePointStore.getState().fetchBalance().catch(console.error);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export async function generateEmptyShotClip(args: {
|
||||
outputPath: string;
|
||||
}): Promise<string> {
|
||||
const res = await invoke<ApiResponse<string>>('generate_empty_shot_clip', { args });
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (res.code !== 200 || res.data == null) {
|
||||
throw new Error(res.message || '生成空镜片段失败');
|
||||
}
|
||||
@@ -93,6 +94,7 @@ export async function concatVideoClips(
|
||||
projectId,
|
||||
clipPaths,
|
||||
});
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (res.code !== 200 || res.data == null) {
|
||||
throw new Error(res.message || '视频拼接失败');
|
||||
}
|
||||
@@ -108,6 +110,7 @@ export async function concatVideoClips(
|
||||
*/
|
||||
export async function downloadFile(url: string, outputPath: string): Promise<string> {
|
||||
const res = await invoke<ApiResponse<string>>('download_file', { url, outputPath });
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (res.code !== 200 || res.data == null) {
|
||||
throw new Error(res.message || '下载文件失败');
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
|
||||
|
||||
// 点击外部关闭用户菜单
|
||||
useEffect(() => {
|
||||
if (!showUserMenu) return;
|
||||
if (!showUserMenu) {return;}
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
|
||||
@@ -32,7 +32,8 @@ export default function PricingModal({ open, onClose }: PricingModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || rules.length > 0) return;
|
||||
if (!open || rules.length > 0) {return;}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoading(true);
|
||||
pointsApi.getRules()
|
||||
.then(setRules)
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function UpdateDialog() {
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes === 0) {return '0 B';}
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface UpdaterActions {
|
||||
* 如果 notes 中包含 [强制更新] 或 [mandatory],视为强制更新
|
||||
*/
|
||||
function parseMandatory(notes: string | undefined): boolean {
|
||||
if (!notes) return false;
|
||||
if (!notes) {return false;}
|
||||
return notes.includes('[强制更新]') || notes.includes('[mandatory]');
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export function useUpdater(): UpdaterState & UpdaterActions {
|
||||
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
const update = updateRef.current;
|
||||
if (!update) return;
|
||||
if (!update) {return;}
|
||||
|
||||
setState(s => ({
|
||||
...s,
|
||||
|
||||
@@ -1127,11 +1127,11 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 封面形象网格 — 复用 avatar-card 风格 */
|
||||
/* 封面形象网格 — 复用 works-card 风格 */
|
||||
.cover-avatar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--spacing-xl);
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-sm) var(--spacing-xs);
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
@@ -1139,6 +1139,21 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 封面形象:封面区域使用 3:4 比例(照片尺寸) */
|
||||
.cover-avatar-grid .works-card-cover {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.cover-avatar-grid .works-card-cover::before {
|
||||
padding-top: 133.33%; /* 4/3 * 100 = 133.33% for 3:4 ratio */
|
||||
}
|
||||
|
||||
/* 封面形象:图片使用 contain 以适应透明背景 */
|
||||
.cover-avatar-grid .works-card-poster {
|
||||
object-fit: contain;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
/* Content Page Compact variant */
|
||||
.content-page-compact {
|
||||
gap: var(--spacing-md);
|
||||
@@ -1659,11 +1674,12 @@
|
||||
/* 网格布局 - 一行4个 */
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
grid-auto-rows: min-content;
|
||||
}
|
||||
|
||||
/* 成品卡片 */
|
||||
@@ -1702,6 +1718,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 确保封面高度始终正确(兼容 aspect-ratio 支持不佳的环境) */
|
||||
.works-card-cover::before {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-top: 177.78%; /* 16/9 * 100 = 177.78% for 9:16 ratio */
|
||||
}
|
||||
|
||||
.works-card-poster {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -309,40 +309,39 @@ export default function CoverAvatarLibrary() {
|
||||
) : (
|
||||
<div className="cover-avatar-grid">
|
||||
{coverAvatars.map(a => (
|
||||
<div key={a.id} className="avatar-card">
|
||||
<div key={a.id} className="works-card">
|
||||
{/* 图片预览 */}
|
||||
<div className="avatar-card-thumb-container">
|
||||
<div className="works-card-cover">
|
||||
<img
|
||||
src={a.imageUrl}
|
||||
alt={a.name}
|
||||
className="avatar-card-video"
|
||||
style={{ objectFit: 'contain', background: 'var(--bg-input)' }}
|
||||
className="works-card-poster"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* 悬停操作按钮 */}
|
||||
<div className="avatar-card-actions">
|
||||
<div className="works-card-overlay" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="avatar-card-action-btn"
|
||||
className="works-card-action"
|
||||
onClick={() => startRename(a.id, a.name)}
|
||||
title="重命名"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
className="avatar-card-action-btn delete"
|
||||
className="works-card-action delete"
|
||||
onClick={() => openDeleteModal(a.id, a.name)}
|
||||
title="删除"
|
||||
>
|
||||
✕
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 名称 */}
|
||||
<div className="avatar-card-info">
|
||||
<div className="works-card-info">
|
||||
{editingId === a.id ? (
|
||||
<input
|
||||
type="text"
|
||||
className="avatar-card-name-input"
|
||||
className="works-card-rename-input"
|
||||
value={editingName}
|
||||
onChange={e => setEditingName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
@@ -353,7 +352,7 @@ export default function CoverAvatarLibrary() {
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-card-name">{a.name}</div>
|
||||
<div className="works-card-name" title={a.name}>{a.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { openPath } from '@tauri-apps/plugin-opener';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { getLocalFileUrl } from '../../utils/fileUrl';
|
||||
import { toast } from '../../store/uiStore';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import { switchProject } from '../../store/projectStore';
|
||||
import { localProjectApi } from '../../api/modules/localStorage';
|
||||
@@ -106,7 +108,7 @@ function ProductCard({ product, onDelete, onRename }: {
|
||||
let canceled = false;
|
||||
getLocalFileUrl(product.path)
|
||||
.then(url => {
|
||||
if (!canceled) setVideoUrl(url);
|
||||
if (!canceled) {setVideoUrl(url);}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[ProductCard] 加载视频失败:', err);
|
||||
@@ -165,6 +167,29 @@ function ProductCard({ product, onDelete, onRename }: {
|
||||
|
||||
const handleOpen = () => openPath(product.path);
|
||||
|
||||
const handleExport = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const filename = product.path.split(/[\\/]/).pop() || 'video.mp4';
|
||||
const targetPath = await save({
|
||||
defaultPath: filename,
|
||||
filters: [{ name: '视频', extensions: ['mp4'] }],
|
||||
});
|
||||
if (!targetPath) {return;}
|
||||
const res = await invoke<{ code: number; data?: string; message: string }>('export_product', {
|
||||
sourcePath: product.path,
|
||||
targetPath,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
toast.success('导出成功');
|
||||
} else {
|
||||
toast.error(res.message || '导出失败');
|
||||
}
|
||||
} catch {
|
||||
toast.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
@@ -210,6 +235,9 @@ function ProductCard({ product, onDelete, onRename }: {
|
||||
</div>
|
||||
)}
|
||||
<div className="works-card-overlay" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="works-card-action" title="导出" onClick={handleExport}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
|
||||
</button>
|
||||
<button className="works-card-action" title="重命名" onClick={startRename}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></svg>
|
||||
</button>
|
||||
@@ -308,7 +336,7 @@ export default function MyWorks() {
|
||||
|
||||
useEffect(() => {
|
||||
const el = productsGridRef.current;
|
||||
if (!el) return;
|
||||
if (!el) {return;}
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const width = entries[0].contentRect.width;
|
||||
// 卡片最小 200px + gap 16px (var(--spacing-lg))
|
||||
@@ -449,7 +477,7 @@ export default function MyWorks() {
|
||||
{activeTab === 'products' && (
|
||||
<>
|
||||
{products.length > 0 ? (
|
||||
<div ref={productsGridRef} className="works-grid" style={{ overflow: 'auto', flex: 1, minHeight: 0, display: enableProductVirtualization ? 'block' : undefined }}>
|
||||
<div ref={productsGridRef} className="works-grid" style={{ overflow: 'auto', flex: 1, minHeight: 0, display: enableProductVirtualization ? 'block' : 'grid', gridTemplateColumns: enableProductVirtualization ? undefined : `repeat(${productColumns}, 1fr)` }}>
|
||||
{enableProductVirtualization ? (
|
||||
<div style={{ height: `${productsVirtualizer.getTotalSize()}px`, position: 'relative', width: '100%' }}>
|
||||
{productsVirtualizer.getVirtualItems().map(virtualRow => {
|
||||
|
||||
@@ -317,7 +317,7 @@ export default function VoiceMaterialLibrary() {
|
||||
</div>
|
||||
<div className="voice-upload-text">
|
||||
<span className="voice-upload-title">上传音频样本</span>
|
||||
<span className="voice-upload-hint">MP3 / M4A / WAV,建议时长 30s ~ 3min</span>
|
||||
<span className="voice-upload-hint">MP3 / M4A / WAV,建议时长 10 秒 ~ 5 分钟</span>
|
||||
</div>
|
||||
<div className="voice-upload-arrow">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function Settings() {
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes === 0) {return '0 B';}
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
@@ -66,7 +66,7 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
const handleDownloadAndInstall = async () => {
|
||||
if (!updateInfo) return;
|
||||
if (!updateInfo) {return;}
|
||||
|
||||
setDownloading(true);
|
||||
setProgress(0);
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface FileWithPath extends File {
|
||||
path?: string;
|
||||
}
|
||||
import { useCoverAvatarStore } from '../../store';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
@@ -175,7 +179,7 @@ export default function CoverDesign() {
|
||||
// 本地上传背景图
|
||||
const handleLocalBgUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file) {return;}
|
||||
|
||||
// 释放旧的 Blob URL
|
||||
const oldBg = config.backgroundImage;
|
||||
@@ -184,7 +188,7 @@ export default function CoverDesign() {
|
||||
blobUrlsRef.current.delete(oldBg);
|
||||
}
|
||||
|
||||
const path = (file as any).path || (file as any).webkitRelativePath || '';
|
||||
const path = (file as FileWithPath).path || (file as FileWithPath).webkitRelativePath || '';
|
||||
if (!path) {
|
||||
// Tauri 文件选择器通常有 path,如果没有则使用 Object URL
|
||||
const url = URL.createObjectURL(file);
|
||||
@@ -215,9 +219,10 @@ export default function CoverDesign() {
|
||||
|
||||
// 组件卸载时释放所有 Blob URL
|
||||
useEffect(() => {
|
||||
const urls = blobUrlsRef.current;
|
||||
return () => {
|
||||
blobUrlsRef.current.forEach(url => URL.revokeObjectURL(url));
|
||||
blobUrlsRef.current.clear();
|
||||
urls.forEach(url => URL.revokeObjectURL(url));
|
||||
urls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -240,7 +245,7 @@ export default function CoverDesign() {
|
||||
|
||||
// 背景图/形象变化时自动保存 coverConfig(防抖,只监听视觉素材)
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
if (!projectId) {return;}
|
||||
const timer = setTimeout(() => {
|
||||
useProjectStore.getState().setCoverConfig({
|
||||
template: config.template,
|
||||
@@ -251,6 +256,7 @@ export default function CoverDesign() {
|
||||
});
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.backgroundImage, config.avatarImage, projectId]);
|
||||
|
||||
// 配置变化时重新渲染 Canvas
|
||||
@@ -681,7 +687,7 @@ export default function CoverDesign() {
|
||||
} else {
|
||||
toast.error(res.message || '导出失败');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('导出失败');
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { open, save } from '@tauri-apps/plugin-dialog';
|
||||
import { exists } from '@tauri-apps/plugin-fs';
|
||||
import { compositeApi } from '../../api/modules/videoComposite';
|
||||
import { downloadFile } from '../../api/modules/videoCompose';
|
||||
@@ -61,7 +61,7 @@ export default function VideoCompose() {
|
||||
const setBgmVolume = useProjectStore(state => state.setBgmVolume);
|
||||
const [, setBgmLoading] = useState(false);
|
||||
const [bgmModalOpen, setBgmModalOpen] = useState(false);
|
||||
const localBgmRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
|
||||
// 打开弹窗时根据当前选中的 BGM 分类自动切换标签
|
||||
useEffect(() => {
|
||||
@@ -73,7 +73,7 @@ export default function VideoCompose() {
|
||||
setBgmCategory('all');
|
||||
}
|
||||
}
|
||||
}, [bgmModalOpen]);
|
||||
}, [bgmModalOpen, bgmList, bgmMusicId]);
|
||||
|
||||
// 弹窗关闭时停止试听
|
||||
useEffect(() => {
|
||||
@@ -159,15 +159,16 @@ export default function VideoCompose() {
|
||||
}, []);
|
||||
|
||||
// 本地上传 BGM
|
||||
const handleLocalBgmUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const path = (file as any).path || '';
|
||||
if (path) {
|
||||
setBgmMusic(-1, file.name, path);
|
||||
const handleLocalBgmUpload = async () => {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: '音频文件', extensions: ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] }],
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
const fileName = selected.split(/[\\/]/).pop() || '本地音频';
|
||||
setBgmMusic(-1, fileName, selected);
|
||||
setBgmModalOpen(false);
|
||||
}
|
||||
setBgmModalOpen(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const shots = segments || [];
|
||||
@@ -261,7 +262,7 @@ export default function VideoCompose() {
|
||||
const resolvedCoverPath = coverPath;
|
||||
|
||||
// 3. 调用 Rust 合成,直接输出到 products 目录
|
||||
let result = await compositeApi.synthesis({
|
||||
const result = await compositeApi.synthesis({
|
||||
video_paths: videoPaths,
|
||||
cover_path: resolvedCoverPath,
|
||||
output_path: outputPath,
|
||||
@@ -295,7 +296,7 @@ export default function VideoCompose() {
|
||||
},
|
||||
});
|
||||
if (mixRes.code !== 200) {
|
||||
console.warn('BGM 混合失败,使用无 BGM 版本:', mixRes.message);
|
||||
toast.warning('BGM 混合失败,使用无 BGM 版本: ' + mixRes.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,9 +419,21 @@ export default function VideoCompose() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={e => { e.stopPropagation(); setBgmModalOpen(true); }}>
|
||||
更换
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-ghost btn-sm" onClick={e => { e.stopPropagation(); setBgmModalOpen(true); }}>
|
||||
更换
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setBgmMusic(undefined, undefined, undefined);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -779,7 +792,7 @@ export default function VideoCompose() {
|
||||
<div className="modal-bgm-footer">
|
||||
<button
|
||||
className="modal-bgm-upload-btn"
|
||||
onClick={() => localBgmRef.current?.click()}
|
||||
onClick={() => { void handleLocalBgmUpload(); }}
|
||||
>
|
||||
<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-4" />
|
||||
@@ -788,13 +801,7 @@ export default function VideoCompose() {
|
||||
</svg>
|
||||
本地上传
|
||||
</button>
|
||||
<input
|
||||
ref={localBgmRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleLocalBgmUpload}
|
||||
/>
|
||||
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setBgmModalOpen(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -396,7 +396,7 @@ export default function VoiceSynthesis() {
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [projectId, segments, handleAlignAndClip, checkBalance, handleError, estimatedTtsPoints]);
|
||||
}, [projectId, segments, handleAlignAndClip, checkBalance, handleError, estimatedTtsPoints, clearStepDirty]);
|
||||
|
||||
const { navigate } = useNavigation();
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ export function useVideoGeneration({
|
||||
const baseVideoPoints = Math.ceil(totalAudioDuration) * videoMultiplier;
|
||||
// 系统素材额外收费:每个空镜使用的系统素材额外 2 积分
|
||||
const systemMaterialCount = shots.filter((s: ScriptShot) => {
|
||||
if (s.type !== 'empty_shot') return false;
|
||||
if (s.type !== 'empty_shot') {return false;}
|
||||
const matched = materialMatchMap[String(s.id)];
|
||||
return matched && (matched.url.startsWith('http://') || matched.url.startsWith('https://'));
|
||||
}).length;
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function VideoGeneration() {
|
||||
}
|
||||
// 系统素材额外收费:每个空镜使用的系统素材额外 2 积分
|
||||
const systemMaterialCount = shots.filter((s) => {
|
||||
if (s.type !== 'empty_shot') return false;
|
||||
if (s.type !== 'empty_shot') {return false;}
|
||||
const matched = materialMatchMap[String(s.id)];
|
||||
return matched && (matched.url.startsWith('http://') || matched.url.startsWith('https://'));
|
||||
}).length;
|
||||
@@ -326,7 +326,7 @@ export default function VideoGeneration() {
|
||||
// 匹配成功后自动切换到对应镜头并预览,但只处理最新请求的结果,
|
||||
// 避免用户快速连点多个镜头时,后返回的请求把画面切回之前的镜头。
|
||||
const handleMatchMaterial = async (shotId: string) => {
|
||||
if (matchingShotIds.has(shotId)) return;
|
||||
if (matchingShotIds.has(shotId)) {return;}
|
||||
setMatchingShotIds(prev => new Set(prev).add(shotId));
|
||||
|
||||
const requestId = ++matchRequestIdRef.current;
|
||||
|
||||
Reference in New Issue
Block a user