feat: 重排 AI 设置、首次引导弹窗、示例项目与 Studio 编辑器重做

设置
- AI 模型页拆为 AI 服务 / 字幕转写 / 封面 / 高级四节,逻辑抽到 modelSettingsLogic + useModelSettings,
  ProviderFields / ModelPicker 独立组件;首次配置默认开启画面识别与 AI 封面(参考视频画面)
- 供应商分组「模型聚合站」改为「推荐」,保留赞助说明
- 首页首次进入弹出「连接 AI 服务」对话框(FirstRunSetup),未连接时导入被拦下并引导
- 修复对话框内下拉层级、Esc 误关闭

示例项目
- 内置 Sam Altman 访谈三段拼接原片 + 字幕 + 封面(backend/assets/example),
  一键创建已完成项目,携带来源链接与元数据;卡片 / 详情页标出示例与来源

Studio / 发布
- 编辑器右侧面板按 DESIGN.md 重做(DraftSettingsPanel):字幕样式改为全片四种带预览的样式,
  片头文字降为可选并用视觉缩略图选择;左侧播放器吸顶随滚动可见
- 竖屏裁切增加说话人跟随自动取景(YuNet 人脸 + 口部运动,按需安装 OpenCV 运行时),
  渲染支持逐段 crop 轨迹
- 导入确认页去掉重复的分析方式提问,控件统一 Row/Segmented;发布页文案去术语化,
  封面入口补齐并默认自动生成

其他
- 后端 ai-model-settings 文档模型、云端转写、模型目录等配套服务与测试
- 8 种语言文案同步

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
周小舟
2026-09-30 10:26:39 +08:00
co-authored by Cursor
parent 9fc99136ea
commit a19cac4392
83 changed files with 8577 additions and 1492 deletions
+3
View File
@@ -62,4 +62,7 @@ api_router.include_router(account_health_router, tags=["account-health"])
from .studio import router as studio_router
api_router.include_router(studio_router, prefix="/studio", tags=["studio"])
from .example_project import router as example_project_router
api_router.include_router(example_project_router, tags=["example-project"])
__all__ = ["api_router"]
+3 -1
View File
@@ -310,7 +310,7 @@ async def process_download_task(task_id: str, request: BilibiliDownloadRequest,
video_file_path = Path(video_path)
# 根据视频信息选择合适的模型,但始终使用自动语言检测
model = "base" # 默认使用平衡模型
model = None # Use the configured local transcription model
language = "auto" # 始终使用自动语言检测
# 可以根据视频标题或描述判断内容类型,选择不同的模型大小
@@ -321,6 +321,8 @@ async def process_download_task(task_id: str, request: BilibiliDownloadRequest,
logger.info(f"使用Whisper生成字幕 - 语言: {language}, 模型: {model}")
from backend.utils.speech_recognizer import configured_whisper_model
model = configured_whisper_model(model or 'base')
generated_subtitle = generate_subtitle_for_video(
video_file_path,
language=language,
+20 -190
View File
@@ -1,199 +1,29 @@
"""
示例项目API
用于首次运行向导创建示例项目
"""
import json
import os
from pathlib import Path
from typing import Dict, Any
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.orm import Session
from backend.core.database import get_db
from backend.core.desktop_config import get_desktop_config, DesktopConfig
from backend.models.project import Project
from backend.models.clip import Clip
from backend.models.collection import Collection
from backend.repositories.project_repository import ProjectRepository
from backend.repositories.clip_repository import ClipRepository
from backend.repositories.collection_repository import CollectionRepository
"""Example project endpoints: create the bundled, already-finished project on demand."""
import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from backend.core.database import get_db
from backend.services import example_project
logger = logging.getLogger(__name__)
router = APIRouter()
def check_desktop_mode():
"""检查是否在桌面模式下运行"""
if not os.getenv("AUTOCLIP_DESKTOP_MODE"):
raise HTTPException(status_code=403, detail="此功能仅在桌面模式下可用")
@router.post("/example-project/create")
async def create_example_project(
db: Session = Depends(get_db),
config: DesktopConfig = Depends(get_desktop_config)
):
"""创建示例项目"""
check_desktop_mode()
try:
# 检查是否已存在示例项目
project_repo = ProjectRepository(db)
existing_project = project_repo.get_by_name("AutoClip 示例项目")
if existing_project:
return {
"success": True,
"message": "示例项目已存在",
"project_id": existing_project.id
}
# 读取示例项目数据
example_data_path = Path(__file__).parent.parent.parent.parent / "data" / "example_project.json"
if not example_data_path.exists():
raise HTTPException(status_code=404, detail="示例项目数据文件不存在")
with open(example_data_path, 'r', encoding='utf-8') as f:
example_data = json.load(f)
# 创建示例项目
project_data = example_data["project"]
project = Project(
id=project_data["id"],
name=project_data["name"],
description=project_data["description"],
video_path=project_data["video_path"],
srt_path=project_data["srt_path"],
status=project_data["status"],
processing_config={
"chunk_size": 5000,
"min_score_threshold": 0.7,
"max_clips": 5,
"model": "qwen-plus"
}
)
created_project = project_repo.create(project)
# 创建示例片段
clip_repo = ClipRepository(db)
for clip_data in example_data["clips"]:
clip = Clip(
id=clip_data["id"],
project_id=created_project.id,
title=clip_data["title"],
start_time=clip_data["start_time"],
end_time=clip_data["end_time"],
score=clip_data["score"],
reason=clip_data["reason"],
content=clip_data["content"],
status="completed"
)
clip_repo.create(clip)
# 创建示例合集
collection_repo = CollectionRepository(db)
for collection_data in example_data["collections"]:
collection = Collection(
id=collection_data["id"],
project_id=created_project.id,
title=collection_data["title"],
description=collection_data["description"],
clips=collection_data["clips"],
duration=collection_data["duration"],
status="completed"
)
collection_repo.create(collection)
logger.info(f"示例项目创建成功: {created_project.id}")
return {
"success": True,
"message": "示例项目创建成功",
"project_id": created_project.id,
"project": {
"id": created_project.id,
"name": created_project.name,
"description": created_project.description,
"status": created_project.status,
"clips_count": len(example_data["clips"]),
"collections_count": len(example_data["collections"])
}
}
except Exception as e:
logger.error(f"创建示例项目失败: {e}")
raise HTTPException(status_code=500, detail=f"创建示例项目失败: {str(e)}")
@router.get('/example-project')
def example_project_info(db: Session = Depends(get_db)):
existing = example_project.find_existing(db)
return {'available': example_project.available(), 'project_id': existing.id if existing else None}
@router.get("/example-project/info")
async def get_example_project_info():
"""获取示例项目信息"""
check_desktop_mode()
try:
# 读取示例项目数据
example_data_path = Path(__file__).parent.parent.parent.parent / "data" / "example_project.json"
if not example_data_path.exists():
raise HTTPException(status_code=404, detail="示例项目数据文件不存在")
with open(example_data_path, 'r', encoding='utf-8') as f:
example_data = json.load(f)
return {
"success": True,
"project_info": {
"name": example_data["project"]["name"],
"description": example_data["project"]["description"],
"clips_count": len(example_data["clips"]),
"collections_count": len(example_data["collections"]),
"total_duration": sum(clip["end_time"] - clip["start_time"] for clip in example_data["clips"])
}
}
except Exception as e:
logger.error(f"获取示例项目信息失败: {e}")
raise HTTPException(status_code=500, detail=f"获取示例项目信息失败: {str(e)}")
@router.delete("/example-project")
async def delete_example_project(
db: Session = Depends(get_db)
):
"""删除示例项目"""
check_desktop_mode()
@router.post('/example-project/create')
def create_example_project(db: Session = Depends(get_db)):
try:
project_repo = ProjectRepository(db)
example_project = project_repo.get_by_name("AutoClip 示例项目")
if not example_project:
return {
"success": True,
"message": "示例项目不存在"
}
# 删除相关数据
clip_repo = ClipRepository(db)
collection_repo = CollectionRepository(db)
# 删除片段
clips = clip_repo.get_by_project_id(example_project.id)
for clip in clips:
clip_repo.delete(clip.id)
# 删除合集
collections = collection_repo.get_by_project_id(example_project.id)
for collection in collections:
collection_repo.delete(collection.id)
# 删除项目
project_repo.delete(example_project.id)
logger.info(f"示例项目删除成功: {example_project.id}")
return {
"success": True,
"message": "示例项目删除成功"
}
except Exception as e:
logger.error(f"删除示例项目失败: {e}")
raise HTTPException(status_code=500, detail=f"删除示例项目失败: {str(e)}")
project = example_project.create(db)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception:
logger.exception('创建示例项目失败')
raise HTTPException(status_code=500, detail='创建示例项目失败,请稍后重试')
return {'project_id': project.id, 'name': project.name}
+55 -1
View File
@@ -20,6 +20,60 @@ from pathlib import Path
router = APIRouter(prefix="/settings", tags=["settings"])
from backend.services import ai_model_settings as ai_models
@router.get('/ai-models')
def get_ai_models():
return ai_models.public(ai_models.load() or ai_models.migrate_legacy())
@router.put('/ai-models')
def put_ai_models(body: ai_models.ModelSettings):
try:
return ai_models.save(body)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
class ConnectionModelsRequest(BaseModel):
connection: ai_models.Connection
refresh: bool = False
@router.post('/ai-models/discover')
async def discover_connection_models(body: ConnectionModelsRequest):
from backend.core.model_registry import discover
try:
connection = ai_models.resolve_secret(body.connection, ai_models.load() or ai_models.migrate_legacy())
return await discover(connection, body.refresh)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
class ConnectionTestRequest(BaseModel):
connection: ai_models.Connection
model: str = Field(min_length=1, max_length=200)
vision: bool = False
@router.post('/ai-models/test')
def test_connection_assignment(body: ConnectionTestRequest):
from backend.core.llm_providers import LLMProviderFactory, ProviderType
try:
connection = ai_models.resolve_secret(body.connection, ai_models.load() or ai_models.migrate_legacy())
endpoint = ai_models.chat_endpoint(connection, body.model)
if body.vision:
from backend.services.studio.vision_settings import test, VisionSettingsInput
return test(VisionSettingsInput(mode='custom', **endpoint))
provider = LLMProviderFactory.create_provider(ProviderType.OPENAI, endpoint['api_key'],
body.model, base_url=endpoint['base_url'])
return {'success': provider.test_connection()}
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception:
return {'success': False, 'error': '连接测试失败,请检查接口、密钥和模型'}
class BasicSettings(BaseModel):
"""基础设置"""
@@ -878,4 +932,4 @@ async def get_config_status():
return {
"status": "error",
"message": f"获取配置状态失败: {str(e)}"
}
}
+37
View File
@@ -330,6 +330,43 @@ def rewrite(project_id: str, body: RewriteRequest, db: Session = Depends(get_db)
raise HTTPException(502, '生成文案失败,请检查模型设置后重试;原稿未改动') from None
return candidate
@router.get('/{project_id}/subtitles')
def subtitles(project_id: str, db: Session = Depends(get_db)):
"""Subtitle cues in seconds, for the editor's styled preview overlay."""
from backend.pipeline.quality import to_seconds
from backend.services.publish_export import _load_srt_entries
project_or_404(project_id, db)
entries = _load_srt_entries(project_id)
return {'cues': [{'start': to_seconds(e['start_time']), 'end': to_seconds(e['end_time']), 'text': e.get('text', '')} for e in entries]}
@router.get('/framing/status')
def framing_status():
from backend.services.studio import framing
return framing.get_status()
@router.post('/framing/install')
def framing_install():
from backend.services.studio import framing
return {**framing.start_install(), **framing.get_status()}
@router.post('/{project_id}/auto-frame')
def auto_frame(project_id: str, body: Draft, db: Session = Depends(get_db)):
"""Centre each scene's crop window on the speaker. Pure analysis: nothing is saved."""
from backend.services.publish_export import _probe
from backend.services.studio import framing
project_or_404(project_id, db)
if not framing.is_installed():
raise HTTPException(409, '人物识别组件未安装')
video = call(jobs.source, project_id)
info = _probe(video)
if not info.get('width') or not info.get('height'):
raise HTTPException(422, '无法读取原视频尺寸')
try:
return framing.auto_frame(video, body, int(info['width']), int(info['height']))
except Exception as error:
capture_studio_exception(error, 'auto_frame')
raise HTTPException(502, '自动取景失败,可手动调整取景位置') from None
@router.post('/{project_id}/drafts/{draft_id}/export')
def export(project_id: str, draft_id: str, body: ExportDraftRequest | None = None, db: Session = Depends(get_db)):
project_or_404(project_id, db)
+1 -1
View File
@@ -513,7 +513,7 @@ async def process_youtube_download_task(task_id: str, request: YouTubeDownloadRe
video_file_path = Path(video_path)
# 根据视频信息选择合适的模型
model = "base" # 默认使用平衡模型
model = None # Use the configured local transcription model
language = "auto" # 默认自动检测语言
# 可以根据视频标题判断内容类型
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+66
View File
@@ -0,0 +1,66 @@
{
"version": 2,
"project": {
"name": "Sam Altman interview",
"description": "Clips cut automatically from a 68-minute interview imported by link. The bundled source keeps only the three selected passages, stitched back to back, so every editing step still works.",
"source_url": "https://www.youtube.com/watch?v=VeizK1M7V7E&t=2287s",
"video_duration": 531,
"video_category": "default",
"cover": "cover.jpg",
"analysis_mode": "subtitle",
"produced_at": "2026-09-27T23:07:00+08:00",
"source": "source.mp4",
"subtitles": "source.srt",
"original_duration": 4119
},
"clips": [
{
"id": "8",
"title": "We over-diversified, then refocused: how OpenAI fixed its strategy",
"start_time": "00:00:00,000",
"end_time": "00:03:22,452",
"original_start_time": "00:41:30,400",
"original_end_time": "00:44:52,790",
"final_score": 0.91,
"outline": "Leadership, Execution, and Course Correction: Lessons from the Last 12 Months",
"recommend_reason": "Rare CEO-level accountability meets razor-sharp strategic clarity—turns failure into a compelling narrative of elite execution.",
"content": [
"Acknowledging missteps: over-diversification on the product side (browser, Sora) at the expense of pre-training and core intelligence capability",
"Strategic pivot: ruthless focus on becoming “an intelligent service to people,” with models now world-leading and accelerating",
"Leadership structure post-Fiji Simo: Greg Brockman and the CEO co-leading; decision-making evolving from “try fast” to “measure twice, cut once” at scale"
]
},
{
"id": "11",
"title": "Why your chats with AI deserve legal privilege",
"start_time": "00:03:22,452",
"end_time": "00:07:11,471",
"original_start_time": "01:00:01,680",
"original_end_time": "01:03:50,630",
"final_score": 0.96,
"outline": "AI Privacy Principles and the Case for AI Privilege Law",
"recommend_reason": "Bold, legally grounded, and urgently relevant—introduces 'AI privilege' as a viral-ready concept that reframes privacy as a fundamental human right.",
"content": [
"Privacy commitments: no training on business data, zero data retention options",
"The case for an AI privilege law: conversations with AI should be protected like those with doctors or lawyers",
"Why this matters now as people bring their most sensitive questions to AI"
]
},
{
"id": "13",
"title": "Superintelligence won’t erase humanity — the real risk is the next 12 months",
"start_time": "00:07:11,471",
"end_time": "00:08:51,302",
"original_start_time": "01:05:47,839",
"original_end_time": "01:07:27,589",
"final_score": 0.89,
"outline": "Human-Centered Vision of Superintelligence and Near-Term Risk Prioritization",
"recommend_reason": "Striking emotional contrast—grounds existential AI discourse in warmth, humor, and tangible humanity while naming near-term risks with surgical clarity.",
"content": [
"Building toward superintelligence while keeping people the main character",
"Everyday humanity persists: family, friends, and small joys don’t disappear",
"The near-term priority: getting safety and alignment right in the next 12 months"
]
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Shiqi Yu <shiqi.yu@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+36
View File
@@ -0,0 +1,36 @@
"""Verified ASR routing, 2026-09-29. Only timestamp-capable adapters are selectable.
Sources and intentionally unsupported integrations: docs/AI_MODEL_CONFIGURATION.md.
The public/account catalog still determines availability, not this routing table.
"""
MODELS = {
'openai': ['whisper-1', 'gpt-4o-transcribe-diarize', 'gpt-4o-transcribe', 'gpt-4o-mini-transcribe'],
'dashscope': ['qwen-audio-3.1-asr-flash', 'qwen-audio-3.0-asr-flash', 'fun-asr-flash-2026-06-15',
'qwen-audio-3.1-asr-flash-filetrans', 'qwen-audio-3.0-asr-flash-filetrans',
'qwen3-asr-flash-filetrans', 'qwen3-asr-flash', 'fun-asr', 'paraformer-v2'],
'glm': ['glm-asr-2512'],
'siliconflow': ['FunAudioLLM/SenseVoiceSmall'],
}
DASHSCOPE_SYNC = frozenset(MODELS['dashscope'][:3])
def adapter(provider, model):
if provider == 'dashscope' and model in DASHSCOPE_SYNC:
return 'dashscope'
if provider in {'openai', 'infistar', 'compatible'}:
if model == 'gpt-4o-transcribe-diarize':
return 'diarized'
if model == 'whisper-1' or provider == 'compatible' and model not in MODELS['openai'][2:]:
return 'openai'
return None
def metadata(provider, model, endpoints=()):
known = model in MODELS.get(provider, []) or 'audio-transcription' in endpoints or '/v1/audio/transcriptions' in endpoints
route = adapter(provider, model)
reason = None
if known and not route:
reason = ('需要公网音频地址,暂未接入' if 'filetrans' in model or model in {'fun-asr', 'paraformer-v2'} else
'实时转写接口,暂未接入' if 'streaming' in model else
'字幕时间戳接口尚未适配')
return {'asr': bool(known or (route and provider != 'compatible')), 'asr_supported': bool(route), 'asr_note': reason}
+38
View File
@@ -0,0 +1,38 @@
"""Verified public image model IDs. Supplements live discovery; not an account allowlist.
Checked 2026-09-29. Keep exact provider IDs: gateway aliases are not official IDs.
Sources are retained so catalog additions can be reviewed against provider docs.
"""
SOURCES = {
'seed': 'https://docs.volcengine.com/docs/ark/model-release-announcement?lang=zh',
'dashscope': 'https://help.aliyun.com/en/model-studio/image-model/',
'openai': 'https://developers.openai.com/api/docs/guides/image-generation',
'gemini': 'https://ai.google.dev/gemini-api/docs/image-generation',
'grok': 'https://docs.x.ai/developers/model-capabilities/images/generation',
'glm': 'https://docs.bigmodel.cn/cn/guide/models/image-generation/glm-image',
}
MODELS = {
'seed': ['doubao-seedream-5-0-flash-260915', 'doubao-seedream-5-0-pro-260628', 'doubao-seedream-4-0-20260415'],
'openai': ['gpt-image-2.5-flare', 'gpt-image-2.5-sunburst', 'gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini', 'chatgpt-image-latest'],
'gemini': ['gemini-3.1-flash-lite-image', 'gemini-3.1-flash-image', 'gemini-3-pro-image', 'gemini-2.5-flash-image'],
'grok': ['grok-imagine-image-2.0', 'grok-imagine-image', 'grok-imagine-image-quality'],
'glm': ['glm-image', 'cogview-4-250304', 'cogview-4', 'cogview-3-flash'],
'dashscope': [
'qwen-image-3.0-pro', 'qwen-image-3.0', 'qwen-image-2.0-pro', 'qwen-image-2.0',
'qwen-image-2.0-pro-2026-06-22', 'qwen-image-2.0-pro-2026-04-22', 'qwen-image-2.0-pro-2026-03-03', 'qwen-image-2.0-2026-03-03',
'qwen-image-max', 'qwen-image-max-2025-12-30', 'qwen-image-plus', 'qwen-image-plus-2026-01-09', 'qwen-image',
'wan2.7-image-pro', 'wan2.7-image', 'wan2.6-t2i', 'wan2.6-image', 'wan2.5-t2i-preview',
'wan2.2-t2i-plus', 'wan2.2-t2i-flash', 'wan2.1-t2i-plus', 'wan2.1-t2i-turbo', 'z-image-turbo',
],
}
# These are edit-only in the official API, even if a gateway exposes a generation route.
EDIT_ONLY = {'dashscope': {'qwen-image-edit', 'qwen-image-edit-plus', 'qwen-image-edit-max',
'qwen-image-edit-max-2026-01-16', 'qwen-image-edit-plus-2025-12-15', 'qwen-image-edit-plus-2025-10-30', 'wan2.5-i2i-preview', 'wanx2.1-imageedit'}}
# Research agents output diagrams through Interactions, not the model image API.
# https://ai.google.dev/gemini-api/docs/models/deep-research-preview-04-2026
UNSUPPORTED_ROUTES = {'gemini': {'deep-research-preview-04-2026', 'deep-research-max-preview-04-2026'}}
# Removed from the official service. Existing explicit configurations remain editable.
RETIRED = {
'seed': {'doubao-seedream-4-0-250828', 'doubao-seedream-4-5-251128', 'doubao-seedream-5-0-lite-260128', 'doubao-seedream-5-0-260128'},
'gemini': {'imagen-3.0-generate-002', 'imagen-4.0-generate-001', 'imagen-4.0-ultra-generate-001', 'imagen-4.0-fast-generate-001'},
}
+82 -2
View File
@@ -15,6 +15,7 @@ import logging
import time
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
import requests
@@ -295,6 +296,10 @@ def _dashscope_bytes(data: dict[str, Any], session: requests.Session) -> bytes |
url = str(item.get("url") or "")
if url.startswith(("http://", "https://")):
return _download(session, url)
for choice in output.get('choices') or []:
for part in (choice.get('message') or {}).get('content') or []:
if isinstance(part, dict) and str(part.get('image', '')).startswith(('https://', 'http://')):
return _download(session, part['image'])
if status == "SUCCEEDED":
raise ImageError("生图没有返回图片")
return None
@@ -313,20 +318,54 @@ def generate_dashscope(
http = _session_for(root, session)
headers = {**_bearer(api_key, base_url), "X-DashScope-Async": "enable"}
model = request.model.strip() or "wanx2.1-t2i-turbo"
modern_qwen = model.startswith('qwen-image') or model == 'z-image-turbo'
modern_wan = model.startswith(('wan2.7-image', 'wan2.6-image', 'wan2.6-t2i'))
if modern_qwen or modern_wan:
content = [{'text': request.prompt}]
if request.reference:
if model in {'qwen-image', 'z-image-turbo'} or model.startswith(('qwen-image-plus', 'qwen-image-max')):
raise ImageError('该模型不支持参考帧', unsupported_edit=True)
content.insert(0, {'image': _reference_data_uri(request.reference)})
if modern_qwen:
headers.pop('X-DashScope-Async', None)
route = 'image-generation' if modern_wan else 'multimodal-generation'
size = '928*1664' if request.height > request.width else '1664*928'
if model.startswith('wan2.6-t2i'):
size = '960*1696' if request.height > request.width else '1696*960'
resp = http.post(f'{root}/services/aigc/{route}/generation',
headers={**headers, 'Content-Type': 'application/json'},
json={'model': model, 'input': {'messages': [{'role': 'user', 'content': content}]},
'parameters': {'size': size, 'n': 1}}, timeout=90)
else:
return _generate_legacy_dashscope(api_key=api_key, base_url=base_url, request=request, session=http,
cancel=cancel, poll_interval=poll_interval)
return _wait_dashscope(_raise_for_status(resp, edit=bool(request.reference)), http, root, api_key,
cancel=cancel, poll_interval=poll_interval)
def _generate_legacy_dashscope(*, api_key, base_url, request, session, cancel, poll_interval):
root, http = dashscope_root(base_url), session
headers = {**_bearer(api_key, base_url), 'X-DashScope-Async': 'enable'}
model = request.model.strip() or 'wanx2.1-t2i-turbo'
image_input: dict[str, Any] = {"prompt": request.prompt}
if request.reference:
image_input["ref_img"] = _reference_data_uri(request.reference)
size = ('960*1696' if request.height > request.width else '1696*960') if model.startswith('wan2.5-t2i') else dashscope_size(request.width, request.height)
resp = http.post(
f"{root}/services/aigc/text2image/image-synthesis",
headers={**headers, "Content-Type": "application/json"},
json={
"model": model,
"input": image_input,
"parameters": {"size": dashscope_size(request.width, request.height), "n": 1},
"parameters": {"size": size, "n": 1},
},
timeout=60,
)
data = _raise_for_status(resp, edit=bool(request.reference))
return _wait_dashscope(data, http, root, api_key, cancel=cancel, poll_interval=poll_interval)
def _wait_dashscope(data, http, root, api_key, *, cancel, poll_interval):
ready = _dashscope_bytes(data, http)
if ready is not None:
return ready
@@ -341,13 +380,50 @@ def generate_dashscope(
time.sleep(poll_interval)
if cancel is not None and cancel.is_set():
raise ImageError("已取消")
polled = http.get(f"{root}/tasks/{task_id}", headers=_bearer(api_key, base_url), timeout=30)
polled = http.get(f"{root}/tasks/{task_id}", headers=_bearer(api_key, root), timeout=30)
ready = _dashscope_bytes(_raise_for_status(polled), http)
if ready is not None:
return ready
raise ImageError("生图超时")
def generate_gemini(*, api_key, base_url, request, session=None):
root = (normalize_base_url(base_url) or 'https://generativelanguage.googleapis.com/v1beta').removesuffix('/openai')
http = _session_for(root, session)
parts = [{'text': request.prompt}]
if request.reference:
parts.insert(0, {'inlineData': {'mimeType': 'image/jpeg', 'data': base64.b64encode(request.reference).decode('ascii')}})
response = http.post(f'{root}/models/{quote(request.model, safe="")}:generateContent',
headers={'x-goog-api-key': api_key, 'Content-Type': 'application/json'},
json={'contents': [{'role': 'user', 'parts': parts}], 'generationConfig': {
'responseModalities': ['TEXT', 'IMAGE'], 'imageConfig': {'aspectRatio': '9:16' if request.height > request.width else '16:9'}}}, timeout=120)
data = _raise_for_status(response, edit=bool(request.reference))
for candidate in data.get('candidates') or []:
for part in (candidate.get('content') or {}).get('parts') or []:
inline = part.get('inlineData') or part.get('inline_data') or {}
if inline.get('data') and str(inline.get('mimeType') or inline.get('mime_type') or '').startswith('image/'):
return _decode_b64(inline['data'])
raise ImageError('生图没有返回图片')
def generate_vendor_image(*, provider, api_key, base_url, request, session=None):
root = normalize_base_url(base_url) or ('https://api.x.ai/v1' if provider == 'grok' else 'https://open.bigmodel.cn/api/paas/v4')
http = _session_for(root, session)
body = {'model': request.model, 'prompt': request.prompt}
route = 'generations'
if provider == 'grok':
body.update(aspect_ratio='9:16' if request.height > request.width else '16:9', response_format='b64_json', n=1)
if request.reference:
route = 'edits'
body['image'] = {'url': _reference_data_uri(request.reference)}
else:
if request.reference:
raise ImageError('该模型不支持参考帧', unsupported_edit=True)
body['size'] = '960x1728' if request.height > request.width else '1728x960'
response = http.post(f'{root}/images/{route}', headers={**_bearer(api_key, root), 'Content-Type': 'application/json'}, json=body, timeout=120)
return _openai_image(_raise_for_status(response, edit=bool(request.reference)), http)
def generate_image(
*,
provider: str,
@@ -360,6 +436,10 @@ def generate_image(
) -> bytes:
kind = (provider or "openai").strip().lower()
logger.info("封面生图 provider=%s model=%s reference=%s", kind, request.model or "-", bool(request.reference))
if kind == 'gemini':
return generate_gemini(api_key=api_key, base_url=base_url, request=request, session=session)
if kind in {'grok', 'glm'}:
return generate_vendor_image(provider=kind, api_key=api_key, base_url=base_url, request=request, session=session)
if kind == "dashscope":
return generate_dashscope(
api_key=api_key,
File diff suppressed because it is too large Load Diff
+23 -5
View File
@@ -28,11 +28,14 @@ class LLMManager:
self.settings = self._load_settings()
self._initialize_provider()
def _current_settings_mtime(self) -> Optional[float]:
try:
return self.settings_file.stat().st_mtime
except OSError:
return None
def _current_settings_mtime(self):
from backend.services.ai_model_settings import path as ai_path
def stamp(path):
try:
return path.stat().st_mtime_ns
except OSError:
return None
return (stamp(self.settings_file), stamp(ai_path()))
def _reload_if_settings_changed(self) -> None:
"""设置页保存后 settings.json 会变;API 进程与 Celery worker 都要在下一次调用时拿到新配置,
@@ -158,6 +161,18 @@ class LLMManager:
self._apply_env_fallbacks(default_settings)
self._apply_local_preset(default_settings)
self._apply_cloud_preset(default_settings)
from backend.services import ai_model_settings as ai
configured = ai.load()
if configured and configured.analysis:
binding = configured.analysis
connection = ai.connection_for(configured, binding)
endpoint = ai.chat_endpoint(connection, binding.model)
default_settings.update(llm_provider='openai', cloud_preset=None, llm_provider_preset=None,
openai_api_key=endpoint['api_key'], openai_base_url=endpoint['base_url'],
model_name=binding.model, connection_provider=connection.provider,
connection_name=connection.name, chunk_size=configured.chunk_size,
min_score_threshold=configured.min_score_threshold,
max_clips_per_collection=configured.max_clips_per_collection)
return default_settings
def _apply_local_preset(self, settings: Dict[str, Any]) -> None:
@@ -472,6 +487,9 @@ class LLMManager:
base_url = self._get_provider_kwargs(provider_type).get("base_url")
if base_url:
info["base_url"] = base_url
if self.settings.get('connection_provider'):
info['provider'] = self.settings['connection_provider']
info['display_name'] = self.settings['connection_name']
return info
def _get_provider_display_name(self, provider_type: ProviderType) -> str:
+3
View File
@@ -123,6 +123,9 @@ _TEXT_ONLY_MARKERS = ("deepseek", "-code", "coder", "embedding", "-instruct-text
def supports_vision(model: str) -> bool:
name = (model or "").strip().lower()
# Explicitly verified official model IDs; these no longer carry a -vl suffix.
if name in {'qwen3.8-max', 'qwen3.8-flash'}:
return True
if not name or any(m in name for m in _TEXT_ONLY_MARKERS):
return False
return any(m in name for m in _VISION_MARKERS)
+307
View File
@@ -0,0 +1,307 @@
"""Dynamic discovery plus independently refreshed capability metadata.
Never infer 'text only' from an unfamiliar ID. Provider metadata is scoped to
the actual endpoint; a community catalog supplements exact known IDs only.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import threading
import time
import uuid
from pathlib import Path
from backend.core import asr_model_catalog as asr_catalog
from backend.core import model_catalog
from backend.core import image_model_catalog as image_catalog
from backend.core.path_utils import get_data_directory
_lock = threading.RLock()
_last_remote_attempt = 0.0
_remote_task = None
_MODEL_PROVIDER = {'dashscope': 'alibaba', 'seed': 'volcengine', 'gemini': 'google', 'kimi': 'moonshotai', 'glm': 'zhipuai', 'grok': 'xai'}
# Verified against Alibaba's official text-generation documentation, 2026-09-29.
# https://www.alibabacloud.com/help/en/model-studio/text-generation
_VERIFIED = {('dashscope', name): 'multimodal' for name in ('qwen3.8-max', 'qwen3.8-flash')}
def _path():
return get_data_directory() / 'model-catalog-cache.json'
def _read():
try:
data = json.loads(_path().read_text(encoding='utf-8'))
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _update(key, value):
with _lock:
data = _read()
data[key] = value
target = _path()
target.parent.mkdir(parents=True, exist_ok=True)
temp = target.with_suffix('.' + uuid.uuid4().hex + '.tmp')
try:
temp.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8')
os.replace(temp, target)
finally:
temp.unlink(missing_ok=True)
def _scope(connection):
from backend.services.ai_model_settings import chat_endpoint
endpoint = chat_endpoint(connection, '')
# Credentials never leave the provider, and are never stored in this cache.
identity = '\0'.join((connection.provider, endpoint['base_url'], endpoint['api_key']))
return 'connection:v4:' + hashlib.sha256(identity.encode()).hexdigest()
def _infistar_record(item):
endpoints = item.get('supported_endpoint_types', [])
return {'id': item.get('model_name') or item.get('id'),
'supported_endpoint_types': endpoints,
'capability': item.get('capability') or ('multimodal' if '图像理解' in item.get('tags', '').split(',') else None)}
async def _infistar_public(refresh=False):
"""Public Model Square, never account data or credentials. Store only routing metadata."""
cached = _read().get('public:infistar', {})
now = time.time()
if not refresh and now - cached.get('attempted_at', 0) < (3600 if not cached.get('stale') else 300):
return cached
try:
payload = await model_catalog._http_get_json('https://infistar.cc/api/pricing')
raw = payload.get('data')
if payload.get('success') is not True or not isinstance(raw, list) or not raw:
raise ValueError('Invalid public catalog')
models = [_infistar_record(item) for item in raw if isinstance(item, dict) and item.get('model_name')]
if not models:
raise ValueError('Empty public catalog')
value = {'models': models, 'updated_at': now, 'attempted_at': now, 'stale': False}
except Exception:
models = cached.get('models') or json.loads(Path(__file__).with_name('infistar_models.json').read_text())['models']
value = {'models': models, 'updated_at': cached.get('updated_at'), 'attempted_at': now, 'stale': True}
_update('public:infistar', value)
return value
def _is_infistar(connection):
from urllib.parse import urlparse
from backend.services.ai_model_settings import chat_endpoint
return connection.provider == 'infistar' and urlparse(chat_endpoint(connection, '')['base_url']).hostname in {'infistar.cc', 'www.infistar.cc', 'infistar.ai', 'www.infistar.ai'}
def _with_public_metadata(connection, raw, public):
if not public:
return raw
by_id = {item['id']: item for item in public['models']}
# Exact ID intersection: never add a public-only model to an account's list.
return [{**by_id.get(item if isinstance(item, str) else item.get('id'), {}),
**({'id': item} if isinstance(item, str) else item)} for item in raw if isinstance(item, (str, dict))]
def _modalities(item):
architecture = item.get('architecture') or {}
modalities = item.get('modalities') or {}
inputs = architecture.get('input_modalities', modalities.get('input'))
outputs = architecture.get('output_modalities', modalities.get('output'))
return inputs if isinstance(inputs, list) else None, outputs if isinstance(outputs, list) else None
def _cap(inputs):
if inputs and 'image' in inputs:
return 'multimodal'
if inputs == ['text']:
return 'text'
return None
def lookup_capability(connection, model):
data = _read()
entry = next((m for m in data.get(_scope(connection), {}).get('models', []) if m['id'] == model), None)
if entry and entry.get('capability_source') == 'provider' and entry.get('capability'):
return entry['capability']
return _catalog_capability(connection.provider, model, data)
def _catalog_record(provider, model, data):
# Explicit provider+ID match only. Gateways can return modalities themselves;
# arbitrary aliases must not be guessed from a substring of the model name.
providers = data.get('metadata', {}).get('providers', {})
return providers.get(_MODEL_PROVIDER.get(provider, provider), {}).get(model, {})
def _catalog_capability(provider, model, data):
known = _VERIFIED.get((provider, model)) or _catalog_record(provider, model, data).get('capability')
if not known and provider == 'infistar':
# Public gateway IDs can match original vendors exactly. Require agreement;
# do not infer a capability from a name prefix or missing image tags.
matches = {models[model]['capability'] for models in data.get('metadata', {}).get('providers', {}).values()
if model in models and models[model].get('capability')}
if len(matches) == 1:
known = matches.pop()
return known
async def _refresh_metadata():
global _last_remote_attempt
existing = _read().get('metadata', {})
now = time.time()
if now - existing.get('updated_at', 0) < 86400 or now - _last_remote_attempt < 300:
return
_last_remote_attempt = now
try:
payload = await model_catalog._http_get_json('https://models.dev/api.json')
providers = {}
for provider, info in payload.items():
if not isinstance(info, dict):
continue
models = {}
for model_id, item in (info.get('models') or {}).items():
if not isinstance(item, dict):
continue
inputs, outputs = _modalities(item)
models[model_id] = {'capability': _cap(inputs), 'image_output': bool(outputs and 'image' in outputs),
'source': 'https://models.dev/api.json'}
providers[provider] = models
if providers:
_update('metadata', {'updated_at': now, 'providers': providers})
except Exception:
# Metadata outages must not discard working lists or block custom models.
pass
async def _ensure_metadata():
global _remote_task
if _remote_task is None or _remote_task.done():
_remote_task = asyncio.create_task(_refresh_metadata())
await asyncio.shield(_remote_task)
def _records(connection, raw, data):
result = []
seen = set()
for item in raw:
if isinstance(item, str):
item = {'id': item}
if not isinstance(item, dict):
continue
name = str(item.get('id') or item.get('name') or '').removeprefix('models/')
if not name or name in seen:
continue
seen.add(name)
inputs, outputs = _modalities(item)
caps = item.get('capabilities')
if isinstance(caps, list) and 'vision' in caps:
inputs = ['text', 'image']
capability = _cap(inputs) or item.get('capability')
source = 'provider' if capability else 'catalog'
capability = capability or _catalog_capability(connection.provider, name, data)
catalog = _catalog_record(connection.provider, name, data)
image = ('image' in outputs if outputs is not None else
catalog.get('image_output', False) or name in image_catalog.MODELS.get(connection.provider, []) or name in model_catalog.IMAGE_MODELS.get(connection.provider, []))
if name in image_catalog.EDIT_ONLY.get(connection.provider, set()):
image = False
methods = item.get('supportedGenerationMethods', [])
analysis = ('text' in outputs if outputs else model_catalog.is_chat_model(name, '', official=False) and not image)
if methods and 'generateContent' not in methods:
analysis = False
if connection.provider == 'gemini':
image = False
if name in image_catalog.UNSUPPORTED_ROUTES.get(connection.provider, set()):
analysis = image = False
if 'supported_endpoint_types' in item:
# Provider routing metadata distinguishes generation from editing.
endpoints = item['supported_endpoint_types']
image = 'image-generation' in endpoints or '/v1/images/generations' in endpoints
analysis = 'openai' in endpoints or '/v1/chat/completions' in endpoints
asr = asr_catalog.metadata(connection.provider, name, item.get('supported_endpoint_types', []))
if asr['asr']:
analysis = False
result.append({**asr, 'id': name, 'capability': capability, 'capability_source': source if capability else None,
'analysis': analysis, 'image': image, 'asr_preview': bool(item.get('asr_preview'))})
return result
async def discover(connection, refresh=False):
from backend.services.ai_model_settings import chat_endpoint
from backend.core.llm_providers import is_local_url
endpoint = chat_endpoint(connection, '')
public = await _infistar_public(refresh) if _is_infistar(connection) else None
local = connection.provider in {'ollama', 'lmstudio'} or (connection.base_url and is_local_url(connection.base_url))
if not endpoint['api_key'] and not local:
if public:
raw = public['models']
updated_at = public['updated_at']
else:
await _ensure_metadata()
data = _read()
remote = data.get('metadata', {}).get('providers', {}).get(_MODEL_PROVIDER.get(connection.provider, connection.provider), {}) if not connection.base_url else {}
raw = [name for name in dict.fromkeys(model_catalog.CURATED_MODELS.get(connection.provider, []) + image_catalog.MODELS.get(connection.provider, []) + asr_catalog.MODELS.get(connection.provider, []) + list(remote))
if name not in image_catalog.RETIRED.get(connection.provider, set())]
updated_at = data.get('metadata', {}).get('updated_at')
return {'models': _records(connection, raw, _read()), 'source': 'catalog', 'preview': True, 'updated_at': updated_at,
'warning': '公开目录预览,填写 API Key 后确认账号可用模型。' + (' 当前展示缓存目录。' if public and public.get('stale') else '')}
scope = _scope(connection)
cached = _read().get(scope)
if cached and not refresh and time.time() - cached.get('updated_at', 0) < 300:
return {**cached, 'source': 'cache'}
async def fetch():
if connection.provider == 'gemini' and not connection.base_url:
raw, token = [], None
while True:
params = {'key': endpoint['api_key'], 'pageSize': 200}
if token:
params['pageToken'] = token
payload = await model_catalog._http_get_json(model_catalog.GEMINI_MODELS_URL, params=params)
raw.extend(payload.get('models', []))
token = payload.get('nextPageToken')
if not token:
return raw
headers = {'Authorization': 'Bearer ' + endpoint['api_key']} if endpoint['api_key'] else {}
payload = await model_catalog._http_get_json(endpoint['base_url'] + '/models', headers=headers,
trust_env=not is_local_url(endpoint['base_url']))
raw = payload.get('data', []) if isinstance(payload, dict) else payload
if not isinstance(raw, list):
raise ValueError('Invalid model list')
if connection.provider == 'ollama':
import httpx
root = endpoint['base_url'].removesuffix('/v1')
semaphore = asyncio.Semaphore(4)
async with httpx.AsyncClient(timeout=8, trust_env=not is_local_url(root)) as client:
async def details(item):
if not isinstance(item, dict) or not item.get('id'):
return item
try:
async with semaphore:
response = await client.post(root + '/api/show', json={'model': item['id']}, headers=headers)
response.raise_for_status()
return {**item, 'capabilities': response.json().get('capabilities')}
except Exception:
return item
raw = await asyncio.gather(*(details(item) for item in raw))
return raw
results = await asyncio.gather(fetch(), _ensure_metadata(), return_exceptions=True)
raw = results[0]
if isinstance(raw, BaseException):
if cached:
return {**cached, 'source': 'cache', 'warning': '模型列表刷新失败,正在使用上次成功的列表'}
fallback = public['models'] if public else model_catalog.CURATED_MODELS.get(connection.provider, []) + image_catalog.MODELS.get(connection.provider, []) + asr_catalog.MODELS.get(connection.provider, [])
return {'models': _records(connection, fallback, _read()), 'source': 'catalog', 'updated_at': None,
'preview': True, 'warning': '账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。'}
if connection.provider == 'dashscope':
# The OpenAI-compatible /models endpoint does not enumerate all native ASR APIs.
# Keep native ASR references visibly unverified, rather than calling them account-entitled.
ids = {item if isinstance(item, str) else item.get('id') for item in raw}
raw = [*raw, *({'id': name, 'asr_preview': True} for name in asr_catalog.MODELS['dashscope'] if name not in ids)]
value = {'models': _records(connection, _with_public_metadata(connection, raw, public), _read()), 'source': 'live', 'updated_at': time.time()}
_update(scope, value)
return value
+289
View File
@@ -0,0 +1,289 @@
"""Named connections and independent model assignments, saved as one atomic document.
Legacy settings remain untouched until the user saves this document. Runtime readers
use it when present, otherwise retain their original settings/environment behavior.
"""
from __future__ import annotations
import json
import os
import threading
import uuid
from typing import Literal
from urllib.parse import urlparse
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from backend.core.path_utils import get_data_directory
_lock = threading.RLock()
Capability = Literal['auto', 'multimodal', 'text']
class Connection(BaseModel):
model_config = ConfigDict(extra='ignore')
id: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=100)
provider: str = 'openai'
base_url: str = ''
api_key: str | None = Field(default=None, max_length=2000)
image_api: Literal['auto', 'openai', 'seedream', 'dashscope'] = 'auto'
image_base_url: str = ''
@field_validator('provider')
@classmethod
def provider_supported(cls, value):
from backend.core.cloud_presets import CLOUD_PRESETS
if value not in {'dashscope', 'openai', 'compatible', 'gemini', 'siliconflow', 'ollama', 'lmstudio', *CLOUD_PRESETS}:
raise ValueError('请选择支持的服务类型;自定义服务请选择 OpenAI 兼容')
return value
@model_validator(mode='after')
def custom_requires_url(self):
if self.provider == 'compatible' and not self.base_url:
raise ValueError('请填写自定义服务的接口地址')
return self
@field_validator('base_url', 'image_base_url')
@classmethod
def valid_url(cls, value):
value = value.strip().rstrip('/')
if value:
parsed = urlparse(value)
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError('请填写不含密钥或查询参数的 HTTP(S) 接口地址')
return value
@field_validator('name', 'id')
@classmethod
def nonblank(cls, value):
if not value.strip():
raise ValueError('名称不能为空')
return value.strip()
class Assignment(BaseModel):
connection_id: str
model: str = Field(min_length=1, max_length=200)
capability: Capability = 'auto'
@field_validator('model')
@classmethod
def model_not_blank(cls, value):
if not value.strip():
raise ValueError('请选择或输入模型 ID')
return value.strip()
class Transcription(BaseModel):
provider: Literal['whisper_local', 'cloud'] = 'whisper_local'
model: str = Field(default='base', min_length=1, max_length=200)
connection_id: str | None = None
@model_validator(mode='after')
def valid_selection(self):
self.model = self.model.strip()
if not self.model:
raise ValueError('请选择转写模型')
if self.provider == 'whisper_local':
if self.model not in {'tiny', 'base', 'small', 'medium', 'large', 'large-v3'}:
raise ValueError('请选择本地 Whisper 模型')
self.connection_id = None
elif not self.connection_id:
raise ValueError('请选择转写供应商')
return self
class ModelSettings(BaseModel):
version: Literal[1] = 1
connections: list[Connection] = Field(default_factory=list, max_length=100)
analysis: Assignment | None = None
# None explicitly means reuse the analysis assignment, not an independently changing provider.
vision: Assignment | None = None
cover: Assignment | None = None
transcription: Transcription | None = None
cover_enabled: bool = False
allow_send_frame: bool = False
cover_ocr_model: str = ''
vision_timeout: int = Field(default=180, ge=10, le=300)
analysis_mode: Literal['auto', 'subtitle', 'visual'] = 'auto'
allow_visual_screening: bool = True
chunk_size: int = Field(default=5000, ge=1000, le=10000)
min_score_threshold: float = Field(default=.7, ge=.1, le=1)
max_clips_per_collection: int = Field(default=5, ge=1, le=20)
@model_validator(mode='after')
def consistent(self):
ids = [c.id for c in self.connections]
if len(ids) != len(set(ids)):
raise ValueError('服务连接 ID 不能重复')
for binding in (self.analysis, self.vision, self.cover):
if binding and binding.connection_id not in ids:
raise ValueError('模型引用的服务已被移除,请先重新选择模型')
if self.transcription and self.transcription.provider == 'cloud':
connection = next((c for c in self.connections if c.id == self.transcription.connection_id), None)
if not connection:
raise ValueError('转写模型引用的服务已被移除')
from backend.core.asr_model_catalog import adapter
if not adapter(connection.provider, self.transcription.model):
raise ValueError('该 ASR 模型的字幕时间戳接口尚未适配,请选择其他转写模型')
if self.cover_enabled and not self.cover:
raise ValueError('请选择封面生图模型')
if self.analysis_mode == 'subtitle':
self.allow_visual_screening = False
return self
def path():
return get_data_directory() / 'ai-model-settings.json'
def load() -> ModelSettings | None:
with _lock:
target = path()
if not target.exists():
return None
return ModelSettings.model_validate_json(target.read_text(encoding='utf-8'))
def connection_for(settings: ModelSettings, assignment: Assignment) -> Connection:
return next(c for c in settings.connections if c.id == assignment.connection_id)
def chat_endpoint(connection: Connection, model: str) -> dict:
from backend.core.cloud_presets import CLOUD_PRESETS
from backend.core.local_presets import LOCAL_PRESETS
defaults = {
'openai': 'https://api.openai.com/v1',
'dashscope': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
'gemini': 'https://generativelanguage.googleapis.com/v1beta/openai',
'siliconflow': 'https://api.siliconflow.cn/v1',
**{key: preset.base_url for key, preset in CLOUD_PRESETS.items()},
**{key: preset.base_url for key, preset in LOCAL_PRESETS.items()},
}
return {'base_url': connection.base_url or defaults[connection.provider],
'api_key': connection.api_key or '', 'model': model}
def image_endpoint(connection: Connection) -> dict:
kind = connection.image_api
if kind == 'auto':
kind = {'dashscope': 'dashscope', 'seed': 'seedream', 'gemini': 'gemini', 'grok': 'grok', 'glm': 'glm'}.get(connection.provider, 'openai')
base = connection.image_base_url
if not base:
if connection.provider == 'dashscope' and kind == 'dashscope':
base = ('https://dashscope-intl.aliyuncs.com/api/v1' if 'dashscope-intl' in connection.base_url
else 'https://dashscope.aliyuncs.com/api/v1')
elif kind == 'gemini':
base = connection.base_url.removesuffix('/openai') if connection.base_url else 'https://generativelanguage.googleapis.com/v1beta'
else:
base = chat_endpoint(connection, '')['base_url']
return {'provider': kind, 'base_url': base, 'api_key': connection.api_key or ''}
def capability(settings: ModelSettings, assignment: Assignment) -> str | None:
if assignment.capability != 'auto':
return assignment.capability
from backend.core.model_registry import lookup_capability
return lookup_capability(connection_for(settings, assignment), assignment.model)
def vision_endpoint(settings: ModelSettings) -> dict | None:
binding = settings.vision or settings.analysis
if not binding or capability(settings, binding) != 'multimodal':
return None
return chat_endpoint(connection_for(settings, binding), binding.model)
def public(settings: ModelSettings) -> dict:
value = settings.model_dump()
if settings.transcription is None:
from backend.core.desktop_config import get_desktop_config
previous = get_desktop_config().speech_recognition.whisper_config.model_name
value['transcription'] = Transcription(model=previous).model_dump()
value['saved'] = path().exists()
for c in value['connections']:
key = c.pop('api_key') or ''
c['has_key'] = bool(key)
c['api_key_masked'] = (key[:3] + '…' + key[-3:]) if len(key) > 8 else ('••••' if key else '')
return value
def resolve_secret(connection: Connection, previous: ModelSettings | None = None) -> Connection:
previous = previous if previous is not None else load()
old = next((c for c in previous.connections if c.id == connection.id), None) if previous else None
if connection.api_key is not None:
return connection
if old and old.api_key and (old.provider, old.base_url, old.image_base_url) != (connection.provider, connection.base_url, connection.image_base_url):
raise ValueError('服务地址已改变,请重新填写 API Key')
return connection.model_copy(update={'api_key': old.api_key if old else ''})
def save(settings: ModelSettings) -> dict:
with _lock:
settings = ModelSettings.model_validate(settings.model_dump())
previous = load() or migrate_legacy()
resolved = settings.model_copy(update={'connections': [resolve_secret(c, previous) for c in settings.connections]})
if not resolved.analysis:
raise ValueError('请选择高光分析模型')
if resolved.analysis_mode == 'visual' and not vision_endpoint(resolved):
raise ValueError('画面分析需要多模态模型,请选择模型或在高级设置中确认自定义模型能力')
target = path()
target.parent.mkdir(parents=True, exist_ok=True)
temp = target.with_suffix('.' + uuid.uuid4().hex + '.tmp')
try:
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
json.dump(resolved.model_dump(), stream, ensure_ascii=False, indent=2)
os.replace(temp, target)
finally:
temp.unlink(missing_ok=True)
return public(resolved)
def migrate_legacy() -> ModelSettings:
"""Read-only migration: preserve separate legacy vision/cover endpoints and keys."""
from backend.core.llm_manager import get_llm_manager
from backend.services.studio import vision_settings, analysis_preferences
from backend.services import cover
manager = get_llm_manager()
manager._reload_if_settings_changed()
s = manager.settings
provider = s.get('cloud_preset') or s.get('llm_provider_preset') or s.get('llm_provider', 'dashscope')
endpoint = manager.openai_compatible_endpoint() or {}
base = endpoint.get('base_url', '')
if provider == 'gemini':
base = '' # native listing API, compatible base derived by chat_endpoint
connections = [Connection(id='legacy-analysis', name=provider, provider=provider,
base_url=base, api_key=endpoint.get('api_key', ''))]
analysis = Assignment(connection_id='legacy-analysis', model=s.get('model_name') or 'qwen-plus')
v = vision_settings.effective()
if v.get('mode') == 'text_model' and v.get('model'):
# Preserve the already working legacy choice until refreshed metadata is available.
analysis.capability = 'multimodal'
vision = None
if v.get('mode') == 'custom' and v.get('base_url') and v.get('model'):
connections.append(Connection(id='legacy-vision', name='视觉服务', base_url=v['base_url'], api_key=v.get('api_key', '')))
vision = Assignment(connection_id='legacy-vision', model=v['model'], capability='multimodal')
cfg = cover.load_config()
cover_assignment = None
if cfg.model:
# Even formerly-following covers become explicit references, so changing
# the analysis assignment never silently re-routes image generation.
if cfg.mode == 'text_model':
cover_assignment = Assignment(connection_id='legacy-analysis', model=cfg.model)
else:
kind = {'dashscope': 'dashscope', 'seedream': 'seed'}.get(cfg.provider, 'openai')
connections.append(Connection(id='legacy-cover', name='封面服务', provider=kind,
api_key=cfg.api_key, image_api=cfg.provider,
base_url=cfg.base_url if kind in {'openai', 'seed'} else '',
image_base_url=cfg.base_url))
cover_assignment = Assignment(connection_id='legacy-cover', model=cfg.model)
prefs = analysis_preferences.load()
return ModelSettings(connections=connections, analysis=analysis, vision=vision,
cover=cover_assignment, cover_enabled=cfg.enabled and cover_assignment is not None,
allow_send_frame=cfg.allow_send_frame, analysis_mode=prefs.analysis_mode,
cover_ocr_model=cfg.ocr_model, vision_timeout=v.get('timeout', 180),
allow_visual_screening=prefs.allow_visual_screening,
chunk_size=s.get('chunk_size', 5000), min_score_threshold=s.get('min_score_threshold', .7),
max_clips_per_collection=s.get('max_clips_per_collection', 5))
+155
View File
@@ -0,0 +1,155 @@
"""Cloud ASR with real timestamps, bounded uploads, and no provider fallback."""
from __future__ import annotations
import base64
import json
import math
import os
from pathlib import Path
import subprocess
import tempfile
import wave
import httpx
from backend.core.asr_model_catalog import adapter
from backend.services.ai_model_settings import chat_endpoint
from backend.utils.ffmpeg_utils import get_ffmpeg_path
class CloudTranscriptionError(RuntimeError):
pass
def normalize_segments(items, offset=0.0, milliseconds=False, duration=None):
"""Reject missing/invalid timing instead of inventing clip boundaries."""
result = []
scale = 1000 if milliseconds else 1
for item in items:
text = str(item.get('text') or '').strip()
if not text:
continue
try:
start = float(item['begin_time' if milliseconds else 'start']) / scale
end = float(item['end_time' if milliseconds else 'end']) / scale
if not math.isfinite(start) or not math.isfinite(end) or start < 0 or end <= start:
raise ValueError()
if duration is not None and end > duration + 1:
raise ValueError()
except (KeyError, TypeError, ValueError):
raise CloudTranscriptionError('转写服务未返回有效时间戳,无法生成准确字幕;请更换转写模型。') from None
result.append({'start': start + offset, 'end': end + offset, 'text': text})
return result
def request_chunk(client, connection, model, audio, language):
route = adapter(connection.provider, model)
endpoint = chat_endpoint(connection, model)
headers = {'Authorization': 'Bearer ' + endpoint['api_key']} if endpoint['api_key'] else {}
if route == 'dashscope':
base = (connection.base_url or 'https://dashscope.aliyuncs.com/compatible-mode/v1').removesuffix('/compatible-mode/v1').rstrip('/')
if not base.endswith('/api/v1'):
base += '/api/v1'
params = {'format': 'wav', 'sample_rate': '16000'}
if language != 'auto':
params['language_hints'] = [language]
if model == 'qwen-audio-3.1-asr-flash':
params['speaker_diarization_enabled'] = True
payload = {'model': model, 'input': {'messages': [{'role': 'user', 'content': [
{'type': 'input_audio', 'input_audio': {'data': 'data:audio/wav;base64,' + base64.b64encode(audio.read_bytes()).decode()}}
]}]}, 'parameters': params}
# SSE retains every completed sentence; the final non-stream response may only contain the last one.
sentences = {}
with client.stream('POST', base + '/services/aigc/multimodal-generation/generation',
headers={**headers, 'X-DashScope-SSE': 'enable'}, json=payload) as response:
response.raise_for_status()
if 'text/event-stream' in response.headers.get('content-type', ''):
events = (json.loads(line[5:].strip()) for line in response.iter_lines()
if line.startswith('data:') and line[5:].strip() not in {'', '[DONE]'})
else:
response.read()
events = [response.json()]
for event in events:
if event.get('code') and event['code'] not in {'Success', '200'}:
raise CloudTranscriptionError('云端转写失败,请检查账号权限、额度和模型。')
output = event.get('output') or {}
items = output.get('sentences') or [output.get('sentence') or {}]
for item in items:
if item.get('sentence_end') is True:
sentences[(item.get('channel_id', 0), item.get('sentence_id', item.get('begin_time')))] = item
return list(sentences.values()), True
if route not in {'openai', 'diarized'}:
raise CloudTranscriptionError('该转写模型尚未接入。')
data = {'model': model, 'response_format': 'diarized_json' if route == 'diarized' else 'verbose_json'}
if route == 'diarized':
data['chunking_strategy'] = 'auto'
else:
data['timestamp_granularities[]'] = 'segment'
if language != 'auto':
data['language'] = language
with audio.open('rb') as stream:
response = client.post(endpoint['base_url'].rstrip('/') + '/audio/transcriptions', headers=headers,
data=data, files={'file': ('audio.wav', stream, 'audio/wav')})
response.raise_for_status()
payload = response.json()
segments = payload.get('segments')
if not isinstance(segments, list):
raise CloudTranscriptionError('转写服务没有返回字幕时间戳,请使用支持 verbose_json 的模型。')
return segments, False
def transcribe(video: Path, output: Path | None, settings, language='auto', timeout=0):
selection = settings.transcription
connection = next(c for c in settings.connections if c.id == selection.connection_id)
if not connection.api_key and connection.provider != 'compatible':
raise CloudTranscriptionError('请先在字幕转写设置中填写 API Key。')
output = Path(output) if output else video.with_suffix('.srt')
from backend.utils.speech_recognizer import SpeechRecognizer, srt_has_cue_text
if output.exists() and srt_has_cue_text(output):
return output
# Each upload is <= 180 seconds / 5.76 MB PCM, including base64 < 10 MB.
# Exact sample offsets avoid encoder delay/drift when merging long recordings.
with tempfile.TemporaryDirectory(prefix='autoclip-asr-') as temp:
audio = Path(temp) / 'source.wav'
completed = subprocess.run([get_ffmpeg_path(), '-nostdin', '-y', '-i', str(video), '-vn',
'-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', str(audio)],
capture_output=True, timeout=timeout or None)
if completed.returncode:
raise CloudTranscriptionError('无法提取视频音频,请检查视频是否包含音轨。')
segments = []
from backend.core.llm_providers import is_local_url
try:
with wave.open(str(audio), 'rb') as source, httpx.Client(timeout=timeout or 300, follow_redirects=False,
trust_env=not is_local_url(chat_endpoint(connection, '')['base_url'])) as client:
offset = 0
while True:
samples = source.readframes(16000 * 180)
if not samples:
break
chunk = Path(temp) / 'chunk.wav'
with wave.open(str(chunk), 'wb') as target:
target.setparams(source.getparams())
target.writeframes(samples)
duration = len(samples) / 32000
items, milliseconds = request_chunk(client, connection, selection.model, chunk, language)
segments.extend(normalize_segments(items, offset, milliseconds, duration))
offset += duration
except httpx.HTTPStatusError as exc:
raise CloudTranscriptionError(f'云端转写请求失败(HTTP {exc.response.status_code}),请检查密钥、额度和模型权限。') from None
except (httpx.RequestError, ValueError) as exc:
raise CloudTranscriptionError('云端转写连接失败或响应格式无效,请稍后重试。') from None
if not segments:
raise CloudTranscriptionError('转写结果为空,音频中可能没有可识别的人声。')
segments.sort(key=lambda s: (s['start'], s['end']))
output.parent.mkdir(parents=True, exist_ok=True)
# Publish only the complete transcript; failed later chunks leave no partial subtitle.
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=output.parent, delete=False) as target:
pending = Path(target.name)
target.write(SpeechRecognizer._segments_to_srt(segments))
try:
os.replace(pending, output)
from backend.utils.word_timing import sidecar_path
sidecar_path(output).unlink(missing_ok=True)
finally:
pending.unlink(missing_ok=True)
return output
+12
View File
@@ -160,11 +160,23 @@ def verify_endpoint(cfg: "CoverConfig") -> dict[str, str]:
vision = {}
if vision.get("base_url") and vision.get("model"):
return {"provider": "openai", "api_key": vision.get("api_key", ""), "base_url": vision["base_url"], "model": vision["model"]}
if cfg.source == 'connections':
raise ImageError('未选择可用于封面校对的多模态模型,跳过校对')
return {"provider": cfg.provider, "api_key": cfg.api_key, "base_url": cfg.base_url,
"model": default_ocr_model(cfg.provider, cfg.model, cfg.base_url)}
def load_config() -> CoverConfig:
from backend.services import ai_model_settings as ai
settings = ai.load()
if settings:
binding = settings.cover
endpoint = ai.image_endpoint(ai.connection_for(settings, binding)) if binding else {}
return CoverConfig(enabled=settings.cover_enabled, model=binding.model if binding else '',
provider=endpoint.get('provider', 'openai'), base_url=endpoint.get('base_url', ''),
api_key=endpoint.get('api_key', ''), allow_send_frame=settings.allow_send_frame,
ocr_model=settings.cover_ocr_model,
source='connections', key_source='connection', mode='custom')
env_key = (os.getenv("IMAGE_API_KEY") or "").strip()
env_provider = (os.getenv("IMAGE_PROVIDER") or "").strip().lower()
env_base = (os.getenv("IMAGE_BASE_URL") or "").strip()
+161
View File
@@ -0,0 +1,161 @@
"""Bundled example project: a finished, link-imported project users can open before configuring anything.
The bundled "source" is the three selected passages of the original interview stitched back to back
(see backend/assets/example/manifest.json), with subtitles rebased to that timeline. That keeps the
download small while every downstream step — editor, re-cut, render, cover, publish — runs on real
files. Clip files are cut from the stitched source on creation. Creating it is idempotent and needs
no model key.
"""
from __future__ import annotations
import base64
import json
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.orm import Session
from backend.core.path_utils import get_project_directory
from backend.models.clip import Clip, ClipStatus
from backend.models.project import Project, ProjectStatus, ProjectType
ASSETS = Path(__file__).resolve().parents[1] / 'assets' / 'example'
MANIFEST = ASSETS / 'manifest.json'
EXAMPLE_FLAG = 'example'
def load_manifest() -> dict:
return json.loads(MANIFEST.read_text(encoding='utf-8'))
def available() -> bool:
if not MANIFEST.exists():
return False
meta = load_manifest()['project']
return (ASSETS / meta['source']).exists() and (ASSETS / meta['subtitles']).exists()
def find_existing(db: Session) -> Project | None:
# JSON columns differ across SQLite/Postgres; a small scan of completed projects is enough here.
for project in db.query(Project).filter(Project.status == ProjectStatus.COMPLETED).all():
if (project.processing_config or {}).get(EXAMPLE_FLAG):
return project
return None
def _seconds(timestamp: str) -> float:
hours, minutes, rest = timestamp.split(':')
return int(hours) * 3600 + int(minutes) * 60 + float(rest.replace(',', '.'))
def _safe_title(title: str) -> str:
safe = ''.join(c for c in title if c.isalnum() or c in (' ', '-', '_')).rstrip()
return safe.replace(' ', '_')
def _cover_data_url(name: str | None) -> str | None:
if not name or not (ASSETS / name).exists():
return None
return 'data:image/jpeg;base64,' + base64.b64encode((ASSETS / name).read_bytes()).decode('ascii')
def cut_clip(source: Path, start: float, end: float, target: Path) -> None:
"""Stream-copy one passage. Passage boundaries are keyframes (each was a separate file before stitching)."""
from backend.utils.ffmpeg_utils import get_ffmpeg_path
command = [get_ffmpeg_path(), '-v', 'error', '-y', '-ss', f'{start:.3f}', '-to', f'{end:.3f}', '-i', str(source),
'-c', 'copy', '-avoid_negative_ts', 'make_zero', '-movflags', '+faststart', str(target)]
subprocess.run(command, check=True, capture_output=True, timeout=120)
def create(db: Session) -> Project:
"""Create the example project once; return the existing one on later calls."""
existing = find_existing(db)
if existing:
return existing
if not available():
raise FileNotFoundError('示例项目资源缺失')
manifest = load_manifest()
meta = manifest['project']
now = datetime.now(timezone.utc) # completed_at is stored in UTC like every other timestamp
project = Project(
name=meta['name'],
description=meta.get('description'),
status=ProjectStatus.COMPLETED,
project_type=ProjectType.DEFAULT,
video_duration=meta.get('video_duration'),
thumbnail=_cover_data_url(meta.get('cover')),
processing_config={
EXAMPLE_FLAG: True,
'example_version': manifest.get('version', 1),
'analysis_mode': meta.get('analysis_mode', 'subtitle'),
'video_category': meta.get('video_category', 'default'),
},
project_metadata={'source_url': meta['source_url'], EXAMPLE_FLAG: True},
completed_at=now,
)
db.add(project)
db.flush()
root = get_project_directory(project.id)
raw = root / 'raw'
clips_dir = root / 'output' / 'clips'
for folder in (raw, clips_dir, root / 'metadata'):
folder.mkdir(parents=True, exist_ok=True)
source = raw / 'input.mp4'
shutil.copyfile(ASSETS / meta['source'], source)
shutil.copyfile(ASSETS / meta['subtitles'], raw / 'input.srt')
if meta.get('cover') and (ASSETS / meta['cover']).exists():
shutil.copyfile(ASSETS / meta['cover'], raw / 'input_cover.jpg')
project.video_path = str(source)
project.subtitle_path = str(raw / 'input.srt')
records = []
for entry in manifest['clips']:
start, end = _seconds(entry['start_time']), _seconds(entry['end_time'])
clip = Clip(
project_id=project.id,
title=entry['title'],
description=entry.get('recommend_reason', ''),
start_time=int(start),
end_time=int(end),
duration=int(end) - int(start),
score=entry.get('final_score'),
tags=[],
status=ClipStatus.COMPLETED,
)
db.add(clip)
db.flush()
target = clips_dir / f"{clip.id}_{_safe_title(entry['title'])}.mp4"
cut_clip(source, start, end, target)
clip.video_path = str(target)
# The export path looks clips up in clips_metadata.json by the database id.
record = {
'id': clip.id,
'outline': entry.get('outline', ''),
'content': entry.get('content', []),
'recommend_reason': entry.get('recommend_reason', ''),
'generated_title': entry['title'],
'title': entry['title'],
'start_time': entry['start_time'],
'end_time': entry['end_time'],
'original_start_time': entry.get('original_start_time'),
'original_end_time': entry.get('original_end_time'),
'final_score': entry.get('final_score'),
'video_path': str(target),
EXAMPLE_FLAG: True,
}
clip.clip_metadata = record
records.append(record)
(root / 'metadata' / 'clips_metadata.json').write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding='utf-8')
(root / 'project.json').write_text(json.dumps({
'project_name': meta['name'], 'description': meta.get('description'), 'created_at': now.isoformat(),
'source': {'url': meta['source_url'], 'video': str(source), 'srt': str(raw / 'input.srt'), 'via': 'example'},
'video_category': meta.get('video_category', 'default'), EXAMPLE_FLAG: True,
}, ensure_ascii=False, indent=2), encoding='utf-8')
db.commit()
db.refresh(project)
return project
+15 -2
View File
@@ -158,13 +158,26 @@ def _layout_filters(layout: str, w: Optional[int], h: Optional[int]) -> List[str
return []
# Whole-clip subtitle looks, expressed as libass force_style (colours are &HAABBGGRR).
SUBTITLE_STYLES: Dict[str, str] = {
# White with a thin dark outline: reads on any footage.
"clean": "Fontsize=16,Bold=0,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=2,Shadow=0,MarginV=48,Alignment=2",
# Large bold white with a heavy outline for phone screens.
"bold": "Fontsize=22,Bold=1,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=3,Shadow=1,MarginV=56,Alignment=2",
# White on a translucent dark card.
"box": "Fontsize=17,Bold=0,PrimaryColour=&H00FFFFFF,BackColour=&H88000000,OutlineColour=&H88000000,BorderStyle=3,Outline=6,Shadow=0,MarginV=52,Alignment=2",
# Bold yellow with a dark outline, the classic short-video caption.
"accent": "Fontsize=20,Bold=1,PrimaryColour=&H0000E5FF,OutlineColour=&H00000000,BorderStyle=1,Outline=3,Shadow=0,MarginV=56,Alignment=2",
}
def _build_filter(req: ExportRequest, spec: Dict[str, Any], srt_path: Optional[Path],
title_path: Optional[Path], font: Optional[Path]) -> Optional[str]:
title_path: Optional[Path], font: Optional[Path], subtitle_style: str = "clean") -> Optional[str]:
layout = req.layout or spec["layout"]
parts = _layout_filters(layout, spec.get("w"), spec.get("h"))
last = "base" if parts else "0:v"
if srt_path is not None:
style = "Fontsize=16,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=2,Shadow=0,MarginV=48,Alignment=2"
style = SUBTITLE_STYLES.get(subtitle_style, SUBTITLE_STYLES["clean"])
if font:
# FontName 给 libass;mac 上 PingFang SC 通常能解析
style = "FontName=PingFang SC," + style
+1 -2
View File
@@ -95,8 +95,7 @@ class SimplePipelineAdapter:
srt_path = generate_subtitle_for_video(
video_file_path,
output_path=output_path,
method="whisper_local",
model="base",
method="auto",
language="auto"
)
@@ -45,6 +45,11 @@ def load() -> AnalysisPreferences:
An invalid saved preference is an error, never permission to spend money.
The caller should preserve source material and ask for corrected settings.
"""
from backend.services import ai_model_settings as ai
settings = ai.load()
if settings:
return AnalysisPreferences(analysis_mode=settings.analysis_mode,
allow_visual_screening=settings.allow_visual_screening)
with _lock:
path = settings_path()
if not path.exists():
+245
View File
@@ -0,0 +1,245 @@
"""Speaker-following framing for the portrait crop layout.
Every few seconds we grab two frames a quarter second apart, detect faces with OpenCV's YuNet model
(bundled ONNX, MIT licence) and pick the face whose mouth region moved most — the person talking.
Those positions become a piecewise-constant crop track per scene, so the crop window follows the
conversation instead of freezing on one side. OpenCV is not part of the base install (≈45 MB); it is
installed on demand into the data directory, the same way the Whisper runtime is.
"""
from __future__ import annotations
import importlib.util
import logging
import os
import statistics
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
from typing import Any
from backend.services.studio.models import Draft, Scene
from backend.utils.ffmpeg_utils import get_ffmpeg_path
logger = logging.getLogger(__name__)
PACKAGES = ["opencv-python-headless>=4.10"]
MODEL = Path(__file__).resolve().parents[2] / "assets" / "models" / "face_detection_yunet_2023mar.onnx"
IMPORT_NAME = "cv2"
SAMPLE_INTERVAL = 2.5 # seconds between samples inside a scene
PAIR_GAP = 0.25 # seconds between the two frames of one sample (mouth motion)
FRAME_WIDTH = 480
MOTION_THRESHOLD = 4.0 # mean abs grey difference in the mouth box that counts as "talking"
MIN_HOLD = 2 # samples a new speaker must persist before the window moves
MIN_JUMP = 0.12 # normalised distance below which two positions are the same framing
# ---------------------------------------------------------------- runtime ---
def _data_dir() -> Path:
from backend.services.whisper_runtime import _data_dir as data_dir
return data_dir()
def get_install_dir() -> Path:
d = _data_dir() / "framing-runtime"
d.mkdir(parents=True, exist_ok=True)
return d
def ensure_on_path() -> None:
install_dir = str(get_install_dir())
if install_dir not in sys.path:
sys.path.insert(0, install_dir)
def is_installed() -> bool:
ensure_on_path()
try:
return importlib.util.find_spec(IMPORT_NAME) is not None and MODEL.exists()
except (ImportError, ValueError):
return False
_state_lock = threading.Lock()
_state: dict[str, Any] = {"status": "unknown", "progress": 0, "message": ""}
def _set_state(**kw) -> None:
with _state_lock:
_state.update(kw)
def get_status() -> dict[str, Any]:
with _state_lock:
state = dict(_state)
if state["status"] in ("unknown", "installed", "not_installed"):
state["status"] = "installed" if is_installed() else "not_installed"
state["size_mb"] = 45
return state
def _do_install(index_url: str | None) -> None:
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "--target", str(get_install_dir()), *PACKAGES]
if index_url:
cmd += ["--index-url", index_url]
_set_state(status="installing", progress=5, message="正在下载人物识别组件…")
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=900, check=False)
if proc.returncode == 0 and is_installed():
_set_state(status="installed", progress=100, message="安装完成")
else:
_set_state(status="error", message=f"安装失败(pip 退出码 {proc.returncode}):{proc.stdout[-300:]}")
except (OSError, subprocess.SubprocessError) as error:
logger.exception("安装人物识别组件异常")
_set_state(status="error", message=f"安装异常: {error}")
def start_install(index_url: str | None = None) -> dict[str, Any]:
with _state_lock:
if _state["status"] == "installing":
return {"started": False, "message": "正在安装中"}
if is_installed():
_set_state(status="installed", progress=100, message="已安装")
return {"started": False, "message": "已安装"}
threading.Thread(target=_do_install, args=(index_url or os.getenv("PIP_INDEX_URL"),), name="framing-install", daemon=True).start()
return {"started": True, "message": "已开始安装"}
# ------------------------------------------------------------------- math ---
def window_fraction(source_w: int, source_h: int, out_w: int, out_h: int) -> float:
"""Width of the crop window as a fraction of the source width, for a cover-scaled crop."""
scale = max(out_w / source_w, out_h / source_h)
return min(1.0, out_w / (source_w * scale))
def crop_x_for_center(center_x: float, fraction: float) -> float:
"""crop filter uses x=(iw-ow)*crop_x; place the window so `center_x` (0..1) sits in its middle."""
if fraction >= 1:
return .5
return max(0.0, min(1.0, (center_x - fraction / 2) / (1 - fraction)))
def segment_track(samples: list[tuple[float, float]], min_hold: int = MIN_HOLD, min_jump: float = MIN_JUMP) -> list[dict[str, float]]:
"""Turn (time, crop_x) samples into hold segments.
The window only moves when a clearly different position persists for `min_hold` samples,
so a single glance to the other person does not swing the frame back and forth.
"""
if not samples:
return []
track: list[dict[str, float]] = []
current = samples[0][1]
track.append({"start": 0.0, "crop_x": round(current, 3)})
pending: list[tuple[float, float]] = []
for t, x in samples[1:]:
if abs(x - current) < min_jump:
pending = []
continue
if pending and abs(x - pending[0][1]) >= min_jump:
pending = []
pending.append((t, x))
if len(pending) >= min_hold:
current = statistics.median(v for _, v in pending)
track.append({"start": round(pending[0][0], 2), "crop_x": round(current, 3)})
pending = []
return track
def crop_expression(scene: Scene, fallback: float) -> str:
"""ffmpeg expression for the crop window position within one scene (t is scene-relative)."""
points = scene.crop_track or []
static = scene.crop_x if scene.crop_x is not None else fallback
if len(points) <= 1:
return f"{points[0].crop_x if points else static}"
# Nest from the first point outwards: each boundary tests the *next* point's start.
expression = f"{points[0].crop_x}"
for point in points[1:]:
expression = f"if(lt(t,{point.start}),{expression},{point.crop_x})"
return expression
# ------------------------------------------------------------------ frames ---
def _grab_pair(video: Path, at: float, folder: Path, key: str) -> tuple[Path, Path] | None:
pattern = folder / f"{key}-%d.jpg"
cmd = [get_ffmpeg_path(), "-v", "error", "-ss", f"{at:.3f}", "-i", str(video), "-frames:v", "2",
"-vf", f"fps=1/{PAIR_GAP},scale={FRAME_WIDTH}:-2", "-q:v", "4", "-y", str(pattern)]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
return None
first, second = folder / f"{key}-1.jpg", folder / f"{key}-2.jpg"
if not first.exists():
return None
return first, second if second.exists() else first
def _speaker_center(pair: tuple[Path, Path]) -> float | None:
"""Normalised x-centre of the talking face; the largest face when nobody's mouth moves."""
ensure_on_path()
import cv2 # installed on demand
first = cv2.imread(str(pair[0]))
second = cv2.imread(str(pair[1]))
if first is None:
return None
height, width = first.shape[:2]
detector = cv2.FaceDetectorYN.create(str(MODEL), "", (width, height), score_threshold=0.6, nms_threshold=0.3, top_k=50)
_, faces = detector.detect(first)
if faces is None or len(faces) == 0:
return None
grey_a = cv2.cvtColor(first, cv2.COLOR_BGR2GRAY)
grey_b = cv2.cvtColor(second, cv2.COLOR_BGR2GRAY) if second is not None and second.shape == first.shape else None
best, best_motion = None, -1.0
for face in faces:
w, h = face[2], face[3]
# YuNet landmarks: right eye, left eye, nose, right mouth corner, left mouth corner.
mx1, my1, mx2, my2 = face[10], face[11], face[12], face[13]
if grey_b is not None:
left, right = int(max(0, min(mx1, mx2) - w * .1)), int(min(width, max(mx1, mx2) + w * .1))
top, bottom = int(max(0, min(my1, my2) - h * .12)), int(min(height, max(my1, my2) + h * .2))
if right > left and bottom > top:
motion = float(abs(grey_a[top:bottom, left:right].astype("int16") - grey_b[top:bottom, left:right].astype("int16")).mean())
else:
motion = 0.0
else:
motion = 0.0
if motion > best_motion:
best, best_motion = face, motion
if best_motion < MOTION_THRESHOLD:
best = max(faces, key=lambda f: f[2] * f[3])
x, w = best[0], best[2]
return float((x + w / 2) / width)
def auto_frame(video: Path, draft: Draft, source_w: int, source_h: int) -> dict[str, Any]:
"""Speaker-following crop tracks per scene; scenes without a face keep the draft framing."""
if not is_installed():
raise RuntimeError("人物识别组件未安装")
out_w, out_h = {"portrait": (1080, 1920), "landscape": (1920, 1080)}.get(draft.aspect, (source_w, source_h))
fraction = window_fraction(source_w, source_h, out_w, out_h)
scenes = []
with tempfile.TemporaryDirectory(prefix="ac-framing-") as temp:
folder = Path(temp)
for scene in draft.scenes:
length = scene.end - scene.start
count = max(1, int(length // SAMPLE_INTERVAL) + 1)
samples: list[tuple[float, float]] = []
grabbed = 0
for i in range(count):
rel = min(length - PAIR_GAP - .05, SAMPLE_INTERVAL * i + .5) if length > 1 else length / 2
rel = max(0.0, rel)
pair = _grab_pair(video, scene.start + rel, folder, f"{scene.id}-{i}")
if not pair:
continue
grabbed += 1
center = _speaker_center(pair)
if center is not None:
samples.append((rel, crop_x_for_center(center, fraction)))
if samples:
track = segment_track(samples)
scenes.append({"id": scene.id, "crop_x": track[0]["crop_x"], "crop_track": track,
"faces": len(samples), "samples": grabbed, "switches": len(track) - 1})
else:
scenes.append({"id": scene.id, "crop_x": None, "crop_track": None, "faces": 0, "samples": grabbed, "switches": 0})
return {"scenes": scenes, "window_fraction": round(fraction, 3)}
+12
View File
@@ -11,6 +11,13 @@ class Preferences(BaseModel):
aspect: Literal['original', 'portrait', 'landscape'] = 'original'
duration: int = Field(30, ge=10, le=120)
class CropPoint(BaseModel):
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
# Seconds from the start of the scene; the framing holds until the next point.
start: float = Field(ge=0)
crop_x: float = Field(ge=0, le=1)
class Scene(BaseModel):
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
id: str = Field(pattern=r'^[a-zA-Z0-9_-]+$', min_length=1, max_length=100)
@@ -18,6 +25,10 @@ class Scene(BaseModel):
start: float = Field(ge=0)
end: float = Field(gt=0)
evidence: str = Field(default='', max_length=1000)
# Per-scene horizontal framing for the crop layout (0 = left, 1 = right); None follows the draft.
crop_x: float | None = Field(default=None, ge=0, le=1, allow_inf_nan=False)
# Speaker-following framing: piecewise-constant crop_x over the scene. Empty/None = static crop_x.
crop_track: list[CropPoint] | None = Field(default=None, max_length=400)
@model_validator(mode='after')
def interval(self):
@@ -42,6 +53,7 @@ class Draft(BaseModel):
title_y: float = Field(default=.12, ge=.06, le=.70, allow_inf_nan=False)
title_accent: str | None = Field(default=None, pattern=r'^#[0-9a-fA-F]{6}$')
subtitles: bool = True
subtitle_style: Literal['clean', 'bold', 'box', 'accent'] = 'clean'
original_audio: bool = True
revision: int = Field(default=1, ge=1)
updated_at: str = ''
+1 -1
View File
@@ -31,7 +31,7 @@ def recommend(video: Path, options: ImportOptions):
result = Recommendation(content_type='other', goal=options.goal,
reason='按你指定的制作方式处理,其他未指定选项使用推荐设置。', confidence=1,
aspect='portrait' if options.goal == 'promo' else 'original')
elif consent.analysis_mode == 'subtitle' or (consent.analysis_mode == 'auto' and not consent.allow_visual_screening) or (configured and not analysis_preferences.visual_screening_allowed(consent, vision_configured=configured)):
elif consent.analysis_mode == 'subtitle' or (consent.analysis_mode == 'auto' and (not consent.allow_visual_screening or not configured)) or (configured and not analysis_preferences.visual_screening_allowed(consent, vision_configured=configured)):
mode = 'local'
from backend.services.studio.local_evidence import inspect_subtitles
local_evidence = inspect_subtitles(video, duration)
+3 -2
View File
@@ -64,7 +64,7 @@ def render_draft(project_id, video, draft: Draft, job_id, progress):
title.write_text('\n'.join(textwrap.wrap(hook, width=14)), encoding='utf-8')
spec = {'layout': draft.layout, 'w': w, 'h': h}
req = ExportRequest(project_id, draft.id, layout=draft.layout)
built = _build_filter(req, spec, srt if body else None, title if hook and i == 0 and draft.title_style == 'plain' else None, font)
built = _build_filter(req, spec, srt if body else None, title if hook and i == 0 and draft.title_style == 'plain' else None, font, draft.subtitle_style)
clip_path = folder / f'{i}.mkv'
artwork = None
backdrop = None
@@ -89,7 +89,8 @@ def render_draft(project_id, video, draft: Draft, job_id, progress):
graph, last = built
graph = graph.replace(':reload=0', ':expansion=none:reload=0').replace(':fontsize=42:', f':fontsize={max(18, round(w * .06))}:').replace(f'[fg]scale={w}:-2[fg2]', f'[fg]scale={w}:{h}:force_original_aspect_ratio=decrease[fg2]')
if draft.layout == 'crop':
graph = graph.replace(f'crop={w}:{h}[base]', f'crop={w}:{h}:x=(iw-ow)*{draft.crop_x}:y=(ih-oh)/2,setsar=1[base]')
from backend.services.studio.framing import crop_expression
graph = graph.replace(f'crop={w}:{h}[base]', f"crop={w}:{h}:x='(iw-ow)*{crop_expression(scene, draft.crop_x)}':y=(ih-oh)/2,setsar=1[base]")
if backdrop:
# Keep all three inputs on one clock and use RGB masks:
# a gray mask converted to YUV has neutral chroma (128),
@@ -101,6 +101,14 @@ def _with_text_model(stored):
def effective():
from backend.services import ai_model_settings as ai
settings = ai.load()
if settings:
endpoint = ai.vision_endpoint(settings)
binding = settings.vision or settings.analysis
return {**(endpoint or {'base_url': '', 'api_key': '', 'model': ''}),
'mode': 'custom' if settings.vision else 'text_model', 'source': 'connections',
'text_model': binding.model if binding else '', 'timeout': settings.vision_timeout}
saved = _saved()
if saved is not None:
if saved['mode'] == 'text_model':
+16 -6
View File
@@ -199,16 +199,27 @@ def _generate_import_subtitle(task, project_id: str, video_path: str):
speech_config = None
srt_path = None
try:
from backend.utils.speech_recognizer import generate_subtitle_for_video
from backend.utils.speech_recognizer import generate_subtitle_for_video, configured_whisper_model
from backend.core.desktop_config import get_desktop_config
config = get_desktop_config()
speech_config = config.speech_recognition
from backend.services.ai_model_settings import load as load_model_settings
models = load_model_settings()
if models and models.transcription:
from copy import deepcopy
speech_config = deepcopy(speech_config)
speech_config.method = 'whisper_local' if models.transcription.provider == 'whisper_local' else 'auto'
if models.transcription.provider == 'whisper_local':
speech_config.whisper_config.model_name = models.transcription.model
speech_config.enable_fallback = False
logger.info(f"使用语音转写配置 - 方法: {speech_config.method}")
if speech_config.method == "whisper_local":
model = speech_config.whisper_config.model_name
if models and models.transcription and models.transcription.provider == "cloud":
generated_subtitle = generate_subtitle_for_video(Path(video_path), method="auto")
elif speech_config.method == "whisper_local":
model = configured_whisper_model(speech_config.whisper_config.model_name)
language = speech_config.whisper_config.language
enable_timestamps = speech_config.whisper_config.enable_timestamps
enable_punctuation = speech_config.whisper_config.enable_punctuation
@@ -261,14 +272,14 @@ def _generate_import_subtitle(task, project_id: str, video_path: str):
if speech_config is not None and speech_config.enable_fallback and speech_config.fallback_method != speech_config.method:
try:
logger.info(f"尝试回退方法: {speech_config.fallback_method}")
from backend.utils.speech_recognizer import generate_subtitle_for_video
from backend.utils.speech_recognizer import generate_subtitle_for_video, configured_whisper_model
if speech_config.fallback_method == "whisper_local":
fallback_config = speech_config.whisper_config
generated_subtitle = generate_subtitle_for_video(
Path(video_path),
language=fallback_config.language,
model=fallback_config.model_name,
model=configured_whisper_model(fallback_config.model_name),
method=speech_config.fallback_method
)
else:
@@ -402,4 +413,3 @@ def process_import_task(self, project_id: str, video_path: str, srt_file_path: O
except Exception:
logger.warning("导入任务失败后更新项目状态失败: %s", project_id, exc_info=True)
raise
+348
View File
@@ -0,0 +1,348 @@
"""Connection isolation, migration, atomic persistence and dynamic capability data."""
import asyncio
import json
import os
from types import SimpleNamespace
import pytest
from pydantic import ValidationError
from backend.services import ai_model_settings as ai
from backend.core import model_registry as registry
legacy_migrate = ai.migrate_legacy
@pytest.fixture(autouse=True)
def isolated(tmp_path, monkeypatch):
monkeypatch.setenv('AUTOCLIP_DATA_DIR', str(tmp_path))
monkeypatch.setenv('AUTOCLIP_APP_DIR', str(tmp_path))
monkeypatch.setattr(ai, 'migrate_legacy', lambda: ai.ModelSettings())
def example():
return ai.ModelSettings(
connections=[
ai.Connection(id='one', name='Analysis account', provider='openai', base_url='https://one.example/v1', api_key='sk-first-secret'),
ai.Connection(id='two', name='Cover account', provider='openai', base_url='https://two.example/v1', api_key='sk-second-secret'),
],
analysis=ai.Assignment(connection_id='one', model='custom-vision', capability='multimodal'),
cover=ai.Assignment(connection_id='two', model='my-image'), cover_enabled=True,
)
def test_independent_connections_and_key_references_survive_analysis_change():
from backend.services import cover
from backend.services.studio import vision_settings
ai.save(example())
assert vision_settings.effective()['api_key'] == 'sk-first-secret'
assert cover.load_config().api_key == 'sk-second-secret'
config = ai.load()
config.analysis.model = 'another-model'
config.connections[0].api_key = 'rotated-key'
ai.save(config)
assert vision_settings.effective()['model'] == 'another-model'
assert vision_settings.effective()['api_key'] == 'rotated-key'
assert cover.load_config().model == 'my-image'
assert cover.load_config().api_key == 'sk-second-secret'
def test_same_provider_multiple_accounts_and_separate_vision():
config = example()
config.vision = ai.Assignment(connection_id='two', model='vision-b', capability='multimodal')
config.analysis.capability = 'text'
ai.save(config)
assert ai.vision_endpoint(ai.load()) == {'base_url': 'https://two.example/v1', 'api_key': 'sk-second-secret', 'model': 'vision-b'}
def test_secrets_are_masked_preserved_and_can_be_cleared():
response = ai.save(example())
assert 'sk-first-secret' not in json.dumps(response)
assert all('api_key' not in c for c in response['connections'])
ai.save(ai.ModelSettings.model_validate(response))
assert ai.load().connections[0].api_key == 'sk-first-secret'
response['connections'][0]['api_key'] = ''
ai.save(ai.ModelSettings.model_validate(response))
assert ai.load().connections[0].api_key == ''
assert os.stat(ai.path()).st_mode & 0o777 == 0o600
def test_changed_endpoint_cannot_reuse_hidden_key():
response = ai.save(example())
response['connections'][0]['base_url'] = 'https://new.example/v1'
before = ai.path().read_bytes()
with pytest.raises(ValueError, match='重新填写'):
ai.save(ai.ModelSettings.model_validate(response))
assert ai.path().read_bytes() == before
def test_invalid_binding_and_visual_choice_do_not_partially_save():
ai.save(example())
before = ai.path().read_bytes()
config = example()
config.analysis.capability = 'text'
config.analysis_mode = 'visual'
with pytest.raises(ValueError, match='多模态'):
ai.save(config)
assert ai.path().read_bytes() == before
raw = example().model_dump()
raw['connections'].pop()
with pytest.raises(ValidationError, match='引用的服务'):
ai.ModelSettings.model_validate(raw)
def test_atomic_write_failure_leaves_old_config(monkeypatch):
ai.save(example())
before = ai.path().read_bytes()
def fail(*args):
raise OSError('disk full')
monkeypatch.setattr(ai.os, 'replace', fail)
with pytest.raises(OSError):
ai.save(example())
assert ai.path().read_bytes() == before
assert not list(ai.path().parent.glob('*.tmp'))
def test_local_analysis_does_not_disable_cloud_cover():
from backend.services import cover
config = example()
config.connections[0] = ai.Connection(id='one', name='Local', provider='ollama', api_key='')
ai.save(config)
assert ai.vision_endpoint(ai.load())['base_url'] == 'http://localhost:11434/v1'
assert cover.load_config().enabled and cover.load_config().api_key == 'sk-second-secret'
def test_runtime_manager_reloads_connection_document(tmp_path, monkeypatch):
from backend.core.llm_manager import LLMManager
monkeypatch.setattr(LLMManager, '_sync_config_if_needed', lambda self: None)
monkeypatch.setattr(LLMManager, '_initialize_provider', lambda self: None)
ai.save(example())
manager = LLMManager(settings_file=tmp_path / 'settings.json')
assert manager.settings['openai_api_key'] == 'sk-first-secret'
config = example()
config.analysis = ai.Assignment(connection_id='two', model='new-analysis')
ai.save(config)
manager._reload_if_settings_changed()
assert manager.settings['openai_api_key'] == 'sk-second-secret'
assert manager.settings['model_name'] == 'new-analysis'
def test_qwen_verified_and_unknown_is_not_text_only():
c = ai.Connection(id='q', name='Qwen', provider='dashscope')
assert registry.lookup_capability(c, 'qwen3.8-max') == 'multimodal'
assert registry.lookup_capability(c, 'qwen3.8-flash') == 'multimodal'
assert registry.lookup_capability(c, 'qwen-future-unknown') is None
unknown = registry._records(c, [{'id': 'future-model'}], {})[0]
assert unknown['capability'] is None and unknown['analysis']
def test_http_config_and_discovery_use_saved_secrets_without_returning_them(monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.api.v1.settings import router
app = FastAPI()
app.include_router(router)
async def discover(connection, refresh):
assert connection.api_key == 'sk-first-secret'
return {'models': [{'id': 'new-model', 'capability': 'multimodal', 'analysis': True, 'image': False}], 'source': 'live'}
monkeypatch.setattr(registry, 'discover', discover)
with TestClient(app) as client:
response = client.put('/settings/ai-models', json=example().model_dump())
assert response.status_code == 200
assert 'sk-first-secret' not in response.text
connection = response.json()['connections'][0]
listing = client.post('/settings/ai-models/discover', json={'connection': connection})
assert listing.status_code == 200 and listing.json()['models'][0]['id'] == 'new-model'
assert client.get('/settings/ai-models').json()['cover']['connection_id'] == 'two'
def test_no_unselected_ocr_model_is_called():
from backend.services import cover
config = example()
config.analysis.capability = 'text'
ai.save(config)
with pytest.raises(cover.ImageError, match='跳过校对'):
cover.verify_endpoint(cover.load_config())
def test_live_metadata_overrides_catalog_and_keeps_new_models(monkeypatch):
c = example().connections[0]
async def metadata():
return None
async def fetch(*args, **kwargs):
return {'data': [{'id': 'brand-new', 'architecture': {'input_modalities': ['text', 'image'], 'output_modalities': ['text']}},
{'id': 'image-new', 'architecture': {'input_modalities': ['text'], 'output_modalities': ['image']}}]}
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
monkeypatch.setattr(registry.model_catalog, '_http_get_json', fetch)
result = asyncio.run(registry.discover(c))
assert [m['id'] for m in result['models']] == ['brand-new', 'image-new']
assert registry.lookup_capability(c, 'brand-new') == 'multimodal'
assert result['models'][1]['image'] and not result['models'][1]['analysis']
assert 'sk-first-secret' not in registry._path().read_text()
def test_failed_refresh_preserves_last_live_list(monkeypatch):
c = example().connections[0]
registry._update(registry._scope(c), {'models': [{'id': 'saved-model'}], 'updated_at': 1, 'source': 'live'})
async def metadata():
return None
async def fail(*args, **kwargs):
raise RuntimeError('network unavailable')
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
monkeypatch.setattr(registry.model_catalog, '_http_get_json', fail)
result = asyncio.run(registry.discover(c, refresh=True))
assert result['models'] == [{'id': 'saved-model'}]
assert result['source'] == 'cache' and result['warning']
def test_migration_keeps_legacy_endpoints_without_writing(monkeypatch):
# Retrieve the real function, replaced in the fixture only to isolate saves.
from backend.services import cover
from backend.services.studio import vision_settings, analysis_preferences
from backend.core import llm_manager
manager = SimpleNamespace(settings={'llm_provider': 'openai', 'model_name': 'main-model'},
_reload_if_settings_changed=lambda: None,
openai_compatible_endpoint=lambda: {'base_url': 'https://main.example/v1', 'api_key': 'main-key'})
monkeypatch.setattr(llm_manager, 'get_llm_manager', lambda: manager)
monkeypatch.setattr(vision_settings, 'effective', lambda: {'mode': 'custom', 'base_url': 'https://vision.example/v1', 'api_key': 'vision-key', 'model': 'vision-model'})
monkeypatch.setattr(cover, 'load_config', lambda: cover.CoverConfig(enabled=True, model='image-model', api_key='image-key', base_url='https://image.example/v1'))
monkeypatch.setattr(analysis_preferences, 'load', lambda: analysis_preferences.AnalysisPreferences(analysis_mode='subtitle'))
config = legacy_migrate()
assert len(config.connections) == 3
assert ai.connection_for(config, config.vision).api_key == 'vision-key'
assert ai.connection_for(config, config.cover).api_key == 'image-key'
assert config.analysis_mode == 'subtitle'
assert not ai.path().exists()
def test_infistar_public_preview_and_exact_account_intersection(monkeypatch):
calls = []
async def fetch(url, **kwargs):
calls.append((url, kwargs))
if url.endswith('/api/pricing'):
assert not kwargs.get('headers')
return {'success': True, 'data': [
{'model_name': 'new-image', 'supported_endpoint_types': ['image-generation']},
{'model_name': 'edit-only', 'supported_endpoint_types': ['image-edit']},
{'model_name': 'new-chat', 'supported_endpoint_types': ['openai'], 'tags': '图像理解,工具调用'},
{'model_name': 'public-only', 'supported_endpoint_types': ['image-generation']},
]}
return {'data': [{'id': 'new-image'}, {'id': 'new-chat'}, {'id': 'edit-only'}]}
async def metadata():
return None
monkeypatch.setattr(registry.model_catalog, '_http_get_json', fetch)
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
connection = ai.Connection(id='preview', name='Infistar', provider='infistar')
preview = asyncio.run(registry.discover(connection))
assert preview['preview'] is True
assert len(calls) == 1 and calls[0][0].endswith('/api/pricing')
assert [m['id'] for m in preview['models'] if m['image']] == ['new-image', 'public-only']
connection.api_key = 'test-secret'
account = asyncio.run(registry.discover(connection))
assert not account.get('preview')
assert [m['id'] for m in account['models'] if m['image']] == ['new-image']
assert [m['id'] for m in account['models'] if m['analysis']] == ['new-chat']
assert registry.lookup_capability(connection, 'new-chat') == 'multimodal'
assert 'test-secret' not in registry._path().read_text()
def test_public_catalog_outage_preserves_preview(monkeypatch):
async def fail(*args, **kwargs):
raise RuntimeError('offline')
monkeypatch.setattr(registry.model_catalog, '_http_get_json', fail)
connection = ai.Connection(id='preview', name='Infistar', provider='infistar')
result = asyncio.run(registry.discover(connection))
assert result['preview']
assert len([m for m in result['models'] if m['image']]) > 1
assert '缓存' in result['warning']
def test_official_preview_does_not_call_authenticated_models_endpoint(monkeypatch):
async def metadata():
return None
async def unexpected(*args, **kwargs):
raise AssertionError('preview must not call authenticated endpoint')
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
monkeypatch.setattr(registry.model_catalog, '_http_get_json', unexpected)
result = asyncio.run(registry.discover(ai.Connection(id='preview', name='OpenAI', provider='openai')))
assert result['preview'] and result['models']
@pytest.mark.parametrize('provider,expected', [
('seed', {'doubao-seedream-5-0-flash-260915', 'doubao-seedream-5-0-pro-260628'}),
('dashscope', {'qwen-image-3.0', 'wan2.7-image', 'z-image-turbo'}),
('gemini', {'gemini-3.1-flash-image', 'gemini-3-pro-image'}),
('openai', {'gpt-image-2.5-flare', 'gpt-image-2'}),
('glm', {'glm-image', 'cogview-4'}),
('grok', {'grok-imagine-image-2.0', 'grok-imagine-image'}),
])
def test_official_image_previews_include_verified_models(monkeypatch, provider, expected):
async def metadata():
return None
monkeypatch.setattr(registry, '_ensure_metadata', metadata)
result = asyncio.run(registry.discover(ai.Connection(id='preview', name=provider, provider=provider)))
assert expected <= {m['id'] for m in result['models'] if m['image']}
assert result['preview']
if provider == 'seed':
assert 'doubao-seedream-5-0-260128' not in {m['id'] for m in result['models']}
def test_official_image_and_gateway_protocols_stay_independent():
for provider in ['gemini', 'grok', 'glm']:
endpoint = ai.image_endpoint(ai.Connection(id='one', name='test', provider=provider))
assert endpoint['provider'] == provider
assert not endpoint['base_url'].endswith('/openai')
assert ai.image_endpoint(ai.Connection(id='one', name='test', provider='infistar'))['provider'] == 'openai'
def test_research_agent_image_output_is_not_a_native_cover_model():
connection = ai.Connection(id='preview', name='Gemini', provider='gemini')
data = {'metadata': {'providers': {'google': {'deep-research-preview-04-2026': {'image_output': True}}}}}
records = registry._records(connection, ['deep-research-preview-04-2026'], data)
assert not records[0]['image'] and not records[0]['analysis']
def test_transcription_model_is_saved_and_used_without_overriding_explicit_call(monkeypatch, tmp_path):
from backend.utils import speech_recognizer as speech
config = example()
config.transcription = ai.Transcription(model='large-v3')
saved = ai.save(config)
assert saved['transcription'] == {'provider': 'whisper_local', 'model': 'large-v3', 'connection_id': None}
calls = []
class FakeRecognizer:
def __init__(self, config=None):
self.config = config
def generate_subtitle(self, video, output, config):
calls.append(config.model)
assert config.enable_fallback is False
return output
monkeypatch.setattr(speech, 'SpeechRecognizer', FakeRecognizer)
speech.generate_subtitle_for_video(tmp_path / 'video.mp4', tmp_path / 'out.srt', method='whisper_local')
speech.generate_subtitle_for_video(tmp_path / 'video.mp4', tmp_path / 'out.srt', method='whisper_local', model='tiny')
speech.generate_subtitle_for_video(tmp_path / 'video.mp4', tmp_path / 'out.srt')
assert calls == ['large-v3', 'tiny', 'large-v3']
def test_cloud_transcription_binding_is_validated():
from pydantic import ValidationError
from backend.services.ai_model_settings import ModelSettings, Connection, Transcription
connection = Connection(id='asr', name='ASR', provider='openai')
value = ModelSettings(connections=[connection], transcription=Transcription(provider='cloud', model='whisper-1', connection_id='asr'))
assert value.transcription.connection_id == 'asr'
with pytest.raises(ValidationError):
ModelSettings(transcription=Transcription(provider='cloud', model='whisper-1', connection_id='gone'))
with pytest.raises(ValidationError):
ModelSettings(connections=[connection], transcription=Transcription(provider='cloud', model='gpt-4o-transcribe', connection_id='asr'))
def test_cloud_selection_routes_auto_without_loading_whisper(monkeypatch, tmp_path):
from backend.services import ai_model_settings as settings, cloud_transcription
from backend.utils.speech_recognizer import generate_subtitle_for_video, configured_whisper_model
value = settings.ModelSettings(connections=[settings.Connection(id='asr', name='ASR', provider='openai')],
transcription=settings.Transcription(provider='cloud', connection_id='asr', model='whisper-1'))
monkeypatch.setattr(settings, 'load', lambda: value)
calls = []
monkeypatch.setattr(cloud_transcription, 'transcribe', lambda *args: calls.append(args) or tmp_path / 'result.srt')
assert generate_subtitle_for_video(tmp_path / 'video.mp4') == tmp_path / 'result.srt'
assert calls[0][2] is value
assert configured_whisper_model('small') == 'small'
+90
View File
@@ -0,0 +1,90 @@
import json
from pathlib import Path
from types import SimpleNamespace
import wave
import httpx
import pytest
from backend.services import cloud_transcription as asr
from backend.services.ai_model_settings import Connection, ModelSettings, Transcription
def test_timing_units_offsets_and_reject_fabricated_boundaries():
assert asr.normalize_segments([{'begin_time': 500, 'end_time': 1700, 'text': '你好'}], 180, True) == [
{'start': 180.5, 'end': 181.7, 'text': '你好'}]
for segment in [{'text': 'hi'}, {'text': 'hi', 'start': -1, 'end': 1}, {'text': 'hi', 'start': 1, 'end': float('nan')}]:
with pytest.raises(asr.CloudTranscriptionError):
asr.normalize_segments([segment])
@pytest.mark.parametrize('model,format', [('whisper-1', 'verbose_json'), ('gpt-4o-transcribe-diarize', 'diarized_json')])
def test_openai_upload_and_timestamp_format(tmp_path, model, format):
audio = tmp_path / 'audio.wav'; audio.write_bytes(b'fake-wav')
def handler(request):
assert request.url.path == '/v1/audio/transcriptions'
assert request.headers['authorization'] == 'Bearer secret'
assert format.encode() in request.content
assert b'fake-wav' in request.content
return httpx.Response(200, json={'segments': [{'start': .2, 'end': 1, 'text': 'Hello'}]})
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
items, milliseconds = asr.request_chunk(client, Connection(id='a', name='a', api_key='secret'), model, audio, 'auto')
assert items[0]['text'] == 'Hello'
assert not milliseconds
def test_dashscope_keeps_all_final_sentences_and_ignores_partial(tmp_path):
audio = tmp_path / 'audio.wav'; audio.write_bytes(b'fake-wav')
def handler(request):
assert request.url.path == '/api/v1/services/aigc/multimodal-generation/generation'
body = json.loads(request.content)
assert body['input']['messages'][0]['content'][0]['input_audio']['data'].startswith('data:audio/wav;base64,')
assert body['parameters']['language_hints'] == ['zh']
events = [dict(sentence_id=1, sentence_end=False, text='partial'),
dict(sentence_id=1, sentence_end=True, begin_time=0, end_time=700, text='一'),
dict(sentence_id=2, sentence_end=True, begin_time=900, end_time=1800, text='二')]
return httpx.Response(200, headers={'Content-Type': 'text/event-stream'},
text='\n\n'.join('data: ' + json.dumps({'output': {'sentence': s}}) for s in events))
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
items, milliseconds = asr.request_chunk(client, Connection(id='a', name='a', provider='dashscope'),
'qwen-audio-3.0-asr-flash', audio, 'zh')
assert [s['text'] for s in items] == ['一', '二']
assert milliseconds
def test_long_audio_merges_offsets_and_failure_does_not_publish_partial(monkeypatch, tmp_path):
import backend.utils.speech_recognizer # keep unrelated optional imports outside the subprocess mock
def extract(command, **kwargs):
with wave.open(command[-1], 'wb') as audio:
audio.setnchannels(1); audio.setsampwidth(2); audio.setframerate(16000)
audio.writeframes(b'\0\0' * 16000 * 181)
return SimpleNamespace(returncode=0)
monkeypatch.setattr(asr.subprocess, 'run', extract)
monkeypatch.setattr(asr, 'get_ffmpeg_path', lambda: 'ffmpeg')
selection = ModelSettings(connections=[Connection(id='a', name='a', api_key='secret')],
transcription=Transcription(provider='cloud', model='whisper-1', connection_id='a'))
chunks = []
def response(*args):
chunks.append(args[3].read_bytes())
return [{'start': .1, 'end': .8, 'text': '字幕'}], False
monkeypatch.setattr(asr, 'request_chunk', response)
output = tmp_path / 'out.srt'
asr.transcribe(tmp_path / 'video.mp4', output, selection)
assert '00:03:00,100 --> 00:03:00,800' in output.read_text()
assert len(chunks) == 2
output.unlink(); chunks.clear()
def fail(*args):
chunks.append(1)
if len(chunks) == 2: raise asr.CloudTranscriptionError('failure')
return [{'start': .1, 'end': .8, 'text': '字幕'}], False
monkeypatch.setattr(asr, 'request_chunk', fail)
with pytest.raises(asr.CloudTranscriptionError):
asr.transcribe(tmp_path / 'video.mp4', output, selection)
assert not output.exists()
def test_missing_timestamps_not_treated_as_subtitles(tmp_path):
audio = tmp_path / 'audio.wav'; audio.write_bytes(b'audio')
with httpx.Client(transport=httpx.MockTransport(lambda _: httpx.Response(200, json={'text': 'text only'}))) as client:
with pytest.raises(asr.CloudTranscriptionError, match='时间戳'):
asr.request_chunk(client, Connection(id='a', name='a'), 'whisper-1', audio, 'auto')
+44
View File
@@ -323,3 +323,47 @@ def test_model_catalog_marks_vision_and_image_models():
assert mc.supports_vision("doubao-seed-2-1-lite-260915") and mc.supports_vision("qwen-vl-plus")
assert not mc.supports_vision("deepseek-flash") and not mc.supports_vision("qwen-plus")
assert mc.IMAGE_MODELS["dashscope"][0].startswith("wanx") and "deepseek" not in mc.IMAGE_MODELS
def test_gemini_cover_uses_native_multimodal_request():
import base64
from backend.core.image_providers import generate_image, ImageRequest
image = _jpeg()
session = _Session([_Resp(payload={'candidates': [{'content': {'parts': [{'text': 'caption'}, {'inlineData': {'mimeType': 'image/jpeg', 'data': base64.b64encode(image).decode()}}]}}]})])
result = generate_image(provider='gemini', api_key='test-key', base_url='', request=ImageRequest('cover', 1920, 1080, reference=image, model='gemini-3.1-flash-image'), session=session)
assert result == image
method, url, args = session.calls[0]
assert url.endswith('/models/gemini-3.1-flash-image:generateContent')
assert args['headers']['x-goog-api-key'] == 'test-key'
assert args['json']['contents'][0]['parts'][0]['inlineData']['data']
assert args['json']['generationConfig']['responseModalities'] == ['TEXT', 'IMAGE']
@pytest.mark.parametrize('model,route,asynchronous', [('qwen-image-2.0', 'multimodal-generation', False), ('wan2.7-image', 'image-generation', True), ('wan2.6-t2i', 'image-generation', True)])
def test_modern_dashscope_routes_and_extracts_images(model, route, asynchronous):
from backend.core.image_providers import generate_image, ImageRequest
image = _jpeg()
output = {'output': {'choices': [{'message': {'content': [{'image': 'https://image.example/cover.jpg'}]}}]}}
responses = [_Resp(payload={'output': {'task_id': 'task', 'task_status': 'PENDING'}}), _Resp(payload=output)] if asynchronous else [_Resp(payload=output)]
session = _Session(responses + [_Resp(content=image)])
result = generate_image(provider='dashscope', api_key='test-key', base_url='', request=ImageRequest('cover', 1920, 1080, model=model), session=session, poll_interval=0)
assert result == image
args = session.calls[0][2]
assert session.calls[0][1].endswith(f'/services/aigc/{route}/generation')
assert ('X-DashScope-Async' in args['headers']) == asynchronous
assert args['json']['input']['messages'][0]['content'] == [{'text': 'cover'}]
assert args['json']['parameters']['n'] == 1
def test_grok_and_glm_use_vendor_image_parameters():
import base64
from backend.core.image_providers import generate_image, ImageRequest
image = _jpeg()
for provider, model in [('grok', 'grok-imagine-image-2.0'), ('glm', 'glm-image')]:
session = _Session([_Resp(payload={'data': [{'b64_json': base64.b64encode(image).decode()}]})])
assert generate_image(provider=provider, api_key='test-key', base_url='', request=ImageRequest('cover', 1920, 1080, model=model), session=session) == image
body = session.calls[0][2]['json']
if provider == 'grok':
assert body['aspect_ratio'] == '16:9' and 'size' not in body
else:
assert body['size'] == '1728x960' and 'response_format' not in body
+118
View File
@@ -0,0 +1,118 @@
"""The bundled example project: idempotent, complete, playable, and clearly link-sourced."""
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
@pytest.fixture
def data_dir(tmp_path, monkeypatch):
d = tmp_path / "data"
monkeypatch.setenv("AUTOCLIP_DATA_DIR", str(d))
monkeypatch.setenv("AUTOCLIP_APP_DIR", str(d))
d.mkdir()
return d
@pytest.fixture
def session(data_dir, monkeypatch):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from backend.core import database
from backend.models import project as _p # noqa: F401
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
database.Base.metadata.create_all(bind=engine)
session_local = sessionmaker(bind=engine, autocommit=False, autoflush=False)
monkeypatch.setattr(database, "engine", engine)
monkeypatch.setattr(database, "SessionLocal", session_local)
db = session_local()
try:
yield db
finally:
db.close()
def test_manifest_and_media_are_bundled():
from backend.services import example_project
assert example_project.available()
manifest = example_project.load_manifest()
assert manifest["project"]["source_url"].startswith("https://www.youtube.com/watch?v=")
assert (example_project.ASSETS / manifest["project"]["source"]).stat().st_size > 1_000_000
assert (example_project.ASSETS / manifest["project"]["subtitles"]).read_text(encoding="utf-8").startswith("1\n00:00:00,000")
assert len(manifest["clips"]) >= 2
previous_end = 0.0
for clip in manifest["clips"]:
assert clip["title"] and 0 < clip["final_score"] <= 1
start, end = example_project._seconds(clip["start_time"]), example_project._seconds(clip["end_time"])
assert abs(start - previous_end) < 0.01, "passages are stitched back to back"
assert end > start
assert clip["original_start_time"], "the position in the original interview is kept for reference"
previous_end = end
assert abs(previous_end - manifest["project"]["video_duration"]) < 1
def test_create_is_idempotent_and_playable(session, data_dir):
from backend.models.clip import Clip
from backend.services import example_project
project = example_project.create(session)
again = example_project.create(session)
assert again.id == project.id, "a second call must not create a duplicate"
assert project.status.value == "completed"
assert project.processing_config["example"] is True
assert project.project_metadata["source_url"] == example_project.load_manifest()["project"]["source_url"]
assert project.thumbnail.startswith("data:image/jpeg;base64,")
root = data_dir / "projects" / project.id
assert (root / "raw" / "input.mp4").exists() and (root / "raw" / "input.srt").exists(), "the editor and export need raw/input.*"
assert project.video_path == str(root / "raw" / "input.mp4")
clips = session.query(Clip).filter(Clip.project_id == project.id).all()
assert len(clips) == len(example_project.load_manifest()["clips"])
for clip in clips:
path = Path(clip.video_path)
assert path.exists() and path.parent == data_dir / "projects" / project.id / "output" / "clips"
assert path.name.startswith(f"{clip.id}_"), "the clip endpoint looks files up by <clip_id>_*.mp4"
assert clip.duration == clip.end_time - clip.start_time > 0
assert clip.clip_metadata["recommend_reason"]
assert path.stat().st_size > 500_000, "clip files are cut from the stitched source"
metadata = json.loads((root / "metadata" / "clips_metadata.json").read_text(encoding="utf-8"))
assert {m["id"] for m in metadata} == {c.id for c in clips}, "export looks clips up by database id"
from backend.services.publish_export import (
find_source_srt,
find_source_video,
load_clip_meta,
)
assert find_source_video(project.id) == root / "raw" / "input.mp4"
assert find_source_srt(project.id) == root / "raw" / "input.srt"
assert load_clip_meta(project.id, clips[0].id)["title"] == clips[0].title
def test_endpoints_report_and_create(session, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.api.v1 import example_project as api
from backend.core.database import get_db
app = FastAPI()
app.include_router(api.router, prefix="/api/v1")
app.dependency_overrides[get_db] = lambda: session
client = TestClient(app)
assert client.get("/api/v1/example-project").json() == {"available": True, "project_id": None}
created = client.post("/api/v1/example-project/create").json()
assert created["project_id"]
assert client.get("/api/v1/example-project").json()["project_id"] == created["project_id"]
assert client.post("/api/v1/example-project/create").json()["project_id"] == created["project_id"]
+108
View File
@@ -0,0 +1,108 @@
"""Speaker-centred framing math and whole-clip subtitle styles for Studio drafts."""
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from backend.services.publish_export import (
SUBTITLE_STYLES,
ExportRequest,
_build_filter,
)
from backend.services.studio import framing
from backend.services.studio.models import CropPoint, Draft, Scene
def test_crop_window_fraction_and_centering():
# 16:9 source → 9:16 output covers the height, so the window is 9/16 of the height wide.
fraction = framing.window_fraction(1280, 720, 1080, 1920)
assert abs(fraction - (720 * 9 / 16) / 1280) < 1e-6
assert framing.crop_x_for_center(.5, fraction) == pytest.approx(.5)
# A face at 70% of the width puts the window's centre there; edges clamp instead of overshooting.
assert framing.crop_x_for_center(.7, fraction) == pytest.approx((.7 - fraction / 2) / (1 - fraction))
assert framing.crop_x_for_center(.02, fraction) == 0
assert framing.crop_x_for_center(.99, fraction) == 1
# Same aspect as the output: the whole frame is the window, nothing to move.
assert framing.window_fraction(1080, 1920, 1080, 1920) == 1
assert framing.crop_x_for_center(.9, 1.0) == .5
def test_segment_track_holds_until_a_new_speaker_persists():
# One glance (single sample) at the other person must not move the window; two in a row do.
samples = [(0, .3), (2.5, .31), (5, .8), (7.5, .3), (10, .8), (12.5, .82), (15, .8), (17.5, .3), (20, .3)]
track = framing.segment_track(samples, min_hold=2, min_jump=.12)
assert [(p["start"], round(p["crop_x"], 2)) for p in track] == [(0.0, .3), (10.0, .81), (17.5, .3)]
assert framing.segment_track([]) == []
assert framing.segment_track([(0, .5)]) == [{"start": 0.0, "crop_x": .5}]
def test_crop_expression_nests_hold_segments():
scene = Scene(id="s", label="a", start=100, end=160, crop_x=.4)
assert framing.crop_expression(scene, .5) == "0.4"
assert framing.crop_expression(Scene(id="s", label="a", start=0, end=5), .5) == "0.5"
scene.crop_track = [CropPoint(start=0, crop_x=.3), CropPoint(start=10, crop_x=.8), CropPoint(start=17.5, crop_x=.3)]
assert framing.crop_expression(scene, .5) == "if(lt(t,17.5),if(lt(t,10.0),0.3,0.8),0.3)"
def test_auto_frame_builds_a_speaker_track_per_scene(monkeypatch, tmp_path):
monkeypatch.setattr(framing, "is_installed", lambda: True)
monkeypatch.setattr(framing, "_grab_pair", lambda video, at, folder, key: (folder / f"{key}-1.jpg", folder / f"{key}-2.jpg"))
# Scene "talk": speaker on the left for the first half, on the right afterwards. Scene "empty": nobody.
def speaker(pair):
key = pair[0].name
if key.startswith("empty"):
return None
index = int(key.split("-")[1])
return .3 if index < 3 else .7
monkeypatch.setattr(framing, "_speaker_center", speaker)
draft = Draft(id="d", title="t", aspect="portrait", layout="crop",
scenes=[Scene(id="talk", label="a", start=0, end=15), Scene(id="empty", label="b", start=15, end=16)])
result = framing.auto_frame(tmp_path / "input.mp4", draft, 1280, 720)
fraction = result["window_fraction"]
by_id = {s["id"]: s for s in result["scenes"]}
talk = by_id["talk"]
assert talk["faces"] == talk["samples"] == 7
assert talk["switches"] == 1 and len(talk["crop_track"]) == 2
assert talk["crop_track"][0]["crop_x"] == pytest.approx(framing.crop_x_for_center(.3, fraction), abs=1e-3)
assert talk["crop_track"][1]["crop_x"] == pytest.approx(framing.crop_x_for_center(.7, fraction), abs=1e-3)
assert talk["crop_x"] == talk["crop_track"][0]["crop_x"], "static crop_x doubles as the opening position"
assert by_id["empty"]["crop_x"] is None and by_id["empty"]["crop_track"] is None
def test_auto_frame_requires_runtime(monkeypatch, tmp_path):
monkeypatch.setattr(framing, "is_installed", lambda: False)
draft = Draft(id="d", title="t", scenes=[Scene(id="s", label="a", start=0, end=5)])
with pytest.raises(RuntimeError):
framing.auto_frame(tmp_path / "input.mp4", draft, 1280, 720)
def test_scene_crop_and_subtitle_style_round_trip():
draft = Draft(id="d", title="t", subtitle_style="accent",
scenes=[Scene(id="s", label="a", start=0, end=5, crop_x=.8), Scene(id="u", label="b", start=5, end=9)])
dumped = draft.model_dump()
assert dumped["subtitle_style"] == "accent"
assert dumped["scenes"][0]["crop_x"] == .8 and dumped["scenes"][1]["crop_x"] is None
assert dumped["scenes"][0]["crop_track"] is None
tracked = Scene(id="s", label="a", start=0, end=5, crop_track=[{"start": 0, "crop_x": .2}, {"start": 2, "crop_x": .9}])
assert [p.crop_x for p in tracked.crop_track] == [.2, .9]
with pytest.raises(ValueError):
Scene(id="s", label="a", start=0, end=5, crop_track=[{"start": -1, "crop_x": .2}])
with pytest.raises(ValueError):
Draft(id="d", title="t", subtitle_style="neon", scenes=[Scene(id="s", label="a", start=0, end=5)])
with pytest.raises(ValueError):
Scene(id="s", label="a", start=0, end=5, crop_x=1.5)
def test_subtitle_styles_reach_the_ffmpeg_filter(tmp_path):
srt = tmp_path / "a.srt"
srt.write_text("1\n00:00:00,000 --> 00:00:01,000\nhi\n", encoding="utf-8")
req = ExportRequest("p", "c", layout="fit")
for name, style in SUBTITLE_STYLES.items():
graph, _last = _build_filter(req, {"layout": "fit", "w": 1080, "h": 1920}, srt, None, None, name)
assert f"force_style='{style}'" in graph
default_graph, _ = _build_filter(req, {"layout": "fit", "w": 1080, "h": 1920}, srt, None, None)
unknown_graph, _ = _build_filter(req, {"layout": "fit", "w": 1080, "h": 1920}, srt, None, None, "nope")
assert default_graph == unknown_graph, "unknown styles fall back to the clean default"
+20 -3
View File
@@ -102,7 +102,7 @@ class SpeechRecognitionConfig:
raise ValueError(f"不支持的语言代码: {self.language}")
# 验证模型
valid_models = ["tiny", "base", "small", "medium", "large"]
valid_models = ["tiny", "base", "small", "medium", "large", "large-v3"]
if self.model not in valid_models:
raise ValueError(f"不支持的Whisper模型: {self.model}")
@@ -749,9 +749,15 @@ def _stash_speech_api_key(config: SpeechRecognitionConfig, api_key: str) -> None
config.custom_api_key = api_key
def configured_whisper_model(fallback: str = 'base') -> str:
from backend.services.ai_model_settings import load
settings = load()
return settings.transcription.model if settings and settings.transcription and settings.transcription.provider == 'whisper_local' else fallback
def generate_subtitle_for_video(video_path: Path, output_path: Optional[Path] = None,
method: str = "auto", language: str = "auto",
model: str = "base", enable_fallback: bool = True,
model: Optional[str] = None, enable_fallback: Optional[bool] = None,
enable_timestamps: bool = True,
enable_punctuation: bool = True,
enable_speaker_diarization: bool = False,
@@ -780,6 +786,18 @@ def generate_subtitle_for_video(video_path: Path, output_path: Optional[Path] =
SpeechRecognitionError: 语音识别失败
"""
# 创建配置。本地导入会带上设置里的时间戳 / 超时 / 密钥;缺了这些参数会在进 Whisper 之前 TypeError。
from backend.services.ai_model_settings import load as load_model_settings
model_settings = load_model_settings()
selection = model_settings.transcription if model_settings else None
if method == 'auto' and selection and selection.provider == 'cloud':
from backend.services.cloud_transcription import transcribe
return transcribe(Path(video_path), output_path, model_settings, language, timeout)
local_selected = bool(selection and selection.provider == 'whisper_local')
if method == 'auto' and local_selected:
method = 'whisper_local'
if enable_fallback is None:
enable_fallback = not local_selected
model = model or configured_whisper_model()
config = SpeechRecognitionConfig(
method=SpeechRecognitionMethod(method) if method != "auto" else SpeechRecognitionMethod.WHISPER_LOCAL,
language=LanguageCode(language),
@@ -853,4 +871,3 @@ def get_whisper_models() -> List[str]:
Whisper模型列表
"""
return ["tiny", "base", "small", "medium", "large"]
+76
View File
@@ -0,0 +1,76 @@
# AI 模型配置
设置 → AI 模型默认只需选择预置供应商、填写 API Key。供应商列表区分赞助伙伴、官方服务、自定义兼容接口和本地服务;赞助伙伴展示合作说明。
- 高光分析:获取可用模型后自动预选,也可手动更换。
- 画面理解:默认复用高光分析模型;高级设置中可指定另一个连接和模型。
- 封面生成:默认复用供应商和密钥,预选已适配的生图模型;没有适合的型号时使用视频截帧。高级设置中可以配置其他供应商。
- 字幕转写:以 Whisper 本地服务和模型选择呈现。一次“准备模型”操作自动完成必要组件安装及所选模型下载;安装下载即时执行,选择随设置保存。新导入任务使用所选型号,本地服务不自动回退到云端。
普通配置不需要命名或创建连接,也不使用配置弹窗。内部通过连接引用共享地址和密钥,并保留各供应商已填写的配置。按用途提供封面供应商切换与可选的补充画面模型;预置服务不显示接口地址,地址仅在自定义或本地服务中出现。只有未知的自定义模型显示能力选择。分析方式自动判断,原有显式偏好保留,重新选择分析模型后恢复自动方式。改变服务地址时必须重新填写已保存的密钥,避免把旧密钥自动发送给新地址。空输入保留已保存密钥。
自动预选只从本次返回的模型列表中选择。刷新不会替换用户已选型号,也不会将用户选择的视频截帧重新切换成生图;需要时由用户主动更换模型。
## 持久化与兼容
新配置保存在数据目录的 `ai-model-settings.json`,包括 connections、analysis、vision、cover、分析偏好和切片参数。保存前整体校验,通过临时文件和原子替换一次写入,密钥不返回到前端。API 进程和 worker 在下次调用时读取新配置。
第一次打开页面时,读取原 settings.json、vision-settings.json、cover.json 及其环境变量生成草稿;只有保存时才启用新格式。原文件保留。新格式存在后具有优先级;旧文件及环境变量的更改不覆盖已保存的新配置。独立视觉接口、独立封面接口和显式 OCR 型号均保留。
## 动态模型目录
未填写 Key 时也展示模型预览,并明确标为公开目录。无限星河实时读取公开模型广场 `https://infistar.cc/api/pricing`,按 `supported_endpoint_types` 区分分析、图片生成和图片编辑;其他预置供应商展示参考目录及能力目录。预览不会自动写入模型推荐,填 Key 后才按账号列表自动预选。
无限星河的账号 `/v1/models` 列表与公开目录按精确 ID 关联,补充接口类型与图像理解标签,不将公开但账号不可用的型号混入账号列表。公开目录缓存 1 小时,手动刷新可更新;网络失败保留缓存,首次离线使用随版本提供的精简快照并显示提示。快照只包含公开型号、接口类型和能力,不包含密钥或账号数据。
`POST /settings/ai-models/discover` 接受连接草稿,返回模型 ID、用途和能力。供应商列表成功时以其结果为准,不混入未经该服务列出的预置型号;刷新失败保留上次列表,第一次失败才展示内置参考目录。无论目录是否收录,均可手动输入模型 ID。
能力信息依次采用:用户显式设置、当前服务的模态元数据、经过核实的精确型号修正、models.dev 的精确供应商/型号资料。未知模型不会标记为“仅文字”;可手动声明能力或做图片理解测试。网关任意别名不会按字符串猜测原模型。
- 模型列表缓存 5 分钟,支持手动刷新,按供应商、地址和密钥的完整哈希隔离。
- models.dev 公共能力目录缓存 24 小时,失败保留旧版本;只拉取公开资料,不发送用户密钥或配置。
- Ollama 通过 `/api/show` 读取本地模型的 vision 能力。
- 已选模型不会被新版本、新型号或失败的刷新自动替换。
- 客户端与后端视觉路由共用模型能力资料。仅文字或能力未知的自动模式使用字幕分析。
公开数据源:https://models.dev/api.json 。Qwen3.8 Max/Flash 的多模态修正依据:https://www.alibabacloud.com/help/en/model-studio/text-generation (2026-09-29 核实)。
## 调用协议边界
模型连接使用 OpenAI Chat Completions 兼容接口;Gemini 官方列表使用原生 Models API。生图按供应商适配 OpenAI Images、Seedream、千问同步/万相异步接口、Gemini 原生图片输出、Grok Images 和智谱 Images。预置供应商自动选择生图协议;兼容服务可填写自己的地址。
官方生图参考目录位于 `backend/core/image_model_catalog.py`,保留核对日期和各官方文档链接,与 models.dev 的动态能力元数据及账号实时列表共同使用。Seed 官方 ID 与聚合站别名分别管理;已下线型号不作为无 Key 预览推荐。仅编辑模型不列入文生图列表。模型目录不等同于账号权限,也不代表已经做过真实付费生成验证。
发现一个型号不保证账号有调用权限,也不代表 AutoClip 已适配该模型的所有专有协议。自定义模型可手动选择,但服务必须兼容选定协议。图片测试会产生少量调用,列表刷新不执行生成式测试。此改动不增加云端语音转写协议。
## ASR 与分析方式(2026-09-29)
页面按「AI 服务 → 字幕转写 → 封面 → 高级」排列(`frontend/src/features/settings/AIModelSettings.tsx`,纯逻辑在 `modelSettingsLogic.ts`)。AI 服务一节只有供应商、密钥、分析模型和「画面识别」开关;转写单独绑定供应商和模型,选择已配置的供应商可复用凭证。本地 Whisper 不上传音频;云端转写只在缺少字幕时上传提取的音频。协议验证使用 HTTP mock。
画面识别是一个开关,首次配置默认开启(`allow_send_frame=true`),提示写明游戏画面、口播较少的内容推荐开启。开启映射 `analysis_mode=auto`,抽样几张画面结合字幕分析;关闭映射 `subtitle`,只发送字幕。所选分析模型仅文字时开关自动失效并说明原因。高级里可为画面理解和封面分别指定其他连接。
### 核查来源与接入边界
| 提供商 | 核查到的 ASR | 本次可生成带时间戳字幕 |
| --- | --- | --- |
| 本地 | Whisper tiny/base/small/medium/large-v3 | 是,沿用本地运行时 |
| OpenAI | whisper-1、gpt-4o-transcribe-diarize、gpt-4o-transcribe、gpt-4o-mini-transcribe | 前两项;普通 transcribe 的纯文字输出不能替代字幕时间轴 |
| 阿里云百炼 | Qwen-Audio-3.1/3.0-ASR-Flash、Fun-ASR-Flash、Qwen-ASR-Filetrans、Fun-ASR、Paraformer | 同步 Flash 系列;SSE 收集所有已完成句子,毫秒转秒。Filetrans 需公网音频地址,暂不代建对象存储 |
| 无限星河 | 公开目录当前列出 qwen-audio-3.0-asr-flash-streaming / filetrans | 目录可预览,但其具体上传/时间戳协议尚未确认,标为未接入,不能选为可用默认值 |
| 智谱 | GLM-ASR-2512 | 目录可预览,字幕时间戳适配尚未完成 |
| 火山引擎 | 豆包录音识别(大模型) | 独立语音产品的 App/Token/资源 ID,与方舟 Seed API Key 不同,暂未接入 |
| 自定义兼容服务 | 用户输入模型 ID | `/audio/transcriptions` + `verbose_json.segments`,缺少时间戳直接报错,不猜时间轴 |
来源:
- [OpenAI 文件转写](https://developers.openai.com/api/docs/guides/speech-to-text):Whisper verbose_json 与 diarized_json 的时间戳格式不同。
- [阿里云 ASR 模型总览](https://help.aliyun.com/zh/model-studio/asr-model)
- [阿里云同步 Flash HTTP 接口](https://www.alibabacloud.com/help/zh/model-studio/fun-asr-flash-recorded-speech-recognition-http-api):原 DashScope 域名仍可使用;Base64 音频与 SSE 句级时间戳。
- [无限星河公开模型目录](https://infistar.cc/api/pricing)、[公开 API 入口](https://doc.infistar.cc/api-overview)
- [智谱 GLM-ASR](https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-asr-2512)
- [火山引擎录音文件识别](https://docs.volcengine.com/docs/DoubaoVoice/AudioFileRecognitionStandardEdition?lang=zh)
实现使用 `asr_model_catalog.py` 定义明确适配的协议;模型发现附加 `asr/asr_supported/asr_note`,账号列表不会被公开目录扩大。未知 ASR 只出现在预览中,不能自动选用。新增厂商须补充请求协议和真实时间戳映射,不能仅填一个模型名。
云端任务以 16 kHz 单声道 PCM 每 180 秒分块(原始 5.76 MB,Base64 后低于 10 MB),用准确样本偏移合并时间轴。固定分块边界可能截断词语,是当前分段方式的限制。只有全任务成功才发布 SRT;请求失败不偷偷切换供应商或本地模型,不把凭证或服务端原始错误体写入日志。
新手流程:没有任何可用连接时,首页弹出一次性的「连接 AI 服务」对话框(`FirstRunSetup.tsx`,本会话内「稍后再说」后不再弹),内容与设置页 AI 服务一节完全相同,也可点「更多选项」进入设置页。首次空白配置默认:画面识别开、封面「AI 生成」且「参考视频画面」开,供应商分组把赞助伙伴放在「推荐」并保留合作说明;已有配置不强制改动。未连接 AI 服务时导入视频会被拦下并提示先连接。封面模型缺失时回落到视频截帧。模型使用单选,搜索无匹配时允许显式选择自定义型号。关闭画面识别后的连接测试也不发送图片。
+7 -1
View File
@@ -17,6 +17,8 @@ export const AnalyticsEvent = {
ApiKeyConfigured: 'api_key_configured',
/** 打开赞助商的注册 / 接入说明链接(只记录是哪家、哪个入口) */
SponsorLinkOpened: 'sponsor_link_opened',
/** 首页空态点开内置示例项目(无 Key、无原片) */
ExampleProjectOpened: 'example_project_opened',
} as const
export type AnalyticsEventName =
@@ -60,7 +62,7 @@ export function trackProcessingFailed(props: {
export function trackSponsorLinkOpened(props: {
sponsor: 'infistar'
target: 'register' | 'guide'
placement: 'settings_model'
placement: 'settings_model' | 'home_setup'
}): void {
trackEvent(AnalyticsEvent.SponsorLinkOpened, props)
}
@@ -72,3 +74,7 @@ export function trackApiKeyConfigured(props: {
}): void {
trackEvent(AnalyticsEvent.ApiKeyConfigured, props)
}
export function trackExampleProjectOpened(): void {
trackEvent(AnalyticsEvent.ExampleProjectOpened)
}
+12 -6
View File
@@ -84,6 +84,7 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, onDelete, onRetry, o
const [isRetrying, setIsRetrying] = useState(false)
// 获取分类信息
const isExample = !!(project.settings?.example || project.processing_config?.example)
const getCategoryInfo = (category?: string) => {
const categoryMap: Record<string, { name: string; icon: string; color: string }> = {
'default': { name: t("默认"), icon: '🎬', color: '#4facfe' },
@@ -409,16 +410,21 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, onDelete, onRetry, o
<PlayCircleOutlined style={{ fontSize: '32px', color: 'var(--ac-muted)' }} />
)}
{/* 分类标签 - 左上角 */}
{project.video_category && project.video_category !== 'default' && (
{/* 分类 / 示例标签 - 左上角,中性玻璃 chip */}
{((project.video_category && project.video_category !== 'default') || isExample) && (
<div style={{
position: 'absolute',
top: '8px',
left: '8px'
left: '8px',
display: 'flex',
gap: 6
}}>
<span className="ac-tag ac-tag--sans" style={{ position: 'static', fontSize: 11 }}>
{getCategoryInfo(project.video_category).name}
</span>
{isExample && <span className="ac-tag ac-tag--sans" style={{ position: 'static', fontSize: 11 }}>{t("示例")}</span>}
{project.video_category && project.video_category !== 'default' && (
<span className="ac-tag ac-tag--sans" style={{ position: 'static', fontSize: 11 }}>
{getCategoryInfo(project.video_category).name}
</span>
)}
</div>
)}
@@ -1,21 +1,24 @@
import { t } from '../i18n'
import { useTranslation } from 'react-i18next'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Popconfirm, message } from 'antd'
import { Popconfirm, Select, message } from 'antd'
import { speechApi, WhisperRuntimeStatus, WhisperModel } from '../services/api'
import { Btn, ProgressLine, Row, StatusDot } from '../ui'
interface SpeechRecognitionConfigProps {
config?: Record<string, unknown>
onConfigChange?: (config: Record<string, unknown>) => void
hideProvider?: boolean
selectedModel?: string
onModelChange?: (model: string) => void
}
// Whisper 运行时 + 模型管理 — Calm Premium 行式布局(见 DESIGN.md)
const SpeechRecognitionConfig: React.FC<SpeechRecognitionConfigProps> = () => {
const SpeechRecognitionConfig: React.FC<SpeechRecognitionConfigProps> = ({ selectedModel = 'base', onModelChange, hideProvider = false }) => {
useTranslation()
const [runtime, setRuntime] = useState<WhisperRuntimeStatus | null>(null)
const [models, setModels] = useState<WhisperModel[]>([])
const [loading, setLoading] = useState(true)
const pendingModel = useRef<string | null>(null)
const [preparing, setPreparing] = useState(false)
const timer = useRef<number | null>(null)
const refresh = useCallback(async () => {
@@ -24,7 +27,7 @@ const SpeechRecognitionConfig: React.FC<SpeechRecognitionConfigProps> = () => {
setRuntime(rt)
setModels(Array.isArray(ms) ? ms : [])
} catch {
// 后端可能尚未就绪,静默重试
// Keep the existing status until the next refresh.
} finally {
setLoading(false)
}
@@ -52,6 +55,8 @@ const SpeechRecognitionConfig: React.FC<SpeechRecognitionConfigProps> = () => {
setRuntime((p) => (p ? { ...p, status: 'installing', progress: 5 } : p))
refresh()
} catch (e: any) {
pendingModel.current = null
setPreparing(false)
message.error(e?.response?.data?.detail || t("安装失败"))
}
}
@@ -87,101 +92,55 @@ const SpeechRecognitionConfig: React.FC<SpeechRecognitionConfigProps> = () => {
}
}
if (loading) return <div className="ac-hint">{t("读取 Whisper 状态…")}</div>
useEffect(() => {
if (runtime?.status === 'error') { pendingModel.current = null; setPreparing(false) }
if (runtime?.status === 'installed' && pendingModel.current) {
const model = pendingModel.current
pendingModel.current = null
void handleDownload(model).finally(() => setPreparing(false))
}
}, [runtime?.status])
const selected = models.find(m => m.name === selectedModel)
const installed = runtime?.status === 'installed'
const installing = runtime?.status === 'installing'
const supported = runtime?.platform_supported !== false
const downloading = selected?.status === 'downloading'
const ready = installed && selected?.status === 'downloaded'
const prepare = async () => {
setPreparing(true)
if (installed) {
await handleDownload(selectedModel)
setPreparing(false)
} else {
pendingModel.current = selectedModel
await handleInstall()
}
}
return (
<>
<div className="ac-rows">
<Row
top
label={t("Whisper 运行时")}
hint={
!supported ? t("当前平台不支持本地转写。")
: installed ? t("faster-whisper 已安装{{value1}}。", { value1: runtime?.packages?.length ? `(${runtime.packages.join(', ')})` : '' })
: installing ? (runtime?.message || t("正在安装…"))
: runtime?.status === 'error' ? t("安装出错:{{value1}}", { value1: runtime?.message || '' })
: t("按需安装,约 200–400 MB(不含 PyTorch)。装好后再选一个模型下载即可。")
}
>
{installed && (
<>
<StatusDot tone="ok" label={t("已安装")} />
<Popconfirm title={t("卸载 Whisper 运行时?已下载的模型不会被删除。")} onConfirm={handleUninstall} okText={t("卸载")} cancelText={t("取消")}>
<Btn variant="danger" size="sm">{t("卸载")}</Btn>
</Popconfirm>
</>
)}
{installing && (
<div style={{ width: 220 }}>
<ProgressLine percent={runtime?.progress ?? 5} />
<div className="ac-hint" style={{ textAlign: 'right', fontFamily: 'var(--ac-font-mono)' }}>{Math.round(runtime?.progress ?? 5)}%</div>
</div>
)}
{runtime?.status === 'not_installed' && (
<Btn variant="cta" size="sm" style={{ height: 32, fontSize: 13, padding: '0 16px' }} onClick={handleInstall} disabled={!supported}>{t("安装")}</Btn>
)}
{runtime?.status === 'error' && (
<Btn size="sm" onClick={handleInstall} disabled={!supported}>{t("重试安装")}</Btn>
)}
</Row>
</div>
{installing && runtime?.log_tail && (
<pre className="ac-input ac-input--mono" style={{ height: 'auto', maxHeight: 120, overflow: 'auto', padding: '8px 12px', margin: '12px 0 0', color: 'var(--ac-sub)', background: 'var(--ac-line-2)', fontSize: 11, whiteSpace: 'pre-wrap' }}>
{runtime.log_tail}
</pre>
)}
<div className="ac-eyebrow" style={{ marginTop: 40, marginBottom: 12 }}>{t("模型")}</div>
{!installed ? (
<div className="ac-hint">{t("先安装运行时,再在这里下载模型。")}</div>
) : (
<div className="ac-rows">
{models.map((m) => {
const downloaded = m.status === 'downloaded'
const downloading = m.status === 'downloading'
return (
<Row
key={m.name}
label={
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 10 }}>
<span className="ac-mono">{m.name}</span>
<span className="ac-mono" style={{ fontSize: 12, color: 'var(--ac-muted)', fontWeight: 400 }}>{m.size}</span>
{downloaded && <StatusDot tone="ok" label={t("已下载")} />}
</span>
}
hint={
<>
{t(m.description)} {t("· 准确度")}: {t(m.accuracy)} {t("· 速度")}: {t(m.speed)}
{m.status === 'error' && m.errorMessage && <span style={{ color: 'var(--ac-error)' }}> · {m.errorMessage}</span>}
</>
}
>
{downloaded ? (
<Popconfirm title={t("删除模型 {{value1}}?", { value1: m.name })} onConfirm={() => handleDelete(m.name)} okText={t("删除")} cancelText={t("取消")}>
<Btn variant="danger" size="sm">{t("删除")}</Btn>
</Popconfirm>
) : downloading ? (
<div style={{ width: 160 }}>
<ProgressLine percent={m.downloadProgress ?? 0} />
<div className="ac-hint" style={{ textAlign: 'right', fontFamily: 'var(--ac-font-mono)' }}>
{m.downloadProgress != null ? `${Math.round(m.downloadProgress)}%` : t("下载中")}
</div>
</div>
) : (
<Btn size="sm" onClick={() => handleDownload(m.name)}>{t("下载")}</Btn>
)}
</Row>
)
})}
</div>
)}
</>
)
return <div className="ac-rows">
{!hideProvider && <Row label={t('提供商')} hint={t('在本机把音频转成字幕,不上传音频,无需 API Key。')}>
<span>{t('Whisper · 本地')}</span>
</Row>}
<Row wide label={t('转写模型')} hint={t('视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。')}>
<Select aria-label={t('转写模型')} style={{ width: '100%' }} value={selectedModel} loading={loading} disabled={preparing || installing || downloading}
options={(models.length ? models.map(m => ({ value: m.name, label: `${m.name} · ${m.size}` })) : ['tiny', 'base', 'small', 'medium', 'large-v3'].map(name => ({ value: name, label: name })))}
onChange={onModelChange} />
</Row>
<Row label={ready ? t('模型已就绪') : t('准备本地模型')} hint={ready ? t('保存设置后,新任务将使用这个模型。') : runtime?.status === 'error' ? runtime.message : selected?.status === 'error' && installed ? selected.errorMessage : t('首次使用需下载模型和必要组件,之后可在本机转写。')}>
{ready ? <StatusDot tone="ok" label={t('已就绪')} /> : installing || downloading || preparing ? <div style={{ width: 220 }}>
<ProgressLine percent={installing ? runtime?.progress ?? 5 : selected?.downloadProgress ?? 0} />
<span className="ac-hint">{installing ? t('正在准备组件…') : t('正在下载模型…')}</span>
</div> : <Btn size="sm" disabled={runtime?.platform_supported === false || loading || !runtime} onClick={() => void prepare()}>{t('准备模型')}</Btn>}
</Row>
{!runtime && !loading && <p className="ac-note">{t('暂时无法读取本地模型状态。')} <Btn variant="text" size="sm" onClick={() => void refresh()}>{t('重试')}</Btn></p>}
<details className="ac-disclosure"><summary>{t('本地模型管理')}</summary>
{models.filter(m => m.status === 'downloaded').map(m => <Row key={m.name} label={`${m.name} · ${m.size}`}>
<Popconfirm title={t('删除模型 {{value1}}?', { value1: m.name })} onConfirm={() => handleDelete(m.name)} okText={t('删除')} cancelText={t('取消')}><Btn variant="danger" size="sm">{t('删除')}</Btn></Popconfirm>
</Row>)}
{installed && <Row label={t('本地转写组件')}><Popconfirm title={t('卸载 Whisper 运行时?已下载的模型不会被删除。')} onConfirm={handleUninstall} okText={t('卸载')} cancelText={t('取消')}><Btn variant="danger" size="sm">{t('卸载')}</Btn></Popconfirm></Row>}
{runtime?.log_tail && <pre className="ac-note" style={{ maxHeight: 120, overflow: 'auto', whiteSpace: 'pre-wrap' }}>{runtime.log_tail}</pre>}
</details>
</div>
}
export default SpeechRecognitionConfig
@@ -0,0 +1,145 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Select, Switch } from 'antd'
import { t } from '../../i18n'
import { Btn, Row, Section, Segmented, StatusDot } from '../../ui'
import SpeechRecognitionConfig from '../../components/SpeechRecognitionConfig'
import { PROVIDERS, providerPickerOptions, type ProviderKey } from './providers'
import { useModelSettings, type ModelSettingsStore } from './useModelSettings'
import { connectionOf, isTextOnly, mainConnection, presetKey, type SaveIssue } from './modelSettingsLogic'
import ProviderFields from './ProviderFields'
import ModelPicker from './ModelPicker'
const ANCHORS: Record<string, string> = { model: 'ai-model', analysis: 'ai-model', vision: 'ai-model', speech: 'ai-speech', cover: 'ai-cover' }
const issueAnchor = (issue: SaveIssue) => issue.role === 'transcription' ? 'ai-speech' : issue.role === 'cover' ? 'ai-cover' : 'ai-model'
/**
* Main AI service: provider, key, model (auto-recommended) and the frame-sampling switch.
* Shared with the first-run card so both paths behave identically.
*/
export function AIServiceFields({ m, placement, showVisual = true }: { m: ModelSettingsStore; placement: 'settings_model' | 'home_setup'; showVisual?: boolean }) {
const { settings, lists, busy, listErrors } = m
const main = settings && mainConnection(settings)
if (!settings) return null
// First run: nothing is pre-selected. Key and model rows appear once a provider is chosen.
if (!main) return <ProviderFields placement={placement} onChoose={p => m.chooseProvider('analysis', p)} onEdit={() => undefined} />
const list = lists[main.id]
const model = settings.analysis?.model || ''
const hint = busy[main.id] && !model ? t('正在获取可用模型…')
: model && list && !list.preview ? t('已为你选好推荐模型,可随时更换。')
: t('填写 API Key 后自动选择推荐模型。')
return <>
<ProviderFields connection={main} placement={placement} onChoose={p => m.chooseProvider('analysis', p)} onEdit={patch => m.editConnection(main, patch)} />
<Row wide label={t('模型')} hint={hint}>
<ModelPicker role="analysis" model={model} capability={settings.analysis?.capability} connection={main} list={list} busy={!!busy[main.id]} listError={listErrors[main.id]} mode={settings.analysis_mode}
onChange={value => m.update({ analysis: { connection_id: main.id, model: value, capability: 'auto' } })}
onCapability={capability => settings.analysis && m.update({ analysis: { ...settings.analysis, capability } })}
onRefresh={() => void m.discover(main, true)}
actions={<Btn variant="text" size="sm" disabled={!model} loading={m.testing} onClick={() => void m.test()}>{t('测试连接')}</Btn>} />
</Row>
{showVisual && <Row label={t('画面识别')} hint={isTextOnly(settings, lists)
? t('当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。')
: t('抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。')}>
<Switch checked={settings.analysis_mode !== 'subtitle'} onChange={m.setVisual} />
</Row>}
</>
}
function TranscriptionSection({ m }: { m: ModelSettingsStore }) {
const { settings, lists, busy, listErrors } = m
if (!settings) return null
const cloud = settings.transcription?.provider === 'cloud'
const connection = cloud ? connectionOf(settings, 'transcription') : undefined
const main = mainConnection(settings)
const options = [
{ label: t('本机运行'), options: [{ value: 'whisper_local', label: t('Whisper · 本地') }] },
...providerPickerOptions().map(group => ({ ...group, options: group.options.filter(p => ['openai', 'dashscope', 'infistar', 'glm', 'compatible'].includes(p.value)) })).filter(group => group.options.length),
]
return <div id="ai-speech" className="ac-model-section">
<h3 className="ac-model-section-title">{t('字幕转写')}</h3>
<p className="ac-note">{t('视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。')}</p>
<Row wide label={t('转写方式')} hint={t('本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。')}>
<Select aria-label={t('转写方式')} style={{ width: '100%' }} value={cloud ? connection?.provider : 'whisper_local'} options={options}
onChange={value => value === 'whisper_local' ? m.setTranscriptionLocal(settings.transcription?.model && settings.transcription.provider === 'whisper_local' ? settings.transcription.model : 'base') : m.chooseProvider('transcription', value as ProviderKey)}
optionRender={option => <span>{option.value === 'whisper_local' ? t('Whisper · 本地') : PROVIDERS[option.value as ProviderKey]?.name}{option.value === 'infistar' && <small style={{ marginLeft: 8, color: 'var(--sub)' }}>{t('赞助')} · {t('$5 免费体验额度')}</small>}</span>} />
</Row>
{cloud && connection ? <>
<ProviderFields connection={connection} ariaPrefix={t('转写')} hideProvider placement="settings_model"
sharedKey={connection.id === main?.id && !!(connection.api_key || connection.has_key)}
onChoose={p => m.chooseProvider('transcription', p)} onEdit={patch => m.editConnection(connection, patch)} />
<Row wide label={t('转写模型')} hint={t('将音频发送给所选服务转写,按服务商计费。')}>
<ModelPicker role="transcription" model={settings.transcription?.model || ''} connection={connection} list={lists[connection.id]} busy={!!busy[connection.id]} listError={listErrors[connection.id]} mode={settings.analysis_mode}
onChange={model => m.update({ transcription: { provider: 'cloud', connection_id: connection.id, model } })} onRefresh={() => void m.discover(connection, true)} />
</Row>
</> : <SpeechRecognitionConfig hideProvider selectedModel={settings.transcription?.model || 'base'} onModelChange={m.setTranscriptionLocal} />}
</div>
}
function CoverSection({ m }: { m: ModelSettingsStore }) {
const { settings, lists, busy, listErrors } = m
if (!settings) return null
const main = mainConnection(settings)
const connection = connectionOf(settings, 'cover') || main
const separate = !!settings.cover && settings.cover.connection_id !== main?.id
const mainHasImage = !!lists[main?.id || '']?.models.some(x => x.image)
return <div id="ai-cover" className="ac-model-section">
<h3 className="ac-model-section-title">{t('封面')}</h3>
<p className="ac-note">{t('默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。')}</p>
<Row wide label={t('封面来源')}>
<Segmented ariaLabel={t('封面来源')} value={settings.cover_enabled ? 'ai' : 'frame'} options={[{ value: 'frame', label: t('视频截帧') }, { value: 'ai', label: t('AI 生成') }]} onChange={value => m.setCoverEnabled(value === 'ai')} />
</Row>
{settings.cover_enabled && !main && <p className="ac-note">{t('先在上面选择一家 AI 服务,生图模型会自动选好。')}</p>}
{settings.cover_enabled && main && <>
<Row label={t('封面使用其他服务')} hint={separate || mainHasImage || !lists[main?.id || ''] ? t('默认与 AI 服务共用 API Key,无需重复填写。') : t('{{name}} 没有生图模型,可以为封面单独选一家服务。', { name: PROVIDERS[presetKey(main!)]?.name || main?.provider })}>
<Switch checked={separate} onChange={m.setCoverSeparate} />
</Row>
{separate && connection && <ProviderFields connection={connection} ariaPrefix={t('封面')} placement="settings_model" onChoose={p => m.chooseProvider('cover', p)} onEdit={patch => m.editConnection(connection, patch)} />}
{connection && <Row wide label={t('生图模型')} hint={t('已自动选择推荐的生图模型,可更换。')}>
<ModelPicker role="cover" model={settings.cover?.model || ''} connection={connection} list={lists[connection.id]} busy={!!busy[connection.id]} listError={listErrors[connection.id]} mode={settings.analysis_mode}
onChange={model => m.setCoverModel(connection.id, model)} onRefresh={() => void m.discover(connection, true)} />
</Row>}
<Row label={t('参考视频画面')} hint={t('开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。')}>
<Switch checked={settings.allow_send_frame} onChange={value => m.update({ allow_send_frame: value })} />
</Row>
</>}
</div>
}
export default function AIModelSettings({ requestedSection }: { requestedSection: string }) {
useTranslation()
const m = useModelSettings()
const { settings } = m
useEffect(() => {
const anchor = ANCHORS[requestedSection]
if (!settings || !anchor) return
const timer = window.setTimeout(() => document.getElementById(anchor)?.scrollIntoView({ block: 'start' }), 0)
return () => window.clearTimeout(timer)
}, [requestedSection, !!settings])
if (!settings) return <Section title={t('AI 模型')}><p className="ac-note">{m.error || t('加载中…')}</p>{m.error && <Btn onClick={() => void m.load()}>{t('重试')}</Btn>}</Section>
const save = async () => {
const result = await m.save()
if (result.issue) document.getElementById(issueAnchor(result.issue))?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}
return <div className="ac-ai-settings">
<fieldset className="ac-model-fields" disabled={m.saving}>
<div id="ai-model" className="ac-model-section">
<h3 className="ac-model-section-title">{t('AI 服务')}</h3>
<p className="ac-note">{t('选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。')}</p>
<AIServiceFields m={m} placement="settings_model" />
</div>
<TranscriptionSection m={m} />
<CoverSection m={m} />
<details className="ac-disclosure ac-model-section"><summary>{t('高级')}</summary>
<Row label={t('文本分块大小')}><input aria-label={t('文本分块大小')} className="ac-input" type="number" min={1000} max={10000} step={500} value={settings.chunk_size} onChange={e => m.update({ chunk_size: Number(e.target.value) })} /></Row>
<Row label={t('最低评分阈值')}><input aria-label={t('最低评分阈值')} className="ac-input" type="number" min={.1} max={1} step={.05} value={settings.min_score_threshold} onChange={e => m.update({ min_score_threshold: Number(e.target.value) })} /></Row>
<Row label={t('每个合集最多切片')}><input aria-label={t('每个合集最多切片')} className="ac-input" type="number" min={1} max={20} value={settings.max_clips_per_collection} onChange={e => m.update({ max_clips_per_collection: Number(e.target.value) })} /></Row>
</details>
</fieldset>
{m.error && <p className="studio-error" role="alert">{m.error}</p>}
<div className="ac-savebar">
<StatusDot tone={m.dirty ? 'muted' : 'ok'} label={m.dirty ? t('有未保存的更改') : settings.saved ? t('设置已保存') : t('首次使用,保存后生效')} />
<Btn variant="cta" loading={m.saving} onClick={() => void save()}>{t('保存设置')}</Btn>
</div>
</div>
}
@@ -1,87 +0,0 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { t } from '../../i18n'
import api from '../../services/api'
import { studioApi, errorText } from '../studio/api'
import type { AnalysisPreferences } from '../studio/types'
type Mode = AnalysisPreferences['analysis_mode']
interface VisionView { mode: 'text_model' | 'custom'; model: string; source: string; configured: boolean }
export interface AnalysisVisionHandle { save: () => Promise<void> }
// 三种方式把「效果 / 花费 / 适合」写清楚,让用户自己权衡;图片 token 远多于文字,不藏着
const MODES: Array<{ value: Mode; title: () => string; body: () => string; cost: () => string; fit: () => string }> = [
{ value: 'auto', title: () => t('智能选择(推荐)'), body: () => t('先看字幕;字幕不够时(没对白、游戏录屏)再抽几张画面判断。'), cost: () => t('花费中等,只在需要时看画面'), fit: () => t('不确定素材类型时选这个') },
{ value: 'subtitle', title: () => t('字幕分析'), body: () => t('只读字幕文字,找观点、金句和话题。'), cost: () => t('花费最低'), fit: () => t('适合访谈、讲解、直播等有人说话的视频') },
{ value: 'visual', title: () => t('视觉分析'), body: () => t('抽样画面和字幕一起理解,动作、表情、画面高光更准。'), cost: () => t('花费最高:图片消耗的 token 通常是字幕的数倍'), fit: () => t('适合游戏、运动、没有对白的视频') },
]
/**
* 分析方式。能不能看画面由「模型」里选的模型决定(多模态 / 仅文字),这里不再单独配置视觉模型。
* 选「智能 / 视觉」即同意在需要时发送抽样画面;想完全不看画面选字幕分析。
*/
const AnalysisVisionSettings = forwardRef<AnalysisVisionHandle, { multimodal: boolean | null }>(({ multimodal }, ref) => {
useTranslation()
const [mode, setMode] = useState<Mode>('auto')
const [legacy, setLegacy] = useState<VisionView | null>(null)
const [followModel, setFollowModel] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
let live = true
studioApi.analysisPreferences().then((p) => { if (live) setMode(p.analysis_mode) }).catch((e) => live && setError(t(errorText(e))))
api.get<unknown, VisionView>('/studio/vision-settings')
.then((v) => { if (live && v.mode === 'custom' && v.configured) setLegacy(v) })
.catch(() => undefined)
return () => { live = false }
}, [])
useImperativeHandle(ref, () => ({
save: async () => {
await studioApi.saveAnalysisPreferences({ analysis_mode: mode, allow_visual_screening: mode !== 'subtitle' })
if (followModel) {
await api.put('/studio/vision-settings', { mode: 'text_model' })
setLegacy(null)
setFollowModel(false)
}
},
}), [mode, followModel])
// 旧版单独配了视觉接口的,继续用它看画面,除非用户选择改为跟随模型
const legacyActive = !!legacy && !followModel
const canSee = legacyActive ? true : multimodal
const note = mode === 'subtitle'
? t('不会发送任何画面。')
: canSee === false
? (mode === 'visual'
? t('当前模型只能处理文字,视觉分析用不了。换一个标着「多模态」的模型,或改选智能 / 字幕分析。')
: t('当前模型只能处理文字,智能选择会只用字幕。换成多模态模型后才会在需要时看画面。'))
: t('需要时会发送抽样画面,按模型服务商计费。')
return (
<>
<div className="ac-mode-cards" role="radiogroup" aria-label={t('分析方式')}>
{MODES.map((m) => (
<button key={m.value} type="button" role="radio" aria-checked={mode === m.value} className="ac-mode-card" onClick={() => setMode(m.value)}>
<b>{m.title()}</b>
<span>{m.body()}</span>
<small className="cost">{m.cost()}</small>
<small>{m.fit()}</small>
</button>
))}
</div>
<p className="ac-note">{note}</p>
{legacyActive && legacy && (
<p className="ac-note">
{t('看画面目前用的是之前单独配置的')} <span className="ac-mono">{legacy.model}</span>{legacy.source === 'environment' ? t('(来自环境变量)') : ''}。
{' '}<a className="ac-link" onClick={() => setFollowModel(true)}>{t('改为使用上面的模型')}</a>
</p>
)}
{followModel && <p className="ac-note">{t('保存后,看画面将使用上面选的模型。')}</p>}
{error && <p className="studio-error" role="alert">{error}</p>}
</>
)
})
export default AnalysisVisionSettings
@@ -1,142 +0,0 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Input, Select, Switch } from 'antd'
import { t } from '../../i18n'
import { Row, Segmented } from '../../ui'
import { coverApi, type CoverConfigView, type CoverProvider } from '../../publish/coverApi'
export interface CoverModelHandle { save: () => Promise<void> }
const NONE = '__frame__'
const OTHER = '__other__'
// 接口没返回生图名单时的兜底;与 backend/core/model_catalog.py IMAGE_MODELS 对齐
const FALLBACK_IMAGE_MODELS: Record<string, string[]> = {
dashscope: ['wanx2.1-t2i-turbo', 'wanx2.1-t2i-plus'],
seed: ['doubao-seedream-5-0-260128'],
openai: ['gpt-image-1'],
infistar: ['gpt-image-1', 'dall-e-3', 'doubao-seedream-5-0-260128', 'flux-1.1-pro', 'imagen-4', 'wanx2.1-t2i-turbo'],
}
const OTHER_PRESETS: Record<CoverProvider, { model: string; baseUrl: string }> = {
openai: { model: 'gpt-image-1', baseUrl: 'https://api.openai.com/v1' },
seedream: { model: 'doubao-seedream-5-0-260128', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3' },
dashscope: { model: 'wanx2.1-t2i-turbo', baseUrl: '' },
}
/**
* 「模型」里的封面生图:默认跟着上面的服务商和 Key 走(通义→通义万相、Seed→Seedream、
* OpenAI / Infistar→images 接口,Infistar 可选各家主流生图模型)。
* 上面的服务商没有接入生图时,可以「用其他服务生图」,就地填一组生图配置。
*/
const CoverModelRow = forwardRef<CoverModelHandle, { provider: string; imageModels: string[] }>(({ provider, imageModels }, ref) => {
useTranslation()
const [config, setConfig] = useState<CoverConfigView | null>(null)
const [choice, setChoice] = useState<string>(NONE)
const [draft, setDraft] = useState('')
const [allowFrame, setAllowFrame] = useState(false)
const [other, setOther] = useState({ provider: 'openai' as CoverProvider, model: '', key: '', baseUrl: '' })
const [touched, setTouched] = useState(false)
const followModels = imageModels.length ? imageModels : (FALLBACK_IMAGE_MODELS[provider] || [])
useEffect(() => {
let live = true
coverApi.getConfig().then((c) => {
if (!live) return
setConfig(c)
setAllowFrame(!!c.allow_send_frame)
if (c.mode === 'custom' && (c.configured || c.enabled)) {
setChoice(c.enabled ? OTHER : NONE)
setOther({ provider: (c.provider as CoverProvider) || 'openai', model: c.model || '', key: '', baseUrl: c.base_url || '' })
} else {
setChoice(c.enabled && c.model ? c.model : NONE)
}
}).catch(() => undefined)
return () => { live = false }
}, [])
useImperativeHandle(ref, () => ({
save: async () => {
if (!touched) return
const enabled = choice !== NONE
const body = choice === OTHER
? { mode: 'custom' as const, enabled: true, provider: other.provider, model: other.model.trim(), base_url: other.baseUrl.trim(), api_key: other.key.trim() || undefined, allow_send_frame: allowFrame }
: { mode: 'text_model' as const, enabled, model: enabled ? choice : '', allow_send_frame: enabled && allowFrame }
const saved = await coverApi.saveConfig(body)
setConfig(saved)
setOther((o) => ({ ...o, key: '' }))
setTouched(false)
},
}), [touched, choice, other, allowFrame])
const pick = (v: string) => {
setChoice(v); setDraft(''); setTouched(true)
if (v === OTHER && !other.model) setOther((o) => ({ ...o, model: OTHER_PRESETS[o.provider].model, baseUrl: o.baseUrl || OTHER_PRESETS[o.provider].baseUrl }))
}
const chooseOtherProvider = (next: CoverProvider) => {
const prev = OTHER_PRESETS[other.provider]
setOther((o) => ({
...o,
provider: next,
model: !o.model || o.model === prev.model ? OTHER_PRESETS[next].model : o.model,
baseUrl: !o.baseUrl || o.baseUrl === prev.baseUrl ? OTHER_PRESETS[next].baseUrl : o.baseUrl,
}))
setTouched(true)
}
const custom = choice !== NONE && choice !== OTHER && !followModels.includes(choice) ? [choice] : []
const typed = draft && draft !== choice && !followModels.includes(draft) ? [draft] : []
const options = [
...(followModels.length ? [{ label: t('同一服务商'), options: [...typed, ...custom, ...followModels].map((m) => ({ value: m, label: m })) }] : []),
{ label: t('其他'), options: [{ value: OTHER, label: t('用其他服务生图…') }, { value: NONE, label: t('不生成,用视频截帧') }] },
]
const ownKey = config?.mode === 'custom' && config.key_source === 'own'
return (
<>
<Row
wide
label={t('封面生图')}
hint={followModels.length
? t('默认用同一个服务商和 Key。可从列表选,也可以直接输入账号里可用的生图模型 ID;生图失败会自动改用视频截帧。')
: t('上面的服务商还没接入生图,可以选「用其他服务生图」单独填一个;不选则用视频截帧。')}
>
<Select
value={choice}
style={{ width: '100%' }}
className="ac-mono"
showSearch={followModels.length > 0}
onSearch={(text) => setDraft(text.trim())}
onChange={pick}
options={options}
/>
</Row>
{choice === OTHER && (
<>
<Row label={t('生图服务')}>
<Segmented size="sm" ariaLabel={t('生图服务')} value={other.provider} onChange={chooseOtherProvider}
options={[{ value: 'openai', label: t('OpenAI 兼容') }, { value: 'seedream', label: 'Seedream' }, { value: 'dashscope', label: t('通义万相') }]} />
</Row>
<Row wide label="API Key" hint={ownKey ? t('已配置;留空保留已有密钥') : t('和上面是同一家服务时可以留空,自动复用。')}>
<Input.Password className="ac-mono" autoComplete="new-password" placeholder={ownKey ? (config?.api_key_masked || '') : 'sk-…'} value={other.key} onChange={(e) => { setOther({ ...other, key: e.target.value }); setTouched(true) }} />
</Row>
<Row wide label={t('生图模型')}>
<Input className="ac-mono" placeholder={OTHER_PRESETS[other.provider].model} value={other.model} onChange={(e) => { setOther({ ...other, model: e.target.value }); setTouched(true) }} />
</Row>
{other.provider !== 'dashscope' && (
<Row wide label={t('接口地址')}>
<Input className="ac-mono" placeholder={OTHER_PRESETS[other.provider].baseUrl} value={other.baseUrl} onChange={(e) => { setOther({ ...other, baseUrl: e.target.value }); setTouched(true) }} />
</Row>
)}
</>
)}
{choice !== NONE && (
<Row label={t('允许上传参考帧')} hint={t('把视频截帧一起发给生图服务做参考,封面更贴近画面。默认关闭。')}>
<Switch checked={allowFrame} onChange={(on) => { setAllowFrame(on); setTouched(true) }} />
</Row>
)}
</>
)
})
export default CoverModelRow
@@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import { t } from '../../i18n'
import { Btn, Dialog, StatusDot } from '../../ui'
import { useModelSettings } from './useModelSettings'
import { AIServiceFields } from './AIModelSettings'
import { connectionReady, mainConnection, needsSetup } from './modelSettingsLogic'
const DISMISSED_KEY = 'autoclip.firstRunSetup.dismissed'
/**
* One-time dialog shown on first launch until an AI service is saved. Same fields and save path as
* the settings page; "later" hides it for this session, and a blocked import can reopen it.
*/
export default function FirstRunSetup({ onStatus, openRequest = 0 }: { onStatus?: (needed: boolean) => void; openRequest?: number }) {
useTranslation()
const navigate = useNavigate()
const m = useModelSettings()
const { settings } = m
const [open, setOpen] = useState(false)
// Decide once from the loaded document: typing a key must not close the dialog mid-edit.
const initial = useRef<boolean | null>(null)
if (settings && initial.current === null) initial.current = needsSetup(settings)
const needed = !!settings && !!initial.current && !settings.saved
useEffect(() => { if (settings) onStatus?.(needed) }, [settings, needed])
useEffect(() => {
if (!needed) { setOpen(false); return }
let dismissed = false
try { dismissed = sessionStorage.getItem(DISMISSED_KEY) === '1' } catch { /* restricted storage: just show it */ }
if (!dismissed) setOpen(true)
}, [needed])
useEffect(() => { if (openRequest > 0 && needed) setOpen(true) }, [openRequest])
if (!settings || !needed) return null
const later = () => { try { sessionStorage.setItem(DISMISSED_KEY, '1') } catch { /* ignore */ } setOpen(false) }
const main = mainConnection(settings)
const model = settings.analysis?.model
const fetching = !!main && !!m.busy[main.id] && !model
const status = fetching ? t('正在获取可用模型…')
: model ? t('已选好模型:{{model}}', { model })
: !main ? t('先选择一家 AI 服务')
: connectionReady(main) ? t('请确认 API Key,或直接输入模型名')
: t('等待 API Key')
return <Dialog open={open} onClose={later} title={t('先连接一个 AI 服务')}
description={t('选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。')}
footer={<div className="ac-setup-foot">
<Btn variant="text" size="sm" onClick={() => { setOpen(false); navigate('/settings?section=ai') }}>{t('更多选项')}</Btn>
<Btn size="sm" onClick={later}>{t('稍后再说')}</Btn>
<Btn variant="cta" size="sm" loading={m.saving} disabled={fetching} onClick={() => void m.save()}>{t('连接并保存')}</Btn>
</div>}>
{/* AntD popups default to z-index 1050 and would sit behind the dialog backdrop (1200). */}
<ConfigProvider theme={{ token: { zIndexPopupBase: 1300 } }}>
<div className="ac-setup-dialog ac-ai-settings">
<div className="ac-rows"><AIServiceFields m={m} placement="home_setup" showVisual={false} /></div>
{m.error && <p className="studio-error" role="alert">{m.error}</p>}
<div className="ac-setup-status"><StatusDot tone={fetching ? 'accent' : model ? 'ok' : 'muted'} label={status} /></div>
</div>
</ConfigProvider>
</Dialog>
}
@@ -0,0 +1,64 @@
import { useState, type ReactNode } from 'react'
import { Select } from 'antd'
import { t } from '../../i18n'
import { Btn } from '../../ui'
import type { Capability, Connection, ModelEntry, ModelList, ModelSettings } from './modelSettingsApi'
import { analysisModels } from './modelDefaults'
import { connectionReady, presetKey, type Role } from './modelSettingsLogic'
export function eligibleModels(role: Role, models: ModelEntry[], mode: ModelSettings['analysis_mode']) {
if (role === 'cover') return models.filter(m => m.image)
if (role === 'transcription') return models.filter(m => m.asr)
return analysisModels(models, role === 'vision' ? 'visual' : mode)
}
/**
* Searchable model list for one role. Custom IDs are allowed where the service cannot enumerate them.
* Refresh appears only when the list is not the live account list; a discovery error sits next to the field.
*/
export default function ModelPicker({ role, model, capability, connection, list, busy, listError, mode, actions, onChange, onCapability, onRefresh }: {
role: Role
model: string
capability?: Capability
connection?: Connection
list?: ModelList
busy: boolean
listError?: string
mode: ModelSettings['analysis_mode']
actions?: ReactNode
onChange: (model: string) => void
onCapability?: (capability: Capability) => void
onRefresh: () => void
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const models = list ? eligibleModels(role, list.models, mode) : []
const custom = search.trim()
const allowCustom = role !== 'transcription' || connection?.provider === 'compatible'
const customOption = allowCustom && custom && !models.some(m => m.id === custom) ? [{ value: custom, label: `${t('使用自定义型号')}:${custom}` }] : []
const tag = (m: ModelEntry) => role === 'analysis' || role === 'vision'
? (m.capability ? ` · ${m.capability === 'multimodal' ? t('多模态') : t('仅文字')}` : '')
: role === 'transcription' && m.asr_note ? ` · ${t(m.asr_note)}` : ''
const unknownCapability = (role === 'analysis' || role === 'vision') && !!model && !!connection && presetKey(connection) === 'compatible' && !list?.models.find(m => m.id === model)?.capability
const stale = !!connection && connectionReady(connection) && !busy && (!!listError || !list || list.source !== 'live')
// The generic preview warning is already covered by the row hint; account-specific warnings stay visible.
const warning = list?.warning && list.warning !== '公开目录预览,填写 API Key 后确认账号可用模型。' ? t(list.warning) : ''
return <div style={{ width: '100%' }}>
<Select aria-label={t(role === 'cover' ? '生图模型' : role === 'transcription' ? '转写模型' : '模型')} disabled={!connection} showSearch value={model || undefined} style={{ width: '100%' }}
open={open} onDropdownVisibleChange={o => { setOpen(o); if (!o) setSearch('') }} onSearch={setSearch}
placeholder={busy ? t('正在获取可用模型…') : t(role === 'transcription' && connection?.provider !== 'compatible' ? '选择转写模型' : '选择或搜索模型,也可手动输入模型名')}
loading={busy} optionFilterProp="value"
notFoundContent={t(role === 'cover' ? '该服务没有可用的生图模型。' : '暂无可用模型,请检查 API Key 后刷新。')}
options={[...customOption, ...models.map(m => ({ value: m.id, label: `${m.id}${tag(m)}`, disabled: role === 'transcription' && !m.asr_supported }))]}
onChange={(value: string) => { onChange(value); setOpen(false) }} />
{(actions || stale) && <div className="ac-model-control-action">
{actions}
{stale && <Btn variant="text" size="sm" onClick={onRefresh}>{t('刷新')}</Btn>}
</div>}
{(listError || warning) && <p className="ac-note ac-note--error">{listError || warning}</p>}
{unknownCapability && onCapability && <Select aria-label={t('自定义模型类型')} style={{ width: '100%', marginTop: 8 }} value={capability || 'auto'}
options={[{ value: 'auto', label: t('自动识别') }, { value: 'multimodal', label: t('多模态') }, { value: 'text', label: t('仅文字') }]} onChange={onCapability} />}
{role === 'transcription' && models.length > 0 && !models.some(m => m.asr_supported) && <p className="ac-note">{t('该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。')}</p>}
{role === 'transcription' && (list?.preview || models.some(m => m.asr_preview)) && !model && !(warning || listError) && <small className="ac-note">{t('模型目录可预览;实际可用性以账号权限和服务商开通状态为准。')}</small>}
</div>
}
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { Input, Select } from 'antd'
import { t } from '../../i18n'
import { Btn, Row } from '../../ui'
import { openExternalLink } from '../../utils/externalLinks'
import { trackSponsorLinkOpened } from '../../analytics/events'
import { PROVIDERS, providerPickerOptions, type ProviderKey } from './providers'
import type { Connection } from './modelSettingsApi'
import { presetKey } from './modelSettingsLogic'
/**
* Provider picker + credentials for one connection. Local presets need no key; compatible/local expose the address.
* Without a connection (first run) only the picker renders, with nothing pre-selected.
*/
export default function ProviderFields({ connection, ariaPrefix = '', hideProvider, sharedKey, placement, onChoose, onEdit }: {
connection?: Connection
ariaPrefix?: string
hideProvider?: boolean
/** The key is reused from the main AI service; offer an explicit "edit" instead of a second field. */
sharedKey?: boolean
placement: 'settings_model' | 'home_setup'
onChoose: (provider: ProviderKey) => void
onEdit: (patch: Partial<Connection>) => void
}) {
const [editShared, setEditShared] = useState(false)
const key = connection ? presetKey(connection) : undefined
const preset = key ? PROVIDERS[key] : undefined
const label = (text: string) => ariaPrefix ? `${ariaPrefix} · ${text}` : text
const picker = !hideProvider && <Row wide label={t('提供商')} hint={preset?.hint || t('国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。')}>
<Select aria-label={label(t('提供商'))} value={key} placeholder={t('选择一家 AI 服务')} showSearch optionFilterProp="search" style={{ width: '100%' }} options={providerPickerOptions()}
onChange={onChoose}
optionRender={option => { const p = PROVIDERS[option.value as ProviderKey]; return <span>{p.name}{p.sponsor && <small style={{ marginLeft: 8, color: 'var(--sub)', fontWeight: 400 }}>{t('赞助')} · {t('$5 免费体验额度')}</small>}</span> }} />
</Row>
if (!connection) return <>{picker}</>
return <>
{picker}
{preset?.sponsor && <div className="ac-sponsor">
<div className="ac-sponsor-text"><b>{preset.name}<span className="ac-badge">{t('赞助')}</span></b>
<span>{t('通过专属链接注册,可领取 $5 免费体验额度。')}</span>
<small>{t('该链接含推广分成,用于支持项目维护;领取条件以活动页面为准。')}</small></div>
<div className="ac-sponsor-actions">
<Btn variant="cta" size="sm" onClick={() => { trackSponsorLinkOpened({ sponsor: 'infistar', target: 'register', placement }); openExternalLink(preset.sponsor!.registerUrl) }}>{t('注册并领取体验额度')}</Btn>
<Btn variant="text" size="sm" onClick={() => { trackSponsorLinkOpened({ sponsor: 'infistar', target: 'guide', placement }); openExternalLink(preset.sponsor!.guideUrl) }}>{t('接入说明')}</Btn>
</div>
</div>}
{!preset?.local && (sharedKey && !editShared
? <Row wide label={t('服务密钥(API Key)')} hint={t('共用 API Key,无需重复填写。')}>
<span className="ac-hint">{t('已复用 AI 服务的密钥')}</span>
<Btn variant="text" size="sm" onClick={() => setEditShared(true)}>{t('修改密钥')}</Btn>
</Row>
: <Row wide label={t('服务密钥(API Key)')} hint={preset?.keyUrl
? <>{t('在供应商网站创建密钥,复制后粘贴到右侧。')} <a className="ac-link" href={preset.keyUrl} onClick={e => { e.preventDefault(); openExternalLink(preset.keyUrl) }}>{t('获取 API Key')} ↗</a></>
: t('sk-…(自建服务可留空)')}>
<Input.Password aria-label={label('API Key')} autoComplete="new-password" value={connection.api_key ?? ''}
placeholder={connection.has_key ? t('已配置;留空保留已有密钥') : preset?.placeholder || 'sk-…'}
onChange={e => onEdit({ api_key: e.target.value || (connection.has_key ? undefined : '') })} />
</Row>)}
{(key === 'compatible' || preset?.local) && <Row wide label={t('接口地址')} hint={preset?.local ? t('本机服务无需 API Key,保持默认地址即可。') : undefined}>
<Input aria-label={label(t('接口地址'))} value={connection.base_url} placeholder={preset?.local?.baseUrl || 'https://example.com/v1'} onChange={e => onEdit({ base_url: e.target.value })} />
</Row>}
</>
}
@@ -0,0 +1,57 @@
import type { Connection, ModelEntry, ModelSettings } from './modelSettingsApi'
// Preference order only: select a recommendation from the provider's actual list.
// Never substitute an existing explicit choice when a catalog refreshes.
const PREFERRED: Record<string, string[]> = {
dashscope: ['qwen3.8-flash', 'qwen3.8-max', 'qwen-vl-plus', 'qwen-plus'],
openai: ['gpt-5-mini', 'gpt-4o-mini'],
gemini: ['gemini-3.8-flash', 'gemini-2.5-flash'],
seed: ['doubao-seed-2-1-lite-260915'],
deepseek: ['deepseek-flash'],
kimi: ['kimi-k2.6', 'kimi-k3'],
glm: ['glm-5.3'], grok: ['grok-4.6'],
}
const IMAGE_PREFERRED: Record<string, string[]> = {
dashscope: ['qwen-image-2.0', 'wan2.7-image', 'wanx2.1-t2i-turbo', 'wanx2.1-t2i-plus'],
openai: ['gpt-image-2.5-flare', 'gpt-image-2', 'gpt-image-1', 'dall-e-3'],
seed: ['doubao-seedream-5-0-flash-260915', 'doubao-seedream-5-0-pro-260628', 'doubao-seedream-4-0-20260415', 'doubao-seedream-5-0-260128'],
}
export function defaultModel(provider: string, models: ModelEntry[], image = false): string {
const eligible = models.filter(m => image ? m.image : m.analysis)
if (image && provider === 'infistar') return eligible.find(m => m.id === 'qwen-image-plus')?.id || eligible[0]?.id || ''
if (image) return IMAGE_PREFERRED[provider]
? IMAGE_PREFERRED[provider].find(id => eligible.some(m => m.id === id)) || ''
: eligible[0]?.id || ''
return PREFERRED[provider]?.find(id => eligible.some(m => m.id === id))
|| eligible.find(m => m.capability === 'multimodal')?.id || eligible[0]?.id || ''
}
export function analysisModels(models: ModelEntry[], mode: ModelSettings['analysis_mode']): ModelEntry[] {
// 'auto' is smart selection: a text-only model simply analyses subtitles, so it stays eligible.
// Only an explicit 'visual' route requires a multimodal model.
return models.filter(m => m.analysis && (mode !== 'visual' || m.capability === 'multimodal'))
}
export function applyModelDefaults(settings: ModelSettings, connection: Connection, models: ModelEntry[], autoCover: boolean): ModelSettings {
const value = { ...settings }
for (const role of ['analysis', 'vision', 'cover'] as const) {
const binding = value[role]
if (binding?.connection_id !== connection.id || binding.model) continue
const eligible = role === 'cover' ? models : analysisModels(models, role === 'vision' ? 'visual' : settings.analysis_mode)
const model = defaultModel(connection.provider, eligible, role === 'cover')
if (model) value[role] = { ...binding, model }
}
// First run, or AI covers switched on before a model list arrived: fill the recommended image model.
if ((autoCover || value.cover_enabled) && !value.cover && value.analysis?.connection_id === connection.id) {
const model = defaultModel(connection.provider, models, true)
if (model) {
value.cover = { connection_id: connection.id, model, capability: 'auto' }
value.cover_enabled = true
}
}
if (value.transcription?.provider === 'cloud' && value.transcription.connection_id === connection.id && !value.transcription.model) {
const model = models.find(m => m.asr && m.asr_supported)?.id
if (model) value.transcription = { ...value.transcription, model }
}
return value
}
@@ -0,0 +1,44 @@
import api from '../../services/api'
export type Capability = 'auto' | 'multimodal' | 'text'
export interface Connection {
id: string
name: string
provider: string
base_url: string
api_key?: string | null
has_key?: boolean
api_key_masked?: string
image_api: 'auto' | 'openai' | 'seedream' | 'dashscope'
image_base_url: string
}
export interface Assignment { connection_id: string; model: string; capability: Capability }
export interface ModelSettings {
version: 1
saved?: boolean
connections: Connection[]
analysis: Assignment | null
vision: Assignment | null
cover: Assignment | null
transcription?: { provider: 'whisper_local' | 'cloud'; model: string; connection_id?: string; capability?: Capability } | null
cover_enabled: boolean
allow_send_frame: boolean
analysis_mode: 'auto' | 'subtitle' | 'visual'
allow_visual_screening: boolean
chunk_size: number
min_score_threshold: number
max_clips_per_collection: number
}
export interface ModelEntry { id: string; capability: 'multimodal' | 'text' | null; capability_source?: string; analysis: boolean; image: boolean; asr?: boolean; asr_supported?: boolean; asr_preview?: boolean; asr_note?: string | null }
export interface ModelList { models: ModelEntry[]; source: 'live' | 'cache' | 'catalog'; preview?: boolean; updated_at: number | null; warning?: string }
export const modelSettingsApi = {
get: () => api.get<unknown, ModelSettings>('/settings/ai-models'),
save: (value: ModelSettings) => api.put<unknown, ModelSettings>('/settings/ai-models', value),
discover: (connection: Connection, refresh = false) => api.post<unknown, ModelList>('/settings/ai-models/discover', { connection, refresh }),
test: (connection: Connection, model: string, vision = false) => api.post<unknown, { success?: boolean; ok?: boolean }>('/settings/ai-models/test', { connection, model, vision }),
}
export function bindingCapability(binding: Assignment | null, lists: Record<string, ModelList>): Capability {
if (!binding) return 'auto'
return binding.capability !== 'auto' ? binding.capability : lists[binding.connection_id]?.models.find(m => m.id === binding.model)?.capability || 'auto'
}
@@ -0,0 +1,137 @@
/**
* Pure state transitions for the AI model settings document.
* No React, no i18n: the hook and the tests both call these.
*/
import { PROVIDERS, type ProviderKey } from './providers'
import type { Assignment, Connection, ModelList, ModelSettings } from './modelSettingsApi'
import { applyModelDefaults, defaultModel } from './modelDefaults'
export type Role = 'analysis' | 'vision' | 'cover' | 'transcription'
export type SaveIssue = { role: 'analysis' | 'cover' | 'transcription'; reason: 'provider' | 'model' | 'key' }
const OPENAI_URL = /^https:\/\/api\.openai\.com(?:\/v1)?\/?$/
/** Which picker entry a saved connection belongs to (an OpenAI connection with a foreign address is "compatible"). */
export const presetKey = (c: Connection): ProviderKey =>
c.provider === 'openai' && c.base_url && !OPENAI_URL.test(c.base_url) ? 'compatible' : c.provider as ProviderKey
/** Enough information to list models: a key, a custom address, or a keyless local preset. */
export const canDiscover = (c: Connection) => !!(c.api_key || c.has_key || c.base_url || PROVIDERS[c.provider as ProviderKey]?.local)
/** The connection can actually serve requests: credentials present, or a service that needs none. */
export const connectionReady = (c: Connection) =>
!!(c.api_key || c.has_key) || !!PROVIDERS[c.provider as ProviderKey]?.local || (presetKey(c) === 'compatible' && !!c.base_url)
export const cleanBinding = (connection_id: string): Assignment => ({ connection_id, model: '', capability: 'auto' })
export const mainConnection = (s: ModelSettings) => s.connections.find(c => c.id === s.analysis?.connection_id)
export const connectionOf = (s: ModelSettings, role: Role) => s.connections.find(c => c.id === s[role]?.connection_id)
/** First run: nothing saved yet and the main connection cannot serve requests. */
export const needsSetup = (s: ModelSettings) => {
const main = mainConnection(s)
return !s.saved && (!main || !connectionReady(main))
}
/**
* Loaded document → editable state. On first run nothing is pre-selected: the user picks a provider,
* the recommendation follows the key, and AI covers (frame reference on) are tried by default.
*/
export function prepareLoaded(value: ModelSettings): ModelSettings {
if (!needsSetup(value)) return value
return { ...value, analysis: null, cover: null, vision: null, cover_enabled: true, allow_send_frame: true }
}
/** AI covers stay on by default, but a service without any image model falls back to frames instead of blocking save. */
export function coverFallback(s: ModelSettings, lists: Record<string, ModelList>): ModelSettings {
if (!s.cover_enabled || s.cover?.model) return s
const connection = connectionOf(s, 'cover') || mainConnection(s)
const list = connection && lists[connection.id]
if (list && !list.models.some(m => m.image)) return { ...s, cover_enabled: false, cover: null }
return s
}
export const newConnection = (provider: ProviderKey, id: string = crypto.randomUUID()): Connection =>
({ id, name: PROVIDERS[provider].name, provider, base_url: '', api_key: '', image_api: 'auto', image_base_url: '' })
/** Merge a field edit; changing the address invalidates a stored key so one service's key never reaches another. */
export function editConnection(s: ModelSettings, id: string, patch: Partial<Connection>): ModelSettings {
const changedAddress = patch.base_url !== undefined || patch.image_base_url !== undefined
return { ...s, connections: s.connections.map(c => c.id === id ? { ...c, ...patch, ...(changedAddress && c.has_key ? { api_key: '', has_key: false } : {}) } : c) }
}
/**
* Point a role at a provider. Saved credentials for that provider are reused, but the analysis
* connection is never shared with cover/vision so a provider switch cannot move a key across roles.
*/
export function chooseProvider(
s: ModelSettings, role: Role, provider: ProviderKey, lists: Record<string, ModelList>, autoCover: boolean,
create: (provider: ProviderKey) => Connection = newConnection,
): { settings: ModelSettings; connection: Connection; changed: boolean } {
const oldMainId = s.analysis?.connection_id
const existing = s.connections.find(c => presetKey(c) === provider && (role === 'analysis' || role === 'transcription' || c.id !== oldMainId))
const connection = existing || create(provider)
if (s[role]?.connection_id === connection.id) return { settings: s, connection, changed: false }
let next: ModelSettings = {
...s,
connections: existing ? s.connections : [...s.connections, connection],
[role]: role === 'transcription' ? { provider: 'cloud', model: '', connection_id: connection.id } : cleanBinding(connection.id),
}
if (role === 'analysis') {
if (s.cover?.connection_id === oldMainId) next.cover = s.cover_enabled ? cleanBinding(connection.id) : null
if (s.vision?.connection_id === oldMainId) next.vision = null
}
const cached = lists[connection.id]
if (cached && !cached.preview) next = applyModelDefaults(next, connection, cached.models, autoCover)
return { settings: next, connection, changed: true }
}
/** Cover either follows the analysis connection or gets its own (same provider, separate key). */
export function setCoverSeparate(
s: ModelSettings, separate: boolean, lists: Record<string, ModelList>,
create: (provider: ProviderKey) => Connection = newConnection,
): ModelSettings {
const main = mainConnection(s)
if (!main) return s
if (!separate) return { ...s, cover: { ...cleanBinding(main.id), model: defaultModel(main.provider, lists[main.id]?.models || [], true) } }
const connection = create(presetKey(main))
return { ...s, connections: [...s.connections, connection], cover: cleanBinding(connection.id) }
}
export function setCoverEnabled(s: ModelSettings, enabled: boolean, lists: Record<string, ModelList>): ModelSettings {
const main = mainConnection(s)
if (!enabled) return { ...s, cover_enabled: false }
return { ...s, cover_enabled: true, ...(!s.cover && main ? { cover: { ...cleanBinding(main.id), model: defaultModel(main.provider, lists[main.id]?.models || [], true) } } : {}) }
}
/** The "画面识别" switch: on = smart selection (multimodal models sample frames), off = subtitles only. */
export function setVisual(s: ModelSettings, enabled: boolean): ModelSettings {
return { ...s, analysis_mode: enabled ? 'auto' : 'subtitle', allow_visual_screening: enabled, vision: null }
}
export const isTextOnly = (s: ModelSettings, lists: Record<string, ModelList>) => {
const binding = s.analysis
if (!binding?.model) return false
if (binding.capability !== 'auto') return binding.capability === 'text'
return lists[binding.connection_id]?.models.find(m => m.id === binding.model)?.capability === 'text'
}
/** First blocking problem before save, in page order. */
export function saveIssue(s: ModelSettings): SaveIssue | null {
for (const role of ['analysis', 'transcription', 'cover'] as const) {
if (role === 'transcription' && s.transcription?.provider !== 'cloud') continue
if (role === 'cover' && !s.cover_enabled) continue
const binding = s[role]
const connection = s.connections.find(c => c.id === binding?.connection_id)
if (!connection) return { role, reason: 'provider' }
if (!binding?.model) return { role, reason: 'model' }
if (connection && !connectionReady(connection)) return { role, reason: 'key' }
}
return null
}
/** Document to send: drop blank compatible connections nothing points at; a disabled cover with no model is null. */
export function forSave(s: ModelSettings): ModelSettings {
const cover = !s.cover_enabled && !s.cover?.model ? null : s.cover
const active = new Set([s.analysis?.connection_id, s.vision?.connection_id, cover?.connection_id, s.transcription?.connection_id])
return { ...s, cover, connections: s.connections.filter(c => c.provider !== 'compatible' || c.base_url.trim() || active.has(c.id)) }
}
@@ -0,0 +1,43 @@
import { t } from '../../i18n'
export type ProviderKey = 'dashscope' | 'openai' | 'compatible' | 'infistar' | 'gemini' | 'deepseek' | 'seed' | 'kimi' | 'glm' | 'grok' | 'ollama' | 'lmstudio'
type LocalPreset = { baseUrl: string; defaultModel: string; docsUrl: string; app: string }
type CloudPreset = { baseUrl: string; defaultModel: string }
type Sponsor = { registerUrl: string; guideUrl: string }
// 提供商会越来越多:下拉按组展示、可搜索,新增一家只在 PROVIDERS 里加一条并标 group
type ProviderGroup = 'sponsor' | 'cloud' | 'compatible' | 'local'
const PROVIDER_GROUPS: Array<{ key: ProviderGroup; label: () => string }> = [
{ key: 'sponsor', label: () => t('推荐') },
{ key: 'cloud', label: () => t("官方供应商") },
{ key: 'compatible', label: () => t("兼容接口") },
{ key: 'local', label: () => t("本机运行") },
]
export const PROVIDERS: Record<ProviderKey, { name: string; short: string; hint: string; apiKeyField: string; placeholder: string; keyUrl: string; group: ProviderGroup; local?: LocalPreset; cloud?: CloudPreset; sponsor?: Sponsor }> = {
dashscope: { get name() { return t("阿里云百炼") }, get short() { return t("阿里云百炼") }, get hint() { return t("阿里云百炼官方服务,接口地址已预设。") }, group: 'cloud', apiKeyField: 'dashscope_api_key', placeholder: 'sk-…', keyUrl: 'https://bailian.console.aliyun.com/' },
openai: { name: 'OpenAI', short: 'OpenAI', get hint() { return t('OpenAI 官方服务,接口地址已预设。') }, group: 'cloud', apiKeyField: 'openai_api_key', placeholder: 'sk-…', keyUrl: 'https://platform.openai.com/api-keys' },
compatible: { get name() { return t('自定义 OpenAI 兼容接口') }, get short() { return t('自定义兼容接口') }, get hint() { return t('适用于 OpenRouter、第三方网关或自建服务。') }, group: 'compatible', apiKeyField: 'openai_api_key', get placeholder() { return t('sk-…(自建服务可留空)') }, keyUrl: '' },
// 赞助合作伙伴(docs/INFISTAR_SETUP.md)。多模型网关,型号随账号而定:不预设默认模型,填好 key 后实时拉取
infistar: { get name() { return t("Infistar 无限星河") }, short: 'Infistar', get hint() { return t("赞助合作伙伴。一个 Key 调用 Claude、GPT、Gemini、DeepSeek 等模型,接口地址已预设。") }, group: 'sponsor', apiKeyField: 'infistar_api_key', placeholder: 'sk-…', keyUrl: 'https://www.infistar.cc/register?aff=XLK3BCM6&ref_source=link', cloud: { baseUrl: 'https://infistar.cc/v1', defaultModel: '' }, sponsor: { registerUrl: 'https://www.infistar.cc/register?aff=XLK3BCM6&ref_source=link', guideUrl: 'https://github.com/zhouxiaoka/autoclip/blob/main/docs/INFISTAR_SETUP.md' } },
gemini: { name: 'Google Gemini', short: 'Gemini', get hint() { return t("Google AI Studio 的 Gemini 系列。") }, group: 'cloud', apiKeyField: 'gemini_api_key', placeholder: 'AIza…', keyUrl: 'https://aistudio.google.com/apikey' },
deepseek: { name: 'DeepSeek', short: 'DeepSeek', get hint() { return t("DeepSeek 官方。国内直连,deepseek-flash 是当前 V4.1。") }, group: 'cloud', apiKeyField: 'deepseek_api_key', placeholder: 'sk-…', keyUrl: 'https://platform.deepseek.com/api_keys', cloud: { baseUrl: 'https://api.deepseek.com', defaultModel: 'deepseek-flash' } },
seed: { name: 'Seed', short: 'Seed', get hint() { return t("火山方舟 Seed。国内直连,豆包 Seed 2.1 系列。") }, group: 'cloud', apiKeyField: 'seed_api_key', placeholder: '…', keyUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey', cloud: { baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: 'doubao-seed-2-1-lite-260915' } },
kimi: { name: 'Kimi', short: 'Kimi', get hint() { return t("月之暗面 Kimi。国内直连,适合长字幕分析。") }, group: 'cloud', apiKeyField: 'kimi_api_key', placeholder: 'sk-…', keyUrl: 'https://platform.moonshot.cn/console/api-keys', cloud: { baseUrl: 'https://api.moonshot.cn/v1', defaultModel: 'kimi-k2.6' } },
glm: { get name() { return t("智谱 GLM") }, get short() { return 'GLM' }, get hint() { return t("智谱开放平台。国内直连,glm-5.3 是当前旗舰。") }, group: 'cloud', apiKeyField: 'glm_api_key', placeholder: '…', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys', cloud: { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-5.3' } },
grok: { name: 'Grok', short: 'Grok', get hint() { return t("xAI Grok。需要 xAI 账号。") }, group: 'cloud', apiKeyField: 'grok_api_key', placeholder: 'xai-…', keyUrl: 'https://console.x.ai', cloud: { baseUrl: 'https://api.x.ai/v1', defaultModel: 'grok-4.6' } },
// 本地预设:底层是 openai 兼容 + base_url,后端 core/local_presets.py 负责还原;无需密钥、不花钱、离线可用
ollama: { name: 'Ollama', short: 'Ollama', get hint() { return t("本机运行的 Ollama,免费、离线。推荐 ollama pull qwen2.5:7b。") }, group: 'local', apiKeyField: 'openai_api_key', placeholder: '', keyUrl: 'https://ollama.com/download', local: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'qwen2.5:7b', docsUrl: 'https://ollama.com/download', app: 'Ollama' } },
lmstudio: { name: 'LM Studio', short: 'LM Studio', get hint() { return t("本机 LM Studio 的 Local Server,免费、离线。在 LM Studio 里加载模型并启动服务。") }, group: 'local', apiKeyField: 'openai_api_key', placeholder: '', keyUrl: 'https://lmstudio.ai', local: { baseUrl: 'http://localhost:1234/v1', defaultModel: '', docsUrl: 'https://lmstudio.ai', app: 'LM Studio' } },
}
export const providerPickerOptions = () => PROVIDER_GROUPS
.map((group) => ({
label: group.label(),
options: (Object.keys(PROVIDERS) as ProviderKey[])
.filter((key) => PROVIDERS[key].group === group.key)
.map((key) => ({
value: key,
label: PROVIDERS[key].short,
search: `${key} ${PROVIDERS[key].short} ${PROVIDERS[key].name}`.toLowerCase(),
title: PROVIDERS[key].name,
})),
}))
.filter((group) => group.options.length)
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from 'react'
import { message } from 'antd'
import { t } from '../../i18n'
import { errorText } from '../studio/api'
import { trackApiKeyConfigured } from '../../analytics/events'
import { PROVIDERS, type ProviderKey } from './providers'
import { bindingCapability, modelSettingsApi, type Connection, type ModelList, type ModelSettings } from './modelSettingsApi'
import { applyModelDefaults } from './modelDefaults'
import * as logic from './modelSettingsLogic'
import type { Role, SaveIssue } from './modelSettingsLogic'
export const roleLabel = (role: Role) =>
({ analysis: t('模型'), vision: t('画面理解模型'), cover: t('生图模型'), transcription: t('转写模型') })[role]
export const issueText = (issue: SaveIssue) =>
issue.reason === 'provider' ? t('请先选择一家 AI 服务')
: `${roleLabel(issue.role)}:${issue.reason === 'model' ? t('请先选择模型') : t('请填写 API Key')}`
export type ModelSettingsStore = ReturnType<typeof useModelSettings>
/**
* One editable copy of the AI model document, shared by the settings page and the first-run card.
* Editing a key or address triggers one debounced discovery; recommendations fill empty slots only.
*/
export function useModelSettings() {
const [settings, setSettings] = useState<ModelSettings | null>(null)
const [lists, setLists] = useState<Record<string, ModelList>>({})
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [listErrors, setListErrors] = useState<Record<string, string>>({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [error, setError] = useState('')
const versions = useRef<Record<string, number>>({})
const alive = useRef(true)
// First run only: the recommended image model turns AI covers on. Any explicit cover choice ends it.
const autoCover = useRef(false)
const update = (patch: Partial<ModelSettings>) => {
setSettings(old => old ? { ...old, ...patch } : old)
setDirty(true)
}
const replace = (next: ModelSettings) => { setSettings(next); setDirty(true); setError('') }
const discover = async (connection: Connection, refresh = false) => {
if (connection.provider === 'compatible' && !connection.base_url) return
const version = (versions.current[connection.id] || 0) + 1
versions.current[connection.id] = version
setBusy(old => ({ ...old, [connection.id]: true }))
try {
const result = await modelSettingsApi.discover(connection, refresh)
if (!alive.current || versions.current[connection.id] !== version) return
setLists(old => ({ ...old, [connection.id]: result }))
setListErrors(old => { const next = { ...old }; delete next[connection.id]; return next })
setSettings(old => old && !result.preview ? applyModelDefaults(old, connection, result.models, autoCover.current) : old)
} catch (e) {
if (alive.current && versions.current[connection.id] === version) setListErrors(old => ({ ...old, [connection.id]: errorText(e) }))
} finally {
if (alive.current && versions.current[connection.id] === version) setBusy(old => ({ ...old, [connection.id]: false }))
}
}
const load = async () => {
setError('')
try {
const value = logic.prepareLoaded(await modelSettingsApi.get())
if (!alive.current) return
autoCover.current = !value.saved
setSettings(value)
value.connections.forEach(c => { void discover(c) })
} catch (e) { if (alive.current) setError(errorText(e)) }
}
useEffect(() => { alive.current = true; void load(); return () => { alive.current = false } }, [])
const signature = JSON.stringify(settings?.connections.map(c => [c.id, c.provider, c.base_url, c.api_key, c.image_base_url]))
useEffect(() => {
if (!settings) return
const timer = window.setTimeout(() => settings.connections.forEach(c => { void discover(c) }), 650)
return () => window.clearTimeout(timer)
}, [signature])
useEffect(() => {
if (!dirty) return
const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' }
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [dirty])
const editConnection = (connection: Connection, patch: Partial<Connection>) => {
if (!settings) return
versions.current[connection.id] = (versions.current[connection.id] || 0) + 1
update(logic.editConnection(settings, connection.id, patch))
setLists(old => { const next = { ...old }; delete next[connection.id]; return next })
}
const chooseProvider = (role: Role, provider: ProviderKey) => {
if (!settings) return
const result = logic.chooseProvider(settings, role, provider, lists, autoCover.current)
if (!result.changed) return
replace(result.settings)
void discover(result.connection)
}
const setTranscriptionLocal = (model: string) => update({ transcription: { provider: 'whisper_local', model } })
const setCoverSeparate = (separate: boolean) => { if (settings) replace(logic.setCoverSeparate(settings, separate, lists)) }
const setCoverEnabled = (enabled: boolean) => { autoCover.current = false; if (settings) replace(logic.setCoverEnabled(settings, enabled, lists)) }
const setCoverModel = (connectionId: string, model: string) => { autoCover.current = false; update({ cover_enabled: true, cover: { ...logic.cleanBinding(connectionId), model } }) }
const setVisual = (enabled: boolean) => { if (settings) replace(logic.setVisual(settings, enabled)) }
/** Validate, persist, and report the first blocking problem so the caller can scroll to it. */
const save = async (): Promise<{ ok: boolean; issue?: SaveIssue }> => {
if (!settings) return { ok: false }
const effective = logic.coverFallback(settings, lists)
const issue = logic.saveIssue(effective)
if (issue) { const text = issueText(issue); setError(text); message.error(text); return { ok: false, issue } }
setSaving(true); setError('')
try {
const value = await modelSettingsApi.save(logic.forSave(effective))
setSettings(value); setDirty(false); autoCover.current = false
value.connections.forEach(c => trackApiKeyConfigured({ provider: c.provider, hasKey: !!c.has_key || !!PROVIDERS[c.provider as ProviderKey]?.local }))
message.success(t('已保存'))
return { ok: true }
} catch (e) { setError(errorText(e)); return { ok: false } }
finally { setSaving(false) }
}
const test = async () => {
const main = settings && logic.mainConnection(settings)
if (!settings?.analysis?.model || !main) return
setTesting(true)
try {
const vision = settings.analysis_mode !== 'subtitle' && bindingCapability(settings.analysis, lists) === 'multimodal'
const value = await modelSettingsApi.test(main, settings.analysis.model, vision)
if (value.success || value.ok) message.success(t('连接正常'))
else message.error(t('连接测试失败,请检查接口、密钥和模型'))
} catch (e) { message.error(errorText(e)) }
finally { setTesting(false) }
}
const main = settings ? logic.mainConnection(settings) : undefined
return {
settings, lists, busy, listErrors, dirty, saving, testing, error, setError, main,
load, discover, update, editConnection, chooseProvider, setTranscriptionLocal,
setCoverSeparate, setCoverEnabled, setCoverModel, setVisual, save, test,
}
}
@@ -8,7 +8,7 @@ import { defaultImportOptions, goalLabels, ImportOptions } from './types'
import ImportPreferences from './ImportPreferences'
import './studio.css'
export default function CreativeImport({ onImported }: { onImported: () => Promise<void> }) {
export default function CreativeImport({ onImported, blocked = false, onBlocked }: { onImported: () => Promise<void>; blocked?: boolean; onBlocked?: () => void }) {
useTranslation()
const navigate = useNavigate()
const [source, setSource] = useState<'link' | 'file'>('link')
@@ -22,6 +22,7 @@ export default function CreativeImport({ onImported }: { onImported: () => Promi
const [error, setError] = useState('')
const custom = options.goal !== 'auto' || options.language !== 'source' || options.aspect !== null || options.duration !== null || !!subtitle || !!browser
const submit = async () => {
if (blocked) { setError(t("请先连接 AI 服务,再导入视频。")); onBlocked?.(); return }
if (source === 'file' ? !file : !url.trim()) { setError(t("请先添加视频文件或链接")); return }
setBusy(true); setError('')
try {
@@ -0,0 +1,145 @@
import { Select, Switch } from 'antd'
import { t } from '../../i18n'
import { Btn, ProgressLine, Row, Segmented, StatusDot } from '../../ui'
import { Draft, FramingStatus, Scene, cropAt, languages, subtitleStyles } from './types'
import { titlePresets, titleVersions, isArtworkStyle, titleDesignThumbnails } from './titlePresets'
const ACCENT_DEFAULT: Record<string, string> = { comic: '#ffe52d', neon: '#ccff00', editorial: '#ff4826', pixel: '#ed327c', frosted: '#00e6dc' }
export interface FramingState {
status?: FramingStatus
/** Auto framing is running against the current scenes. */
busy: boolean
/** Set after a run: how many scenes got a speaker position. */
result?: { framed: number; total: number; switches: number }
error?: string
}
/**
* Right-hand settings of the Studio editor as one column of setting rows (see DESIGN.md → Row).
* Top to bottom: name → subtitles (whole clip) → audio → frame + speaker framing → opening title
* (optional, folded) → text language → cover. Everything has a working default.
*/
export default function DraftSettingsPanel({ draft, patch, scene, currentTime, onSceneCrop, framing, onAutoFrame, onInstallFraming, onPortrait, coverHref, onOpenCover }: {
draft: Draft
patch: (changes: Partial<Draft>) => void
/** Scene currently shown in the preview; the framing slider edits this one. */
scene?: Scene
currentTime: number
onSceneCrop: (sceneId: string, cropX: number) => void
framing: FramingState
onAutoFrame: () => void
onInstallFraming: () => void
onPortrait: () => void
coverHref?: string
onOpenCover: (href: string) => void
}) {
const artwork = isArtworkStyle(draft.title_style)
const style = draft.title_style || 'plain'
const cropping = draft.aspect !== 'original' && draft.layout === 'crop'
const sceneCrop = cropAt(scene, currentTime, draft.crop_x ?? .5)
const tracked = !!scene?.crop_track?.length
const runtime = framing.status?.status
return <aside className="studio-edit-panel">
<div className="studio-panel-head">
<h2>{t("成片设置")}</h2>
<Btn size="sm" variant="text" onClick={onPortrait}>{t("一键竖屏")}</Btn>
</div>
<div className="ac-rows">
<Row stack label={t("成片名称")}>
<input className="ac-input" aria-label={t("成片名称")} maxLength={200} value={draft.title} onChange={e => patch({ title: e.target.value })} />
</Row>
<Row label={t("字幕")} hint={t("把字幕压进画面,整条成片统一样式。")}>
<Switch size="small" checked={draft.subtitles} onChange={value => patch({ subtitles: value })} />
</Row>
{draft.subtitles && <Row stack label={t("字幕样式")}>
<div className="studio-tiles" role="radiogroup" aria-label={t("字幕样式")}>
{subtitleStyles.map(preset => <button type="button" key={preset.value} className="studio-tile" role="radio" aria-checked={(draft.subtitle_style || 'clean') === preset.value} onClick={() => patch({ subtitle_style: preset.value })}>
<span className="studio-tile-sample"><span className={`studio-caption studio-caption--${preset.value}`}>{t("这里是字幕效果")}</span></span>
<span className="studio-tile-label">{t(preset.label)}</span>
</button>)}
</div>
</Row>}
<Row label={t("原声")} hint={t("关闭后成片静音。")}>
<Switch size="small" checked={draft.original_audio} onChange={value => patch({ original_audio: value })} />
</Row>
<Row label={t("画幅")}>
<Segmented size="sm" ariaLabel={t("画幅")} value={draft.aspect}
options={[{ value: 'original', label: t("原画幅") }, { value: 'portrait', label: '9:16' }, { value: 'landscape', label: '16:9' }]}
onChange={value => patch({ aspect: value, ...(value === 'portrait' ? { layout: 'crop' as const } : {}) })} />
</Row>
{draft.aspect !== 'original' && <Row label={t("构图")} hint={cropping ? t("主体铺满画面,自动对准说话的人。") : t("保留完整画面,两侧留边或模糊背景。")}>
<Segmented size="sm" ariaLabel={t("构图")} value={draft.layout}
options={[{ value: 'crop', label: t("满屏") }, { value: 'blur', label: t("模糊背景") }, { value: 'fit', label: t("留边") }]}
onChange={value => patch({ layout: value })} />
</Row>}
{cropping && <Row stack label={t("取景")} hint={
runtime === 'not_installed' ? t("首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。", { size: framing.status?.size_mb ?? 45 })
: runtime === 'installing' ? t("正在下载人物识别组件…")
: framing.busy ? t("正在识别人物位置…")
: framing.error ? framing.error
: framing.result ? (framing.result.framed ? t("已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。", framing.result) : t("没有识别到人物,请手动调整取景位置。"))
: tracked ? t("正在跟随说话人取景;拖动滑块会改为固定取景。")
: t("拖动调整当前镜头的取景位置。")}>
{runtime === 'not_installed' && <Btn size="sm" onClick={onInstallFraming}>{t("下载并自动取景")}</Btn>}
{runtime === 'installing' && <ProgressLine percent={framing.status?.progress ?? 5} />}
{runtime === 'error' && <StatusDot tone="error" label={framing.status?.message} />}
{runtime === 'installed' && !framing.busy && <Btn size="sm" onClick={onAutoFrame}>{framing.result ? t("重新自动取景") : t("自动取景")}</Btn>}
<input aria-label={t("取景位置")} type="range" min="0" max="1" step=".01" value={sceneCrop} disabled={framing.busy}
onChange={e => scene ? onSceneCrop(scene.id, Number(e.target.value)) : patch({ crop_x: Number(e.target.value) })} />
</Row>}
<details className="ac-disclosure" open={!!draft.hook.trim() || undefined}>
<summary>{t("片头文字(可选)")}</summary>
<Row stack label={t("片头文字")} hint={t("在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。")}>
<textarea className="ac-input ac-textarea" style={{ minHeight: 56 }} aria-label={t("片头标题文字")} maxLength={120} value={draft.hook} placeholder={t("例如:一个问题,或一句结论")} onChange={e => patch({ hook: e.target.value })} />
</Row>
{!!draft.hook.trim() && <>
<Row stack label={t("片头样式")} hint={t("缩略图为设计参考,实际文字效果见左侧预览。")}>
<div className="studio-tiles studio-tiles--3" role="radiogroup" aria-label={t("片头样式")}>
{titlePresets.map(preset => <button type="button" key={preset.value} className="studio-tile" role="radio" aria-checked={style === preset.value}
onClick={() => patch({ title_style: preset.value, title_template_version: isArtworkStyle(preset.value) ? 6 : 1, title_accent: null })}>
{titleDesignThumbnails[preset.value]
? <img className="studio-tile-image" src={titleDesignThumbnails[preset.value]} alt="" width={360} height={240} />
: <span className="studio-tile-sample studio-tile-sample--center"><span className={`studio-hook studio-hook--${preset.value}`}>{t("片头")}</span></span>}
<span className="studio-tile-label">{t(preset.label)}</span>
</button>)}
</div>
</Row>
{artwork && <details className="ac-disclosure">
<summary>{t("调整文字样式")}</summary>
<Row label={t("样式版本")}>
<Select aria-label={t("样式版本")} size="small" style={{ width: 160 }} value={draft.title_template_version ?? 1}
options={titleVersions(draft.title_style).map(v => ({ value: v.value, label: t(v.label) }))}
onChange={value => patch({ title_template_version: value as Draft['title_template_version'] })} />
</Row>
<Row label={t("强调色")}>
<input type="color" aria-label={t("标题强调色")} value={draft.title_accent || ACCENT_DEFAULT[style] || '#dfff00'} onChange={e => patch({ title_accent: e.target.value })} />
</Row>
<Row stack label={t("文字大小")}>
<input type="range" aria-label={t("文字大小")} min=".75" max="1.2" step=".05" value={draft.title_scale ?? 1} onChange={e => patch({ title_scale: Number(e.target.value) })} />
</Row>
<Row stack label={t("文字位置")}>
<input type="range" aria-label={t("文字位置")} min=".06" max=".70" step=".01" value={draft.title_y ?? .12} onChange={e => patch({ title_y: Number(e.target.value) })} />
</Row>
{!['pixel', 'frosted'].includes(style) && <Row label={t("入场动效")} hint={t("翻译与动效以渲染结果为准;支持手动换行。")}>
<Switch size="small" checked={draft.title_motion ?? true} onChange={value => patch({ title_motion: value })} />
</Row>}
</details>}
</>}
</details>
<Row label={t("文字语言")} hint={t("选择翻译语言后,渲染时会翻译片头文字与字幕。")}>
<Select aria-label={t("文字语言")} size="small" style={{ width: 140 }} value={draft.language}
options={languages.map(l => ({ value: l.value, label: l.value === 'source' ? t('原语言') : l.label }))}
onChange={value => patch({ language: value as Draft['language'] })} />
</Row>
<Row label={t("封面")} hint={coverHref ? t("发布时按平台生成带标题的封面,也可以用视频截帧。") : t("导出成片后,在发布页生成带标题的封面。")}>
{coverHref && <Btn size="sm" variant="text" onClick={() => onOpenCover(coverHref)}>{t("去生成")}</Btn>}
</Row>
</div>
</aside>
}
@@ -1,14 +1,25 @@
import { useTranslation } from 'react-i18next'
import { Select } from 'antd'
import { t } from '../../i18n'
import { Row } from '../../ui'
import { ImportOptions, goalLabels, languages } from './types'
/** Optional production preferences as setting rows; every field defaults to "let AI match". */
export default function ImportPreferences({value, onChange, hideGoal=false}: {value: ImportOptions; onChange: (value: ImportOptions) => void; hideGoal?:boolean}) {
useTranslation()
const patch = (changes: Partial<ImportOptions>) => onChange({...value, ...changes})
return <div className="studio-fields">
{!hideGoal&&<label className="studio-field">{t("制作方式")}<select value={value.goal} onChange={e=>patch({goal:e.target.value as ImportOptions['goal']})}>{Object.entries(goalLabels).map(([key,label])=><option value={key} key={key}>{t(label)}</option>)}</select></label>}
<label className="studio-field">{t("文字语言")}<select value={value.language} onChange={e=>patch({language:e.target.value as ImportOptions['language']})}>{languages.map(l=><option value={l.value} key={l.value}>{l.value==='source'?t("沿用素材语言(推荐)"):l.label}</option>)}</select></label>
<label className="studio-field">{t("每条期望时长")}<select value={value.duration ?? ''} onChange={e=>patch({duration:e.target.value?Number(e.target.value):null})}><option value="">{t("AI 根据内容匹配")}</option>{[15,30,60,90,120].map(n=><option value={n} key={n}>{t('约 {{seconds}} 秒', { seconds: n })}</option>)}</select>{value.goal==='content'&&<small>{t("内容切片按完整语义选段,具体起止可在编辑器调整。")}</small>}</label>
<label className="studio-field">{t("画幅")}<select value={value.aspect ?? ''} onChange={e=>patch({aspect:(e.target.value || null) as ImportOptions['aspect']})}><option value="">{t("AI 根据内容匹配")}</option><option value="original">{t("保持原画幅")}</option><option value="portrait">{t("9:16 竖屏")}</option><option value="landscape">{t("16:9 横屏")}</option></select></label>
return <div className="ac-rows studio-preferences">
{!hideGoal&&<Row label={t("制作方式")}>
<Select aria-label={t("制作方式")} size="small" style={{width:180}} value={value.goal} options={Object.entries(goalLabels).map(([key,label])=>({value:key,label:t(label)}))} onChange={goal=>patch({goal:goal as ImportOptions['goal']})} />
</Row>}
<Row label={t("文字语言")}>
<Select aria-label={t("文字语言")} size="small" style={{width:180}} value={value.language} options={languages.map(l=>({value:l.value,label:l.value==='source'?t("沿用素材语言(推荐)"):l.label}))} onChange={language=>patch({language:language as ImportOptions['language']})} />
</Row>
<Row label={t("每条期望时长")} hint={value.goal==='content'?t("内容切片按完整语义选段,具体起止可在编辑器调整。"):undefined}>
<Select aria-label={t("每条期望时长")} size="small" style={{width:180}} value={value.duration ?? ''} options={[{value:'',label:t("AI 根据内容匹配")},...[15,30,60,90,120].map(n=>({value:n,label:t('约 {{seconds}} 秒', { seconds: n })}))]} onChange={duration=>patch({duration:duration===''?null:Number(duration)})} />
</Row>
<Row label={t("画幅")}>
<Select aria-label={t("画幅")} size="small" style={{width:180}} value={value.aspect ?? ''} options={[{value:'',label:t("AI 根据内容匹配")},{value:'original',label:t("保持原画幅")},{value:'portrait',label:t("9:16 竖屏")},{value:'landscape',label:t("16:9 横屏")}]} onChange={aspect=>patch({aspect:(aspect || null) as ImportOptions['aspect']})} />
</Row>
</div>
}
@@ -21,12 +21,12 @@ export default function ImportReview() {
const producing=running&&workspace.analysis?.phase==='production'
if(!id)return null
return <main className="ac-page studio-import-review">
<button className="ac-back" onClick={()=>navigate('/')}>{t("返回导入")}</button>
<h1 className="ac-title">{t("确认制作内容")}</h1>
<p className="studio-muted">{t("导入视频 → 识别与确认 → 开始制作")}</p>
<button className="ac-back" onClick={()=>navigate('/')}>{t("‹ 项目")}</button>
<h1 className="ac-title">{t("确认要做什么")}</h1>
<p className="studio-muted">{t("AI 先看一遍素材给出建议;你确认后才开始正式剪辑。")}</p>
{running?<div className="studio-import-box"><h2>{producing?t("制作已经开始"):t("正在快速识别素材")}</h2><p className="studio-muted">{producing?t("可进入项目查看进度。"):t("先判断适合制作的类型,完成后由你确认;此时不会开始正式剪辑。")}</p>{producing&&<Btn variant="cta" onClick={()=>navigate(`/project/${id}`)}>{t("查看制作进度")}</Btn>}</div>:<PlanSummary projectId={id} plan={workspace.plan} status={workspace.analysis?.status} onChanged={refresh} onStarted={()=>navigate(`/project/${id}`)}/>}
{(error||actionError||workspace.analysis?.status==='failed')&&<p role="alert" className="studio-error">{t(actionError||error||workspace.analysis?.error||'')}</p>}
{!running&&<div className="studio-actions"><Btn size="sm" loading={busy} onClick={retry}>{t("重新识别")}</Btn><Btn size="sm" onClick={()=>navigate('/settings')}>{t("模型设置")}</Btn></div>}
{!running&&(workspace.analysis?.status==='failed'||!workspace.plan)&&<div className="studio-actions"><Btn size="sm" loading={busy} onClick={retry}>{t("重新识别")}</Btn><Btn size="sm" onClick={()=>navigate('/settings?section=ai')}>{t("模型设置")}</Btn></div>}
{workspace.plan&&<details className="studio-details"><summary>{t("查看导入的素材")}</summary><video controls preload="metadata" className="studio-source-video" src={studioApi.source(id)}/></details>}
</main>
}
+10 -6
View File
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'
import { t } from '../../i18n'
import { useEffect, useState } from 'react'
import { Btn, Dialog, fmtDuration } from '../../ui'
import { Btn, Dialog, Row, Segmented, fmtDuration } from '../../ui'
import { AnalysisMode, Goal, ImportOptions, ImportPlan, defaultImportOptions, goalLabels, languages } from './types'
import ImportPreferences from './ImportPreferences'
import { studioApi, errorText } from './api'
@@ -37,12 +37,16 @@ export default function PlanSummary({projectId, plan, status, onChanged, onStart
<div className={`studio-plan-summary ${awaiting?'studio-plan-confirm':''}`}>
<div>{prefs?<><b>{awaiting?t("这段素材,可以这样做"):plan?.mode==='ai'?t("AI 建议"):plan?.mode==='manual'?t("你的方案"):t("当前方案")}</b><span className="studio-muted">{t(contentLabels[plan?.content_type || 'other'] ?? contentLabels.other)}{plan?.source_duration!=null?` · ${t('原素材 {{duration}}', { duration: fmtDuration(plan.source_duration) })}`:''} · {prefs.goal==='content'?t("按完整语义选段"):t('每条参考 {{seconds}} 秒', { seconds: prefs.duration })} · {aspectSummary} · {prefs.language==='source' ? t('原语言') : languages.find(l=>l.value===prefs.language)?.label}</span><details><summary>{t("查看判断依据")}{plan?.mode==='ai' && plan.confidence<.6?t("· 识别把握较低"):''}</summary><p className="studio-muted">{t(plan?.reason || '')}</p></details></>:<span className="studio-muted">{running?t("快速判断素材适合的制作类型"):t("可调整制作方案后重新识别")}</span>}</div>
{awaiting && plan ? <>
<label className="studio-field">{t("本次分析方式")}<select disabled={busy} value={analysisMode} onChange={e=>setAnalysisMode(e.target.value as AnalysisMode)}><option value="subtitle">{t("字幕分析 · 低成本")}</option><option value="visual">{t("视觉分析")}</option></select></label>
<p className="studio-muted">{t(analysisMode==='subtitle'?"仅分析字幕文本;无字幕时需要转写。":"发送抽样画面与文本,按模型服务商计费。")}{analysisMode==='visual'&&capability?.visual_model?` · ${capability.visual_model}`:''}</p>
{unavailable&&<p role="alert" className="studio-error">{t("视觉模型不可用,请前往模型设置。")}</p>}
{incompatible&&<p role="alert" className="studio-error">{t("内容切片使用字幕分析,请调整分析方式或制作类型。")}</p>}
<Row label={t("分析方式")} hint={analysisMode==='visual'
? <>{t("会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。")}{capability?.visual_model?` · ${capability.visual_model}`:''}</>
: unavailable ? t("当前模型不支持画面分析;换一个多模态模型后可开启。") : t("只分析字幕文本,成本最低;没有字幕时会先转写。")}>
<Segmented size="sm" ariaLabel={t("分析方式")} value={analysisMode} onChange={value=>!busy&&setAnalysisMode(value)}
options={[{value:'subtitle',label:t("仅字幕")},{value:'visual',label:t("字幕 + 画面")}]} />
</Row>
{unavailable&&<p role="alert" className="studio-error">{t("当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。")}</p>}
{incompatible&&<p role="alert" className="studio-error">{t("「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。")}</p>}
<p className="studio-muted">{plan.suggested_goals.length?t("已勾选建议制作的类型,你可以取消或补选。"):t("本次未能自动推荐,请按素材内容选择制作类型。")}{' '}{t("确认后才开始详细理解与剪辑。")}</p>
<div className="studio-output-choices">{choices.map(({goal,description})=><label key={goal} className={`studio-output-choice ${selected.includes(goal)?'is-selected':''}`}><input type="checkbox" checked={selected.includes(goal)} disabled={busy} onChange={e=>setSelected(e.target.checked?[...selected,goal]:selected.filter(g=>g!==goal))}/><b>{t(goalLabels[goal])}</b>{plan.suggested_goals.includes(goal)&&<small>{t("建议")}</small>}<span className="studio-muted">{t(goal==='highlight'&&analysisMode==='subtitle'?'按语音与内容含义,提炼完整片段':description)}</span></label>)}</div>
<div className="studio-output-choices">{choices.map(({goal,description})=><label key={goal} className={`studio-output-choice ${selected.includes(goal)?'is-selected':''}`}><input type="checkbox" checked={selected.includes(goal)} disabled={busy} onChange={e=>setSelected(e.target.checked?[...selected,goal]:selected.filter(g=>g!==goal))}/><b>{t(goalLabels[goal])}</b>{plan.suggested_goals.includes(goal)&&<small>{t("建议")}</small>}<span className="studio-muted">{t(goal==='highlight'&&analysisMode==='subtitle'?'按字幕找到高潮句,保留前后关键过程':description)}</span></label>)}</div>
{analysisMode==='subtitle'&&selected.includes('promo')&&<p className="studio-muted">{t("字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。")}</p>}
<details className="studio-details"><summary>{t("调整制作参数(可选)")}</summary><ImportPreferences value={value} onChange={setValue} hideGoal/></details>
<div className="studio-row studio-confirm-footer"><span className="studio-muted">{selected.length?t('将制作 {{count}} 类内容', { count: selected.length }):t("至少选择一种制作类型")}</span><Btn variant="cta" disabled={!selected.length||busy||incompatible||unavailable} loading={busy} onClick={start}>{t("确认并开始制作")}</Btn></div>
+63 -12
View File
@@ -6,11 +6,12 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Btn, Dialog, ProgressLine, Row, fmtDuration } from '../../ui'
import { studioApi, errorText, type SourcePreview } from './api'
import { useWorkspace } from './useWorkspace'
import { Draft, Scene, languages, draftDuration, draftError, moveScene, applyCandidate, portraitDesign } from './types'
import { Draft, Scene, SubtitleCue, languages, draftDuration, draftError, moveScene, applyCandidate, portraitDesign, cropAt } from './types'
import CandidatePicker from './CandidatePicker'
import TitleArtwork from './TitleArtwork'
import DraftVariantDialog from './DraftVariantDialog'
import { titlePresets, titleVersions, isArtworkStyle, titleDesignThumbnails } from './titlePresets'
import { titlePresets, isArtworkStyle } from './titlePresets'
import DraftSettingsPanel, { type FramingState } from './DraftSettingsPanel'
import { draftExportState } from './draftExportState'
import './studio.css'
@@ -36,6 +37,22 @@ function Editor({ projectId, draftId }: { projectId: string; draftId: string })
const [showRendered, setShowRendered] = useState(false)
const [playbackError, setPlaybackError] = useState(false)
const [sourcePreview, setSourcePreview] = useState<SourcePreview>({status: 'idle'})
const [cues, setCues] = useState<SubtitleCue[]>([])
const [currentTime, setCurrentTime] = useState(0)
const [framing, setFraming] = useState<FramingState>({ busy: false })
useEffect(() => {
const controller = new AbortController()
studioApi.subtitles(projectId, controller.signal).then(v => setCues(v.cues)).catch(() => setCues([]))
studioApi.framingStatus().then(status => setFraming(f => ({ ...f, status }))).catch(() => undefined)
return () => controller.abort()
}, [projectId])
useEffect(() => {
if (framing.status?.status !== 'installing') return
const timer = window.setInterval(() => {
studioApi.framingStatus().then(status => setFraming(f => ({ ...f, status }))).catch(() => undefined)
}, 2000)
return () => window.clearInterval(timer)
}, [framing.status?.status])
useEffect(() => {
if (!['queued', 'running'].includes(sourcePreview.status)) return
let cancelled = false
@@ -57,6 +74,39 @@ function Editor({ projectId, draftId }: { projectId: string; draftId: string })
} catch(error) { setSourcePreview({status:'failed', error:errorText(error)}) }
}
/** Centre every scene's crop window on the speaker; scenes without a face keep their value. */
const autoFrame = async (target?: Draft) => {
const base = target || draft
if (!base || framing.status?.status !== 'installed') return
setFraming(f => ({ ...f, busy: true, error: undefined }))
try {
const result = await studioApi.autoFrame(projectId, base)
const found = new Map(result.scenes.filter(s => s.crop_x !== null).map(s => [s.id, s]))
setDraft(current => current ? { ...current, scenes: current.scenes.map(sc => { const f = found.get(sc.id); return f ? { ...sc, crop_x: f.crop_x, crop_track: f.crop_track } : sc }) } : current)
setShowRendered(false)
setFraming(f => ({ ...f, busy: false, result: { framed: found.size, total: result.scenes.length, switches: result.scenes.reduce((n, s) => n + s.switches, 0) } }))
} catch (e) { setFraming(f => ({ ...f, busy: false, error: t(errorText(e)) })) }
}
const installFraming = async () => {
try { const status = await studioApi.framingInstall(); setFraming(f => ({ ...f, status })) }
catch (e) { setFraming(f => ({ ...f, error: t(errorText(e)) })) }
}
// Once the runtime lands (or the user switches to a cropped layout), frame automatically the first time.
const cropping = !!draft && draft.aspect !== 'original' && draft.layout === 'crop'
useEffect(() => {
if (!cropping || framing.busy || framing.result || framing.status?.status !== 'installed') return
if (draft?.scenes.some(s => s.crop_x != null || s.crop_track?.length)) return
void autoFrame()
}, [cropping, framing.status?.status])
const setSceneCrop = (sceneId: string, cropX: number) => { if (draft) patch({ scenes: draft.scenes.map(sc => sc.id === sceneId ? { ...sc, crop_x: cropX, crop_track: null } : sc) }) }
const applyPortrait = () => {
if (!draft) return
const next = portraitDesign(draft)
patch(next)
setFraming(f => ({ ...f, result: undefined }))
void autoFrame(next)
}
const [suggestion, setSuggestion] = useState<Draft | null>(null)
const [undo, setUndo] = useState<Draft | null>(null)
const video = useRef<HTMLVideoElement>(null)
@@ -119,6 +169,7 @@ function Editor({ projectId, draftId }: { projectId: string; draftId: string })
}
if (!draft) return <div className="ac-page"><button className="ac-back" onClick={() => navigate(`/project/${projectId}`)}>{t("‹ 返回项目")}</button><div className="ac-empty"><b>{loading ? t("加载成片草稿…") : t("无法打开草稿")}</b>{loadError || (!loading && t("草稿不存在或已移除"))}<Btn onClick={refresh}>{t("重试")}</Btn></div></div>
const previewUrl = currentJob?.status === 'completed' ? studioApi.video(projectId, currentJob.job_id) : ''
const activeCue = cues.find(c => c.start <= currentTime && currentTime < c.end)
const updateScene = (i: number, update: Partial<Scene>) => patch({ scenes: draft.scenes.map((s, index) => index === i ? {...s,...update} : s) })
return <div className="ac-page studio-editor-page">
<button className="ac-back" onClick={() => navigate(`/project/${projectId}`)}>{t("‹ 返回项目")}</button>
@@ -131,25 +182,25 @@ function Editor({ projectId, draftId }: { projectId: string; draftId: string })
<Btn disabled={['queued', 'running'].includes(sourcePreview.status)} onClick={preparePreview}>{t(['queued', 'running'].includes(sourcePreview.status) ? '正在生成兼容预览,长视频可能需要几分钟…' : '生成兼容预览')}</Btn>
</details>}
<fieldset disabled={!!busy} className="studio-fieldset">
<div className="studio-editor-grid"><section><div className={`studio-stage studio-stage--${draft.aspect}`}>
<div className="studio-editor-grid"><section className="studio-editor-main"><div className="studio-editor-sticky"><div className={`studio-stage studio-stage--${draft.aspect}`}>
<div className="studio-video-frame" style={{aspectRatio: draft.aspect==='portrait'?'9/16':draft.aspect==='landscape'?'16/9':undefined}}>
<video ref={video} controls preload="metadata" muted={!draft.original_audio} onError={() => setPlaybackError(true)} onLoadedData={() => setPlaybackError(false)} src={showRendered && previewUrl ? previewUrl : sourcePreview.status === 'completed' && sourcePreview.version ? studioApi.compatibleSource(projectId, sourcePreview.version) : studioApi.source(projectId)} style={{objectFit: showRendered || draft.layout!=='crop'?'contain':'cover', objectPosition:`${(draft.crop_x ?? .5)*100}% 50%`}} onLoadedMetadata={() => { if(video.current && scene && !showRendered) { setSourceDuration(video.current.duration); video.current.currentTime=scene.start } }} onTimeUpdate={() => {const v=video.current; if(v && scene && !showRendered && v.currentTime>=scene.end) {v.pause();v.currentTime=scene.start}}} />
<video ref={video} controls preload="metadata" muted={!draft.original_audio} onError={() => setPlaybackError(true)} onLoadedData={() => setPlaybackError(false)} src={showRendered && previewUrl ? previewUrl : sourcePreview.status === 'completed' && sourcePreview.version ? studioApi.compatibleSource(projectId, sourcePreview.version) : studioApi.source(projectId)} style={{objectFit: showRendered || draft.layout!=='crop'?'contain':'cover', objectPosition:`${cropAt(scene, currentTime, draft.crop_x ?? .5)*100}% 50%`}} onLoadedMetadata={() => { if(video.current && scene && !showRendered) { setSourceDuration(video.current.duration); video.current.currentTime=scene.start } }} onTimeUpdate={() => {const v=video.current; if(!v) return; setCurrentTime(v.currentTime); if(scene && !showRendered && v.currentTime>=scene.end) {v.pause();v.currentTime=scene.start}}} />
{!showRendered && draft.subtitles && activeCue && <div className={`studio-caption-overlay studio-caption studio-caption--${draft.subtitle_style || 'clean'}`}>{activeCue.text}</div>}
{!showRendered && selected===0 && draft.hook && !artworkStyle && <div className={`studio-hook studio-hook--${draft.title_style || 'plain'}`}>{draft.hook}</div>}
{!showRendered && selected===0 && draft.hook && artworkStyle && <TitleArtwork projectId={projectId} draft={draft}/>}
</div>
</div><div className="studio-row studio-preview-foot"><span className="studio-muted">{showRendered?t("实际渲染结果"):t("原片预览 · 字幕、翻译以渲染结果为准")}</span>{previewUrl ? <Btn size="sm" onClick={() => setShowRendered(!showRendered)}>{showRendered?t("查看原片"):t("播放成片")}</Btn> : <Btn size="sm" disabled={!!active} onClick={render}>{active?t("正在渲染"):t("渲染预览")}</Btn>}</div></section>
<aside className="studio-edit-panel"><h2>{t("改到满意,就导出。")}</h2><Btn size="sm" onClick={() => patch(portraitDesign(draft))}>{t("应用竖屏推荐")}</Btn><label className="studio-field">{t("成片名称")}<input maxLength={200} value={draft.title} onChange={e => patch({title:e.target.value})} /></label><label className="studio-field">{t("开头文字")}<textarea maxLength={120} value={draft.hook} placeholder={t("可留空;在首镜头最多显示 4 秒")} onChange={e => patch({hook:e.target.value})} /></label>
<div className="studio-field"><span>{t("标题模板")}</span><small className="studio-muted">{t("缩略图为设计参考,当前文案效果见预览。")}</small><div className="studio-template-options">{titlePresets.map(preset=><button type="button" key={preset.value} className={`studio-template studio-template--${preset.value} ${isArtworkStyle(preset.value)?'studio-template--art':''}`} aria-pressed={(draft.title_style || 'plain') === preset.value} onClick={()=>patch({title_style:preset.value,title_template_version:isArtworkStyle(preset.value)?6:1,title_accent:null})}>{isArtworkStyle(preset.value)&&<img src={titleDesignThumbnails[preset.value]} alt="" width={360} height={240}/>}<span>{t(preset.label)}</span></button>)}</div></div>{artworkStyle && <details className="studio-details"><summary>{t("调整文字样式")}</summary><label className="studio-field">{t("样式版本")}<select aria-label={t("样式版本")} value={draft.title_template_version??1} onChange={e=>patch({title_template_version:Number(e.target.value) as Draft['title_template_version']})}>{titleVersions(draft.title_style).map(v=><option key={v.value} value={v.value}>{t(v.label)}</option>)}</select></label><label className="studio-field">{t("强调色")}<input type="color" aria-label={t("标题强调色")} value={draft.title_accent || (draft.title_style==='comic'?'#ffe52d':draft.title_style==='neon'?'#ccff00':draft.title_style==='editorial'?'#ff4826':draft.title_style==='pixel'?'#ed327c':draft.title_style==='frosted'?'#00e6dc':'#dfff00')} onChange={e=>patch({title_accent:e.target.value})}/></label><label className="studio-field">{t("文字大小")}<input type="range" aria-label={t("文字大小")} min=".75" max="1.2" step=".05" value={draft.title_scale??1} onChange={e=>patch({title_scale:Number(e.target.value)})}/></label><label className="studio-field">{t("文字位置")}<input type="range" aria-label={t("文字位置")} min=".06" max=".70" step=".01" value={draft.title_y??.12} onChange={e=>patch({title_y:Number(e.target.value)})}/></label>{!['pixel','frosted'].includes(draft.title_style||'') && <label><input type="checkbox" checked={draft.title_motion??true} onChange={e=>patch({title_motion:e.target.checked})}/>{t("开启入场动效")}</label>}<p className="studio-muted">{t("预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。")}</p></details>}<label className="studio-field">{t("输出文字语言")}<select value={draft.language} onChange={e => patch({language:e.target.value as Draft['language']})}>{languages.map(l => <option key={l.value} value={l.value}>{l.value==='source' ? t('原语言') : l.label}</option>)}</select></label>
<p className="studio-muted">{t("选择翻译语言后,渲染时会翻译开头文字与所选原字幕。")}</p>
<Row label={t("字幕")}><label><input type="checkbox" checked={draft.subtitles} onChange={e=>patch({subtitles:e.target.checked})} />{t("烧录原字幕")}</label></Row><Row label={t("声音")}><label><input type="checkbox" checked={draft.original_audio} onChange={e=>patch({original_audio:e.target.checked})} />{t("保留原声")}</label></Row>
<details className="studio-details"><summary>{t("画面设置")}</summary><label className="studio-field">{t("画幅")}<select value={draft.aspect} onChange={e=>patch({aspect:e.target.value as Draft['aspect'], ...(e.target.value==='portrait'?{layout:'crop' as const}:{})})}><option value="original">{t("原画幅")}</option><option value="portrait">{t("9:16 竖屏")}</option><option value="landscape">{t("16:9 横屏")}</option></select></label><label className="studio-field">{t("构图")}<select value={draft.layout} onChange={e=>patch({layout:e.target.value as Draft['layout']})}><option value="fit">{t("完整画面 · 留边")}</option><option value="blur">{t("完整画面 · 模糊背景")}</option><option value="crop">{t("满屏取景")}</option></select></label>{draft.layout==='crop' && <label className="studio-field">{t("取景位置 · 左右移动")}<input aria-label={t("取景位置")} type="range" min="0" max="1" step=".01" value={draft.crop_x ?? .5} onChange={e=>patch({crop_x:Number(e.target.value)})}/></label>}<p className="studio-muted">{draft.layout==='crop'?t("主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。"):draft.aspect==='portrait'?t("当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。"):t("保留原画面构图。")}</p></details>
</aside></div>
</div><div className="studio-row studio-preview-foot"><span className="studio-muted">{showRendered?t("实际渲染结果"):t("原片预览 · 字幕、翻译以渲染结果为准")}</span>{previewUrl ? <Btn size="sm" onClick={() => setShowRendered(!showRendered)}>{showRendered?t("查看原片"):t("播放成片")}</Btn> : <Btn size="sm" disabled={!!active} onClick={render}>{active?t("正在渲染"):t("渲染预览")}</Btn>}</div></div>
<div className="studio-prompt"><input aria-label={t("文案修改要求")} placeholder={t("告诉 AI 怎么改文案,例如:开头改成一个简短的问题")} value={instruction} onChange={e=>setInstruction(e.target.value)} /><Btn variant="cta" loading={busy==='rewrite'} disabled={!instruction.trim()} onClick={rewrite}>{t("改一版文案")}</Btn></div>
{undo && <Btn variant="text" onClick={()=>{setDraft({...undo, revision:draft.revision});setUndo(null);setShowRendered(false)}}>{t("撤销上次修改")}</Btn>}
<details className="studio-details studio-source-details"><summary>{t("调整镜头")}{' '}<span className="ac-mono">{draft.scenes.length}</span>{' '}{t("· 顺序与起止点")}</summary>
<Btn size="sm" disabled={draft.scenes.length >= 30} onClick={() => setPicker('append')}>{t("追加镜头")}</Btn>
{draft.scenes.map((s,i)=><div className="studio-scene-row" key={s.id}><Btn size="sm" onClick={()=>{setSelected(i);setShowRendered(false)}}>{i+1}. {s.label}</Btn><label>{t("起点(秒)")}<input type="number" min={0} step={.1} value={s.start} onChange={e=>updateScene(i,{start:Number(e.target.value)})}/></label><label>{t("终点(秒)")}<input type="number" min={0} step={.1} value={s.end} onChange={e=>updateScene(i,{end:Number(e.target.value)})}/></label><div className="studio-actions"><Btn size="sm" onClick={() => setPicker(i)}>{t("替换")}</Btn><Btn size="sm" disabled={i===0} onClick={()=>patch({scenes:moveScene(draft,i,-1).scenes})}>{t("上移")}</Btn><Btn size="sm" disabled={i===draft.scenes.length-1} onClick={()=>patch({scenes:moveScene(draft,i,1).scenes})}>{t("下移")}</Btn><Btn size="sm" disabled={draft.scenes.length===1} onClick={()=>{patch({scenes:draft.scenes.filter((_,index)=>index!==i)});setSelected(0)}}>{t("移除")}</Btn></div></div>)}
</details>
</section>
<DraftSettingsPanel draft={draft} patch={patch} scene={scene} currentTime={currentTime} onSceneCrop={setSceneCrop}
framing={framing} onAutoFrame={() => void autoFrame()} onInstallFraming={() => void installFraming()} onPortrait={applyPortrait}
coverHref={previewUrl && currentJob ? `/project/${projectId}/publish/studio-${currentJob.job_id}` : undefined}
onOpenCover={href => navigate(href)} /></div>
</fieldset>
{active && <div className="studio-render-state"><ProgressLine percent={active.percent}/><span className="studio-muted">{active.status==='queued'?t("等待渲染"):t("渲染成片")} · {active.percent}%</span></div>}
{currentJob?.status==='failed' && <p className="studio-error" role="alert">{t(currentJob.error || '')}</p>}
@@ -169,7 +220,7 @@ function Editor({ projectId, draftId }: { projectId: string; draftId: string })
<Dialog open={!!suggestion} onClose={()=>setSuggestion(null)} title={t("查看文案修改")} description={t("确认后应用到当前草稿,镜头与声音保持原设置。")} footer={<div className="studio-actions"><Btn onClick={()=>setSuggestion(null)}>{t("保留原稿")}</Btn><Btn variant="cta" onClick={()=>{setUndo(draft);setDraft(suggestion);setSuggestion(null);setShowRendered(false)}}>{t("应用修改")}</Btn></div>}><p>{suggestion?.title}</p><p>{suggestion?.hook || t("无开头文字")}</p></Dialog>
<Dialog open={showExport} onClose={()=>!busy && setShowExport(false)} title={t("导出成片")} description={t("保存当前修改并渲染;已有输出会保留在导出记录。")} footer={<div className="studio-actions"><Btn disabled={!!busy} onClick={()=>setShowExport(false)}>{t("关闭")}</Btn>{previewUrl && <Btn onClick={()=>navigate(`/project/${projectId}/publish/studio-${currentJob!.job_id}`)}>{t("发布这版成片")}</Btn>}{previewUrl ? <StudioDownloadLink className="ac-btn ac-btn--cta" projectId={projectId} jobId={currentJob!.job_id}/> : <Btn variant="cta" loading={busy==='render'||!!active} disabled={!!active} onClick={render}>{t("确认导出")}</Btn>}</div>}>
<Row label={t("成片")}>{draft.title}</Row><Row label={t("格式")}>MP4 · 30 fps</Row><Row label={t("画幅")}>{draft.aspect==='portrait'?'1080 × 1920':draft.aspect==='landscape'?'1920 × 1080':t("保持原尺寸")}</Row><Row label={t("文字语言")}>{draft.language==='source' ? t('原语言') : languages.find(l=>l.value===draft.language)?.label}</Row>
<Row label={t("开头包装")}>{draft.hook ? t(titlePresets.find(preset=>preset.value===(draft.title_style||'plain'))?.label ?? '') : t("无开头文字")}</Row><Row label={t("原声")}>{draft.original_audio?t("保留"):t("已关闭")}</Row><Row label={t("字幕")}>{draft.subtitles?t("烧录已有字幕"):t("已关闭")}</Row>
<Row label={t("片头标题")}>{draft.hook ? t(titlePresets.find(preset=>preset.value===(draft.title_style||'plain'))?.label ?? '') : t("无开头文字")}</Row><Row label={t("原声")}>{draft.original_audio?t("保留"):t("已关闭")}</Row><Row label={t("字幕")}>{draft.subtitles?t("字幕压进画面"):t("已关闭")}</Row><Row label={t("封面")} hint={t("渲染完成后,在发布页按平台生成带标题的封面。")}>{previewUrl && currentJob && <Btn size="sm" variant="text" onClick={()=>navigate(`/project/${projectId}/publish/studio-${currentJob.job_id}`)}>{t("去生成")}</Btn>}</Row>
{currentJob?.status==='completed' && !!currentJob.result?.warnings?.length && <div role="status" className="studio-muted">{currentJob.result.warnings.map(w=><p key={w}>{t(w)}</p>)}</div>}
{active && <ProgressLine percent={active.percent}/>}<p className="studio-muted">{previewUrl?t("当前版本已渲染完成,可以直接下载。"):t("任务在后台继续,关闭面板不会取消渲染。")}</p>{error && <p className="studio-error">{error}</p>}{currentJob?.status==='failed'&&<p className="studio-error">{t(currentJob.error || '')}</p>}
</Dialog>
+5 -1
View File
@@ -1,7 +1,7 @@
import { observeStudioOperation, studioImportProperties, studioGoals, observeStudioWorkspace } from '../../analytics/studio'
import { workflow } from '../../analytics/observer'
import api from '../../services/api'
import { Draft, Workspace, RenderJob, Language, CandidateList, ImportOptions, Goal, AnalysisMode, AnalysisPreferences } from './types'
import { Draft, Workspace, RenderJob, Language, CandidateList, ImportOptions, Goal, AnalysisMode, AnalysisPreferences, SubtitleCue, FramingStatus, AutoFrameResult } from './types'
export type SourcePreview = { status: 'idle' | 'queued' | 'running' | 'completed' | 'failed'; version?: string; error?: string }
export const studioApi = {
preparePreview: (pid: string): Promise<SourcePreview> => api.post(`/studio/${pid}/source-preview`),
@@ -10,6 +10,10 @@ export const studioApi = {
analysisPreferences: (): Promise<AnalysisPreferences> => api.get('/studio/analysis-preferences'),
saveAnalysisPreferences: (body: AnalysisPreferences): Promise<AnalysisPreferences> => observeStudioOperation('studio_analysis_preferences', () => api.put('/studio/analysis-preferences', body), undefined, { analysis_mode: body.analysis_mode, allow_visual_screening: body.allow_visual_screening }),
source: (pid: string) => `${api.defaults.baseURL}/studio/${pid}/source`,
subtitles: (pid: string, signal?: AbortSignal): Promise<{ cues: SubtitleCue[] }> => api.get(`/studio/${pid}/subtitles`, { signal }),
framingStatus: (): Promise<FramingStatus> => api.get('/studio/framing/status'),
framingInstall: (): Promise<FramingStatus & { started: boolean }> => api.post('/studio/framing/install'),
autoFrame: (pid: string, draft: Draft): Promise<AutoFrameResult> => api.post(`/studio/${pid}/auto-frame`, draft, { timeout: 120000 }),
capabilities: (): Promise<{ visual_analysis: boolean; visual_model: string }> => api.get('/studio/capabilities'),
get: (pid: string, signal?: AbortSignal): Promise<Workspace> => observeStudioWorkspace(pid, () => api.get(`/studio/${pid}`, { signal })),
titleThumbnail: (style: string, version = 6) => `${api.defaults.baseURL}/studio/title-presets/${style}/thumbnail?v=${version}`,
+49 -5
View File
@@ -1,14 +1,14 @@
.ac-creative-import{max-width:820px;margin:20px auto 48px}.ac-creative-import>.ac-title{font-size:24px;margin-bottom:10px}.studio-muted{font-size:12.5px;color:var(--ac-sub);line-height:1.6}.studio-import-box{margin-top:20px;padding:22px;border:1px solid var(--ac-line);border-radius:16px;background:var(--ac-card);box-shadow:var(--ac-shadow)}.studio-row{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap}.studio-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.studio-goal{padding:18px 0;margin:4px 0 14px;border-bottom:1px solid var(--ac-line);font-size:13px}.studio-field{display:flex;flex-direction:column;gap:8px;font-size:12.5px;color:var(--ac-sub);margin:16px 0;min-width:0}.studio-field input,.studio-field textarea,.studio-field select,.studio-input,.studio-scene-row input,.studio-fieldset input[type=number]{border:1px solid var(--ac-line);background:var(--ac-card);color:var(--ac-ink);border-radius:9px;padding:10px 12px;font:inherit;max-width:100%;min-width:0}.studio-input{width:340px}.studio-field textarea{min-height:84px;resize:vertical}.studio-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.studio-details{margin-top:15px}.studio-details>summary{color:var(--ac-sub);font-size:13px;cursor:pointer;padding:6px 0}.studio-import-bottom{margin-top:20px}.studio-file{border:1px dashed var(--ac-line);border-radius:12px;padding:24px;display:flex;flex-direction:column;gap:12px;text-align:center;align-items:center}.studio-file input{max-width:100%;font-size:12px}.studio-file small{color:var(--ac-muted)}.studio-error{color:var(--ac-error);font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere;margin:14px 0}.studio-link{color:var(--ac-accent);background:transparent;border:0;cursor:pointer;font:inherit}.studio-fieldset{border:0;margin:0;padding:0;min-width:0}.studio-editor-head{margin:6px 0 28px;align-items:flex-start}.studio-editor-grid{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(280px,.8fr);gap:26px}.studio-stage{padding:22px;background:var(--ac-line-2);border-radius:16px;display:flex;justify-content:center;align-items:center;min-height:360px}.studio-video-frame{container-type:inline-size;width:100%;position:relative;background:#19181a;border-radius:10px;overflow:hidden}.studio-video-frame video{display:block;width:100%;height:100%;min-height:180px}.studio-stage--portrait .studio-video-frame{max-width:235px}.studio-hook{position:absolute;top:8%;left:5%;right:5%;font-size:6cqw;font-weight:600;line-height:1.3;color:white;text-align:center;text-shadow:0 2px 5px #000;white-space:pre-wrap;pointer-events:none;overflow-wrap:anywhere}.studio-edit-panel{border:1px solid var(--ac-line);border-radius:16px;background:var(--ac-card);padding:22px}.studio-edit-panel h2{font-size:16px;margin:0 0 12px}.studio-edit-panel input[type=checkbox]{accent-color:var(--ac-accent)}.studio-preview-foot{margin-top:12px}.studio-prompt{margin-top:22px;display:flex;gap:12px;padding:8px 8px 8px 18px;background:var(--ac-card);border:1px solid var(--ac-line);border-radius:999px;align-items:center}.studio-prompt input{flex:1;border:0;background:none;color:var(--ac-ink);font:inherit;font-size:13px;min-width:0}.studio-source-details{margin-top:28px;padding-top:15px;border-top:1px solid var(--ac-line)}.studio-source-details>summary{color:var(--ac-ink)}.studio-scene-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--ac-line);padding:15px 0}.studio-scene-row label{font-size:12px;color:var(--ac-sub);display:flex;flex-direction:column;gap:7px}.studio-scene-row input{width:110px}.studio-history-row{display:flex;flex-direction:column;gap:10px;padding:16px 0;border-bottom:1px solid var(--ac-line)}.studio-history-row>a{align-self:flex-start}.studio-render-state{margin-top:20px}.studio-thumb{border:0;width:100%;padding:0;cursor:pointer}.studio-thumb video{width:100%;height:100%;object-fit:cover}.studio-result-grid .ac-card-actions{opacity:1;pointer-events:auto}.studio-source-row{padding:16px 0;border-bottom:1px solid var(--ac-line);display:flex;align-items:center;justify-content:space-between;gap:18px}.studio-source-row>div:first-child{min-width:0;flex:1}.studio-source-row b{font-weight:500;font-size:14px}.studio-source-video{width:100%;max-height:420px;border-radius:12px;background:#19181a;margin-top:14px}.studio-processing{margin-bottom:24px}.studio-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
@media(max-width:850px){.studio-editor-grid{grid-template-columns:minmax(0,1fr) minmax(250px,.8fr);gap:16px}.studio-fields{grid-template-columns:1fr 1fr}}
.ac-creative-import{max-width:820px;margin:20px auto 48px}.ac-creative-import>.ac-title{font-size:24px;margin-bottom:10px}.studio-muted{font-size:12.5px;color:var(--ac-sub);line-height:1.6}.studio-import-box{margin-top:20px;padding:22px;border:1px solid var(--ac-line);border-radius:16px;background:var(--ac-card);box-shadow:var(--ac-shadow)}.studio-row{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap}.studio-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.studio-goal{padding:18px 0;margin:4px 0 14px;border-bottom:1px solid var(--ac-line);font-size:13px}.studio-field{display:flex;flex-direction:column;gap:8px;font-size:12.5px;color:var(--ac-sub);margin:16px 0;min-width:0}.studio-field input,.studio-field textarea,.studio-field select,.studio-input,.studio-scene-row input,.studio-fieldset input[type=number]{border:1px solid var(--ac-line);background:var(--ac-card);color:var(--ac-ink);border-radius:9px;padding:10px 12px;font:inherit;max-width:100%;min-width:0}.studio-input{width:340px}.studio-field textarea{min-height:84px;resize:vertical}.studio-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.studio-details{margin-top:15px}.studio-details>summary{color:var(--ac-sub);font-size:13px;cursor:pointer;padding:6px 0}.studio-import-bottom{margin-top:20px}.studio-file{border:1px dashed var(--ac-line);border-radius:12px;padding:24px;display:flex;flex-direction:column;gap:12px;text-align:center;align-items:center}.studio-file input{max-width:100%;font-size:12px}.studio-file small{color:var(--ac-muted)}.studio-error{color:var(--ac-error);font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere;margin:14px 0}.studio-link{color:var(--ac-accent);background:transparent;border:0;cursor:pointer;font:inherit}.studio-fieldset{border:0;margin:0;padding:0;min-width:0}.studio-editor-head{margin:6px 0 28px;align-items:flex-start}.studio-editor-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,400px);gap:26px;align-items:start}.studio-stage{padding:22px;background:var(--ac-line-2);border-radius:16px;display:flex;justify-content:center;align-items:center;min-height:320px}.studio-video-frame{container-type:inline-size;width:100%;position:relative;background:#19181a;border-radius:10px;overflow:hidden}.studio-video-frame video{display:block;width:100%;height:100%;min-height:180px}.studio-stage--portrait .studio-video-frame{width:auto;height:min(56vh,560px);max-width:100%}.studio-hook{position:absolute;top:8%;left:5%;right:5%;font-size:6cqw;font-weight:600;line-height:1.3;color:white;text-align:center;text-shadow:0 2px 5px #000;white-space:pre-wrap;pointer-events:none;overflow-wrap:anywhere}.studio-edit-panel{border:1px solid var(--ac-line);border-radius:16px;background:var(--ac-card);padding:22px}.studio-edit-panel input[type=checkbox]{accent-color:var(--ac-accent)}.studio-preview-foot{margin-top:12px}.studio-prompt{margin-top:22px;display:flex;gap:12px;padding:8px 8px 8px 18px;background:var(--ac-card);border:1px solid var(--ac-line);border-radius:999px;align-items:center}.studio-prompt input{flex:1;border:0;background:none;color:var(--ac-ink);font:inherit;font-size:13px;min-width:0}.studio-source-details{margin-top:28px;padding-top:15px;border-top:1px solid var(--ac-line)}.studio-source-details>summary{color:var(--ac-ink)}.studio-scene-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--ac-line);padding:15px 0}.studio-scene-row label{font-size:12px;color:var(--ac-sub);display:flex;flex-direction:column;gap:7px}.studio-scene-row input{width:110px}.studio-history-row{display:flex;flex-direction:column;gap:10px;padding:16px 0;border-bottom:1px solid var(--ac-line)}.studio-history-row>a{align-self:flex-start}.studio-render-state{margin-top:20px}.studio-thumb{border:0;width:100%;padding:0;cursor:pointer}.studio-thumb video{width:100%;height:100%;object-fit:cover}.studio-result-grid .ac-card-actions{opacity:1;pointer-events:auto}.studio-source-row{padding:16px 0;border-bottom:1px solid var(--ac-line);display:flex;align-items:center;justify-content:space-between;gap:18px}.studio-source-row>div:first-child{min-width:0;flex:1}.studio-source-row b{font-weight:500;font-size:14px}.studio-source-video{width:100%;max-height:420px;border-radius:12px;background:#19181a;margin-top:14px}.studio-processing{margin-bottom:24px}.studio-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
@media(max-width:850px){.studio-editor-grid{grid-template-columns:minmax(0,1fr);gap:16px}.studio-editor-main{position:static;max-height:none;overflow:visible}.studio-fields{grid-template-columns:1fr 1fr}}
@media(max-width:650px){.studio-editor-grid,.studio-fields{grid-template-columns:1fr}.studio-import-box,.studio-edit-panel{padding:16px}.studio-prompt{border-radius:12px;flex-wrap:wrap}.studio-prompt input{min-width:140px}.studio-source-row{align-items:flex-start;flex-direction:column}.studio-result-grid{grid-template-columns:1fr}.studio-input{width:100%}.studio-editor-head .studio-actions{margin-top:8px}}
.studio-candidate-video{display:block;width:100%;max-height:230px;background:#19181a;border-radius:10px}.studio-candidate-list{display:flex;flex-direction:column;gap:8px;max-height:220px;overflow:auto}.studio-candidate-option{display:flex;gap:10px;padding:12px;border:1px solid var(--ac-line);border-radius:10px;align-items:flex-start;cursor:pointer}.studio-candidate-option:has(input:checked){border-color:var(--ac-accent)}.studio-candidate-option span{min-width:0}.studio-candidate-option b{display:block;font-size:13px;font-weight:500;overflow-wrap:anywhere}.studio-candidate-option small{display:block;margin-top:6px;font-size:12px;color:var(--ac-sub)}
.studio-template-options{display:flex;gap:8px;flex-wrap:wrap}.studio-template{padding:12px 10px;border-radius:8px;border:2px solid transparent;cursor:pointer;font-size:12px}.studio-template[aria-pressed=true]{border-color:var(--ac-accent);box-shadow:0 0 0 2px var(--ac-card)}.studio-template--impact{background:#151923;color:#ffec47;font-weight:900}.studio-template--card{background:#182637;color:#fff;border-left:4px solid #56ecbb}.studio-template--plain{background:var(--ac-line-2);color:var(--ac-ink)}.studio-hook--impact{top:12%;left:10%;right:10%;font-size:8.5cqw;line-height:1.08;font-family:Arial,sans-serif;font-weight:900;text-transform:uppercase;color:#ffec47;-webkit-text-stroke:.25cqw #10141c;paint-order:stroke fill;text-shadow:0 .6cqw .15cqw #10141c;letter-spacing:-.15cqw}.studio-hook--card{top:12%;left:8%;right:8%;padding:5cqw 4cqw;font-size:6.5cqw;line-height:1.18;text-align:left;background:#142231e8;border-left:1cqw solid #56ecbb;font-weight:700;text-shadow:none}
.studio-hook--impact{top:12%;left:10%;right:10%;font-size:8.5cqw;line-height:1.08;font-family:Arial,sans-serif;font-weight:900;text-transform:uppercase;color:#ffec47;-webkit-text-stroke:.25cqw #10141c;paint-order:stroke fill;text-shadow:0 .6cqw .15cqw #10141c;letter-spacing:-.15cqw}.studio-hook--card{top:12%;left:8%;right:8%;padding:5cqw 4cqw;font-size:6.5cqw;line-height:1.18;text-align:left;background:#142231e8;border-left:1cqw solid #56ecbb;font-weight:700;text-shadow:none}
.studio-title-art{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.studio-title-status{position:absolute;bottom:18%;left:5%;right:5%;background:#152030e8;color:#fff;padding:8px;font-size:12px;text-align:center}.studio-title-status button{margin-left:6px}
.studio-title-art{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.studio-title-status{position:absolute;bottom:18%;left:5%;right:5%;background:#152030e8;color:#fff;padding:8px;font-size:12px;text-align:center}.studio-title-status button{margin-left:6px}.studio-template--comic{background:#161a24;color:#ffe52d;font-weight:900;font-style:italic}.studio-template--neon{background:#ccff00;color:#10151d;font-weight:900}.studio-template--arena{background:#121d30;color:#dfff00;font-weight:900;font-style:italic;border-left:4px solid #25baff}
.studio-template--art{flex:1 1 27%;min-width:78px;max-width:130px;padding:6px 5px;background:#121b28;color:#fff;font-style:normal;border-left:2px solid transparent}.studio-template--art img{display:block;width:100%;height:auto;aspect-ratio:3/2;object-fit:cover;border-radius:4px}.studio-template--art span{display:block;padding:5px 0;font-size:11px;font-weight:500}
.studio-title-backdrop{position:absolute;inset:0;pointer-events:none;backdrop-filter:blur(1.6cqw);-webkit-backdrop-filter:blur(1.6cqw);mask-size:100% 100%;-webkit-mask-size:100% 100%;mask-repeat:no-repeat;-webkit-mask-repeat:no-repeat}
@@ -40,3 +40,47 @@
.studio-thumb--exported{background:#19181a}.studio-thumb--exported video{object-fit:contain}
.studio-thumb img{display:block;width:100%;height:100%;object-fit:cover}.studio-thumb--exported img{object-fit:contain}.studio-thumbnail-fallback{display:flex;height:100%;align-items:center;justify-content:center;padding:36px;color:#fff;background:#29272b;font-size:12px}
/* ---- editor settings panel: one column of setting rows (DESIGN.md → Row) ---- */
.studio-panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 4px}
.studio-panel-head h2{font-size:15px;font-weight:600;margin:0}
.studio-edit-panel .ac-rows{border-top:0}
.studio-edit-panel .ac-row{gap:16px;padding:14px 0}
.studio-edit-panel .ac-row:last-child{border-bottom:0}
.studio-edit-panel .ac-row--stack .ac-row-control,.studio-edit-panel .ac-row--stack .ac-row-control--wide{width:100%;flex:1 1 auto;flex-direction:column;align-items:stretch;gap:10px}
.studio-edit-panel .ac-row--stack .ac-input{width:100%}
.studio-edit-panel .ac-row--stack input[type=range]{width:100%;accent-color:var(--ac-ink)}
.studio-edit-panel .ac-disclosure{margin-top:0;border-bottom:1px solid var(--ac-line)}
.studio-edit-panel .ac-disclosure>summary{padding:14px 0}
.studio-edit-panel .ac-disclosure .ac-row:first-of-type{border-top:1px solid var(--ac-line-2)}
.studio-edit-panel input[type=color]{width:36px;height:24px;padding:0;border:1px solid var(--ac-line);border-radius:999px;background:none}
.studio-style-preview{display:block;width:100%;max-width:220px;height:auto;aspect-ratio:3/2;object-fit:cover;border-radius:10px;border:1px solid var(--ac-line)}
.studio-preferences .ac-row{padding:12px 0}
.studio-preferences .ac-row:last-child{border-bottom:0}
.studio-plan-confirm .ac-row{padding:14px 0}
/* ---- style tiles: pick by looking, not by reading names ---- */
.studio-tiles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;width:100%}
.studio-tiles--3{grid-template-columns:repeat(3,minmax(0,1fr))}
.studio-tile{appearance:none;font:inherit;padding:0;border:1px solid var(--ac-line);border-radius:10px;background:var(--ac-card);cursor:pointer;overflow:hidden;text-align:center;transition:border-color var(--ac-t-short,200ms) ease-out}
.studio-tile:hover{border-color:var(--ac-muted)}
.studio-tile[aria-checked=true]{border-color:var(--ac-ink);box-shadow:0 0 0 1px var(--ac-ink) inset}
.studio-tile-sample{container-type:inline-size;display:flex;align-items:flex-end;justify-content:center;aspect-ratio:16/9;padding:6% 8%;background:linear-gradient(180deg,#5c6270 0%,#2b2f38 100%);color:#fff;font-size:11px;line-height:1.3;overflow:hidden}
.studio-tile-sample--center{align-items:center}
.studio-tile-sample .studio-hook{position:static;font-size:16cqw;text-align:center;top:auto;left:auto;right:auto}
.studio-tile-sample .studio-hook--card{padding:6cqw 5cqw;font-size:12cqw}
.studio-tile-image{display:block;width:100%;height:auto;aspect-ratio:3/2;object-fit:cover}
.studio-tile-label{display:block;padding:6px 4px;font-size:12px;color:var(--ac-sub)}
.studio-tile[aria-checked=true] .studio-tile-label{color:var(--ac-ink)}
/* ---- whole-clip caption looks; approximate the libass force_style presets ---- */
.studio-caption{font-family:"PingFang SC","Noto Sans SC",system-ui,sans-serif;text-align:center;white-space:pre-wrap;overflow-wrap:anywhere}
.studio-caption--clean{color:#fff;font-weight:500;text-shadow:0 0 2px #000,0 0 2px #000,1px 1px 0 #000,-1px -1px 0 #000}
.studio-caption--bold{color:#fff;font-weight:800;font-size:1.3em;-webkit-text-stroke:.06em #000;paint-order:stroke fill;text-shadow:0 .08em .1em rgba(0,0,0,.6)}
.studio-caption--box{color:#fff;font-weight:500;background:rgba(0,0,0,.55);padding:.25em .6em;border-radius:.2em;display:inline-block}
.studio-caption--accent{color:#ffe500;font-weight:800;font-size:1.2em;-webkit-text-stroke:.06em #000;paint-order:stroke fill}
.studio-caption-overlay{position:absolute;left:6%;right:6%;bottom:14%;font-size:clamp(12px,4.6cqw,22px);line-height:1.35;pointer-events:none;text-align:center}
.studio-caption-overlay.studio-caption--box{left:50%;right:auto;transform:translateX(-50%);max-width:88%}
/* ---- editor layout: the whole left column (player, prompt, shots) stays in view while the
settings column scrolls with the page; if the left column is taller than the window it scrolls on its own ---- */
.studio-editor-main{min-width:0;position:sticky;top:84px;max-height:calc(100vh - 100px);overflow:auto;padding-right:2px}
.studio-editor-main .studio-prompt{margin-top:22px}
.studio-editor-main .studio-source-details{margin-top:22px}
+18 -3
View File
@@ -1,6 +1,19 @@
export type Goal = 'content' | 'highlight' | 'promo'
export type Language = 'source' | 'zh' | 'en' | 'ja'
export interface Scene { id: string; label: string; start: number; end: number; evidence: string }
export type SubtitleStyle = 'clean' | 'bold' | 'box' | 'accent'
export const subtitleStyles: { value: SubtitleStyle; label: string }[] = [{ value: 'clean', label: '简洁描边' }, { value: 'bold', label: '粗体大字' }, { value: 'box', label: '底色字幕条' }, { value: 'accent', label: '醒目黄字' }]
export interface SubtitleCue { start: number; end: number; text: string }
export interface FramingStatus { status: 'installed' | 'not_installed' | 'installing' | 'error'; progress: number; message: string; size_mb: number }
export interface AutoFrameResult { window_fraction: number; scenes: { id: string; crop_x: number | null; crop_track: CropPoint[] | null; faces: number; samples: number; switches: number }[] }
export interface CropPoint { start: number; crop_x: number }
export interface Scene { id: string; label: string; start: number; end: number; evidence: string; crop_x?: number | null; crop_track?: CropPoint[] | null }
/** Crop window position for a scene at `time` (absolute seconds): speaker track first, then static values. */
export function cropAt(scene: Scene | undefined, time: number, fallback = .5): number {
if (!scene) return fallback
const track = scene.crop_track || []
if (track.length) { const rel = time - scene.start; let x = track[0].crop_x; for (const p of track) { if (p.start <= rel) x = p.crop_x; else break } return x }
return scene.crop_x ?? fallback
}
export interface Candidate extends Scene { kind: 'visual' | 'legacy' }
export interface CandidateList { duration: number; candidates: Candidate[]; warnings: string[] }
export interface Draft {
@@ -8,7 +21,7 @@ export interface Draft {
aspect: 'original' | 'portrait' | 'landscape'; layout: 'fit' | 'crop' | 'blur'
crop_x?: number; title_style?: 'plain' | 'impact' | 'card' | 'comic' | 'neon' | 'arena' | 'editorial' | 'pixel' | 'frosted'
title_template_version?: 1 | 2 | 3 | 4 | 5 | 6; title_motion?: boolean; title_scale?: number; title_y?: number; title_accent?: string | null
subtitles: boolean; original_audio: boolean; revision: number; updated_at: string; origin: string
subtitles: boolean; subtitle_style?: SubtitleStyle; original_audio: boolean; revision: number; updated_at: string; origin: string
parent_draft_id?: string | null; parent_revision?: number | null
}
export interface RenderJob {
@@ -51,8 +64,10 @@ export function applyCandidate(draft: Draft, candidate: Candidate, target: numbe
return result
}
/** Phone-friendly defaults: fill the frame and use large captions. The title look only changes when there is an opening title. */
export function portraitDesign(draft: Draft): Draft {
return {...draft, aspect:'portrait', layout:'crop', crop_x:draft.crop_x ?? .5, title_style:'comic', title_template_version:6}
const withTitle = draft.hook.trim() ? { title_style: 'comic' as const, title_template_version: 6 as const } : {}
return {...draft, aspect:'portrait', layout:'crop', crop_x:draft.crop_x ?? .5, subtitle_style: 'bold', ...withTitle}
}
export interface ImportOptions {
+237 -30
View File
@@ -853,9 +853,6 @@
"保持原画幅": "Keep original aspect ratio",
"9:16 竖屏": "9:16 portrait",
"16:9 横屏": "16:9 landscape",
"返回导入": "Back to import",
"确认制作内容": "Confirm what to create",
"导入视频 → 识别与确认 → 开始制作": "Import video → Identify and confirm → Start production",
"制作已经开始": "Production has started",
"正在快速识别素材": "Quickly identifying the source",
"可进入项目查看进度。": "Open the project to see progress.",
@@ -941,37 +938,21 @@
"查看原片": "View source",
"播放成片": "Play exported video",
"渲染预览": "Render preview",
"改到满意,就导出。": "Make it yours, then export.",
"应用竖屏推荐": "Apply portrait preset",
"成片名称": "Video name",
"开头文字": "Opening text",
"可留空;在首镜头最多显示 4 秒": "Optional; shown for up to 4 seconds in the first scene",
"标题模板": "Title template",
"缩略图为设计参考,当前文案效果见预览。": "Thumbnails show the design. Preview displays your text.",
"调整文字样式": "Adjust text style",
"样式版本": "Style version",
"强调色": "Accent color",
"标题强调色": "Title accent color",
"文字大小": "Text size",
"文字位置": "Text position",
"开启入场动效": "Enable entrance animation",
"预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。": "Preview uses the actual text layer. Translation and animation appear in the render; manual line breaks are supported.",
"输出文字语言": "Output text language",
"选择翻译语言后,渲染时会翻译开头文字与所选原字幕。": "The opening text and selected source subtitles are translated during rendering.",
"烧录原字幕": "Burn in source subtitles",
"声音": "Audio",
"保留原声": "Keep original audio",
"画面设置": "Video framing",
"原画幅": "Original aspect ratio",
"构图": "Framing",
"完整画面 · 留边": "Full frame · Letterbox",
"完整画面 · 模糊背景": "Full frame · Blurred background",
"满屏取景": "Fill frame",
"取景位置 · 左右移动": "Framing position · Move left or right",
"取景位置": "Framing position",
"主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。": "Fill the frame. Adjust horizontally and check the character, obstacles and HUD.",
"当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。": "Keeping the full landscape frame adds bars. Choose Fill frame to fill portrait output.",
"保留原画面构图。": "Keep the source framing.",
"文案修改要求": "Copy editing instructions",
"告诉 AI 怎么改文案,例如:开头改成一个简短的问题": "Tell AI how to edit, e.g. open with a short question",
"改一版文案": "Rewrite copy",
@@ -997,10 +978,8 @@
"成片": "Video",
"格式": "Format",
"保持原尺寸": "Keep original dimensions",
"开头包装": "Opening style",
"原声": "Original audio",
"保留": "Keep",
"烧录已有字幕": "Burn in existing subtitles",
"当前版本已渲染完成,可以直接下载。": "This version is rendered and ready to download.",
"任务在后台继续,关闭面板不会取消渲染。": "Rendering continues in the background when you close this panel.",
"追加一个镜头": "Add a scene",
@@ -1100,21 +1079,13 @@
"制作任务未能启动,请重试确认;原素材与已有成片已保留": "Production could not start. Please confirm again; your source and existing exports have been preserved.",
"这个切片当前播放器无法解码,请下载后用系统播放器打开。": "This clip can't be decoded in the player. Download it and open it in a system player.",
"切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。": "Clip analysis turns off DeepSeek thinking mode so long reasoning traces are not billed as output. A long video is still several calls, about one per 30 minutes of subtitles and one per step. Refreshing the page does not run it again; starting processing again does.",
"分析方式": "Analysis method",
"本次分析方式": "Analysis for this import",
"默认分析方式": "Default analysis method",
"字幕分析 · 低成本": "Subtitle analysis · Lower cost",
"视觉分析": "Visual analysis",
"智能选择": "Smart selection",
"配置视觉模型不会自动切换分析方式。": "Configuring a visual model does not change your analysis preference.",
"仅分析字幕文本;无字幕时需要转写。": "Analyzes subtitle text only; transcription is needed if subtitles are missing.",
"发送抽样画面与文本,按模型服务商计费。": "Sends sampled frames and text. Your model provider may charge for usage.",
"允许付费视觉初筛": "Allow paid visual screening",
"关闭时不调用视觉初筛,确认后才开始正式分析。": "When off, no visual screening runs. Full analysis starts after confirmation.",
"分析偏好已保存": "Analysis preference saved",
"视觉模型不可用,请前往模型设置。": "Visual model unavailable. Open model settings.",
"按字幕链路处理,未调用视觉初筛;正式制作时使用已有字幕或转写,尚未验证语音内容。": "Uses the subtitle workflow without visual screening. Production uses existing subtitles or transcription; speech content has not been verified.",
"内容切片使用字幕分析,请调整分析方式或制作类型。": "Content clips use subtitle analysis. Adjust the analysis method or output types.",
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Subtitle promos make one additional text-model request for copy, billed by your provider. Review before use.",
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Usable subtitles found. Semantic editing is suggested; content quality has not been assessed and no model was called.",
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "No subtitles found. Transcription or supplied subtitles are needed; usable speech is not yet confirmed. You can select output types manually.",
@@ -1193,5 +1164,241 @@
"同一服务商": "Same provider",
"用其他服务生图…": "Use another image service…",
"生图服务": "Image service",
"和上面是同一家服务时可以留空,自动复用。": "Leave empty to reuse the key if it's the same service as above."
"和上面是同一家服务时可以留空,自动复用。": "Leave empty to reuse the key if it's the same service as above.",
"高光分析": "Highlight analysis",
"画面理解": "Visual understanding",
"请填写连接名称": "Enter a connection name",
"请填写完整的 HTTP(S) 接口地址": "Enter a complete HTTP(S) API URL",
"服务地址已改变,请重新填写 API Key": "The endpoint changed. Enter the API key again.",
"请先选择模型": "Select a model first",
"连接测试失败,请检查接口、密钥和模型": "Connection test failed. Check the endpoint, key and model.",
"AI 模型": "AI models",
"加载中…": "Loading…",
"模型服务": "Model services",
"选择模型服务": "Select a model service",
"添加模型服务…": "Add a model service…",
"自动获取模型列表,也可以直接输入模型 ID。": "Models are discovered automatically. You can also enter a model ID.",
"已从服务获取模型列表": "Model list fetched from the service",
"使用缓存的模型列表": "Using the cached model list",
"模型列表加载中…": "Loading model list…",
"刷新列表": "Refresh models",
"编辑服务": "Edit service",
"自定义模型能力": "Custom model capabilities",
"模型能力": "Model capability",
"已知模型自动识别;自定义型号可手动指定。": "Known models are identified automatically. You can specify custom model capabilities.",
"自动识别": "Detect automatically",
"测试会发起少量模型调用,按服务商计费。": "Tests make small model requests billed by your provider.",
"接入模型服务,为高光分析和封面生成分别选择模型。": "Connect model services and choose models independently for highlights and covers.",
"尚未使用": "Not in use",
"编辑": "Edit",
"使用中的服务需先更换模型引用才能移除": "Reassign models before removing a service in use",
"添加模型服务": "Add model service",
"使用字幕分析;没有字幕时需要先转写。": "Analyze subtitles. Transcription is needed when subtitles are missing.",
"可使用字幕和抽样画面分析,画面调用按服务商计费。": "Can analyze subtitles and sampled frames. Visual requests are billed by your provider.",
"尚未确认画面能力,当前使用字幕分析;自定义模型可在下方指定能力。": "Visual capability is not confirmed; subtitle analysis is used. Specify custom capabilities below.",
"封面方式": "Cover method",
"视频截帧": "Video frame",
"AI 生图": "AI image generation",
"生图失败时使用视频截帧;不会自动改用其他供应商。": "If image generation fails, use a video frame. Providers are never switched automatically.",
"单独指定画面理解模型": "Use a separate vision model",
"默认复用高光分析模型,也可以选择其他服务。": "Reuse the highlight model by default, or choose another service.",
"导入时允许画面初筛": "Allow visual screening on import",
"自动推荐制作类型时发送抽样画面,按服务商计费。": "Send sampled frames to recommend a workflow. Provider charges apply.",
"本地 Whisper;安装和下载操作立即执行。": "Local Whisper. Installation and download actions take effect immediately.",
"有未保存的更改": "Unsaved changes",
"当前配置": "Current configuration",
"保存设置": "Save settings",
"同一供应商可以添加多个账号;服务连接可被多个功能复用。": "Add multiple accounts for one provider. Connections can be shared across features.",
"应用到配置": "Apply to draft",
"修改此连接会影响:{{uses}}": "Changing this connection affects: {{uses}}",
"连接名称": "Connection name",
"例如:个人账号、本机服务": "For example: Personal account, Local server",
"供应商": "Provider",
"清除密钥": "Clear key",
"获取 API Key": "Get an API key",
"留空使用供应商默认地址": "Leave blank to use the provider default",
"生图接口设置": "Image API settings",
"自定义生图服务需与所选接口协议兼容。": "Custom image services must support the selected API protocol.",
"接口协议": "API protocol",
"使用供应商默认协议": "Use provider default",
"生图接口地址": "Image API URL",
"模型列表刷新失败,正在使用上次成功的列表": "Could not refresh models. Using the last successful list.",
"尚未获取服务模型列表,可以重试或手动输入模型 ID": "Could not fetch models. Retry or enter a model ID manually.",
"高光分析模型": "Highlight analysis model",
"画面理解模型": "Vision model",
"封面生图模型": "Cover image model",
"默认使用同一供应商,可在高级设置中单独配置。": "Uses the same provider by default. Customize it in advanced settings.",
"填写 API Key 后自动选择,也可手动输入模型名": "Selected automatically after entering your API key, or enter a model ID",
"选择供应商并填写 API Key,自动配置分析和封面模型;需要时再调整。": "Choose a provider and enter your API key. Analysis and cover models are selected automatically; adjust them if needed.",
"封面使用其他供应商": "Use another provider for covers",
"默认复用上面的供应商和 API Key。": "Uses the provider and API key above by default.",
"官方供应商": "Official providers",
"OpenAI 官方服务,接口地址已预设。": "Official OpenAI service. The API endpoint is preset.",
"自定义 OpenAI 兼容接口": "Custom OpenAI-compatible API",
"自定义兼容接口": "Custom compatible API",
"适用于 OpenRouter、第三方网关或自建服务。": "For OpenRouter, third-party gateways or self-hosted services.",
"接口设置": "API settings",
"修改接口地址后,请重新填写 API Key。": "Re-enter the API key after changing an endpoint.",
"选择或搜索模型,也可手动输入模型名": "Select or search models, or enter a model ID",
"公开目录预览,填写 API Key 后确认账号可用模型。": "Public catalog preview. Enter an API key to confirm models available to your account.",
"公开目录预览,填写 API Key 后确认账号可用模型。 当前展示缓存目录。": "Public catalog preview from cache. Enter an API key to confirm account availability.",
"账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。": "Could not load account models. Showing a reference catalog; check your key and refresh.",
"Whisper · 本地": "Whisper · Local",
"使用字幕分析;选择多模态模型后可同时理解画面。": "Analyzes subtitles. A multimodal model can also understand video frames.",
"使用独立的画面模型": "Use a separate visual model",
"保存设置后,新任务将使用这个模型。": "New tasks will use this model after you save.",
"共用 API Key,无需重复填写。": "Share the API key without entering it again.",
"准备本地模型": "Prepare local model",
"准备模型": "Prepare model",
"分析模型只能读文字时,可增加一个多模态模型来理解视频画面。": "Add a multimodal model for video frames when the analysis model only reads text.",
"在本机把音频转成字幕,不上传音频,无需 API Key。": "Transcribe audio locally. No audio uploads or API key required.",
"字幕转写": "Subtitle transcription",
"已就绪": "Ready",
"暂时无法读取本地模型状态。": "Local model status is temporarily unavailable.",
"本地模型管理": "Manage local models",
"本地转写组件": "Local transcription components",
"模型已就绪": "Model ready",
"正在下载模型…": "Downloading model…",
"正在准备组件…": "Preparing components…",
"自定义模型类型": "Custom model type",
"补充画面识别": "Add visual understanding",
"视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。": "Used when a video has no subtitles. Larger models generally improve accuracy but require more time and memory.",
"转写模型": "Transcription model",
"首次使用需下载模型和必要组件,之后可在本机转写。": "Download the model and required components once, then transcribe locally.",
"配置高光分析、封面生成和字幕转写使用的模型。": "Choose models for highlight analysis, cover generation, and subtitle transcription.",
"通过专属链接注册,可领取 $5 免费体验额度。": "Register through the dedicated link to claim $5 in free trial credit.",
"$5 免费体验额度": "$5 free trial credit",
"本地": "本地",
"已配置的独立画面模型": "Existing separate vision model",
"视频已有字幕时直接使用;没有字幕时才调用转写模型。": "Use existing subtitles when available; transcribe only when subtitles are missing.",
"改用高光分析模型识别画面": "Use the highlight model for vision",
"会发送抽样画面给多模态模型,费用通常高于仅文字分析。": "Sampled frames will be sent to the multimodal model. This usually costs more than text-only analysis.",
"该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。": "Subtitle timestamps are not integrated for this provider yet. Choose Alibaba Cloud, OpenAI, or local Whisper.",
"选择转写模型": "Select a transcription model",
"需要公网音频地址,暂未接入": "Requires a public audio URL; not integrated yet",
"实时转写接口,暂未接入": "Realtime API; not integrated yet",
"字幕时间戳接口尚未适配": "Subtitle timestamps are not integrated yet",
"模型目录可预览;实际可用性以账号权限和服务商开通状态为准。": "Preview the model catalog. Actual availability depends on your account permissions and activated services.",
"按下面三步完成设置。已有字幕可跳过第 1 步;封面可先使用视频截帧。": "Follow these three steps. Skip step 1 if you already have subtitles; video frames can serve as covers.",
"供应商提供 AI 服务,模型负责具体任务。同一供应商的密钥可以复用。": "Providers host AI services; models perform each task. You can reuse a key for the same provider.",
"把视频中的说话声转成字幕。视频已有字幕时不会调用,可以直接继续第 2 步。": "Turn speech into subtitles. If the video already has subtitles, skip to step 2.",
"必需。选择分析方式,再选择供应商、填写密钥;模型会自动推荐,也可以更换。": "Required. Choose an analysis method, then a provider and key. A model will be suggested; you can change it.",
"服务密钥(API Key)": "Service key (API Key)",
"在供应商网站创建密钥,复制后粘贴到右侧。": "Create a key on the provider website, then copy and paste it here.",
"请填写 API Key": "Enter an API key",
"已复用字幕转写的密钥,点击可修改": "Using the transcription key; click to edit",
"使用自定义型号": "Use custom model",
"可选。使用视频截帧无需配置模型,也不会产生生图费用。": "Optional. Video frames need no model and incur no image generation charges.",
"封面来源": "Cover source",
"AI 生成": "AI generation",
"保存后,返回项目导入视频即可开始。": "After saving, return to Projects and import a video to begin.",
"设置已保存": "Settings saved",
"首次使用,保存后生效": "First setup; save to apply",
"阿里云百炼": "Alibaba Cloud Model Studio",
"阿里云百炼官方服务,接口地址已预设。": "Alibaba Cloud Model Studio. The API endpoint is preset.",
"修改密钥": "Edit key",
"正在获取可用模型…": "Fetching available models…",
"已为你选好推荐模型,可随时更换。": "A recommended model is selected; change it any time.",
"填写 API Key 后自动选择推荐模型。": "A recommended model is picked automatically once the API key is in.",
"画面识别": "Frame analysis",
"视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。": "When a video has no subtitles, speech is transcribed first. By default this runs locally for free; no audio is uploaded.",
"转写方式": "Transcription",
"本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。": "Local transcription is free but downloads a model first; cloud transcription needs no download and is billed by usage.",
"将音频发送给所选服务转写,按服务商计费。": "Audio is sent to the selected service for transcription, billed by the provider.",
"封面使用其他服务": "Use another service for covers",
"默认与 AI 服务共用 API Key,无需重复填写。": "Shares the AI service key by default; nothing else to fill in.",
"{{name}} 没有生图模型,可以为封面单独选一家服务。": "{{name}} has no image model; pick a separate service for covers.",
"已自动选择推荐的生图模型,可更换。": "A recommended image model is selected; change it if you like.",
"参考视频画面": "Reference a video frame",
"开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。": "Sends one video frame to the cover model so the generated cover matches the content more closely.",
"AI 服务": "AI service",
"选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。": "Pick a service and paste its API key; the model is chosen for you. Transcription and covers reuse this key by default.",
"高级": "Advanced",
"先连接一个 AI 服务": "Connect an AI service first",
"选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。": "Pick a service and paste its API key to get started; the model is chosen for you. You can adjust everything later in Settings.",
"已选好模型:{{model}}": "Model selected: {{model}}",
"等待 API Key": "Waiting for an API key",
"更多选项": "More options",
"连接并保存": "Connect and save",
"已复用 AI 服务的密钥": "Reusing the AI service key",
"本机服务无需 API Key,保持默认地址即可。": "Local services need no API key; keep the default address.",
"推荐": "Recommended",
"请确认 API Key,或直接输入模型名": "Check the API key, or type a model name",
"请先连接 AI 服务,再导入视频。": "Connect an AI service before importing a video.",
"稍后再说": "Later",
"当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。": "The current model is text-only, so clips are cut from subtitles alone. For gameplay or content with little speech, switch to a multimodal model and turn this on.",
"抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。": "Samples a few frames to understand action and scenes. Recommended for gameplay or content with little speech — cuts are more accurate; slightly higher cost. Off sends subtitles only.",
"默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。": "By default the service above draws a titled cover; you can switch to a video frame instead.",
"请先选择一家 AI 服务": "Choose an AI service first",
"选择一家 AI 服务": "Choose an AI service",
"先选择一家 AI 服务": "Choose an AI service to begin",
"国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。": "Mainland-direct, international and free local models are all available; if unsure, start with “Recommended”.",
"先在上面选择一家 AI 服务,生图模型会自动选好。": "Choose an AI service above first; the image model is picked automatically.",
"示例": "Example",
"示例项目暂时不可用": "The example project is unavailable right now",
"还没有项目": "No projects yet",
"从上方导入一段视频,或者先用示例项目看看出片效果。": "Import a video above, or open the example project first to see what the output looks like.",
"用示例项目看看效果": "Open the example project",
"来源": "Source",
"成片设置": "Output settings",
"片头标题文字": "Opening title text",
"片头标题样式": "Opening title style",
"把字幕压进画面": "Show subtitles on the video",
"片头标题": "Opening title",
"字幕压进画面": "Subtitles on the video",
"把这段的字幕压进画面": "Show this segment’s subtitles on the video.",
"不显示": "Hide",
"片头约 4 秒显示标题文字": "Shows the title text for about 4 seconds at the start.",
"示例项目 · 原片只保留了三段": "Example project · source keeps only the three passages",
"一键竖屏": "Portrait preset",
"例如:一个问题,或一句结论": "For example: a question, or a one-line takeaway",
"片头样式": "Title style",
"入场动效": "Entrance animation",
"翻译与动效以渲染结果为准;支持手动换行。": "Translation and animation are final in the rendered video; manual line breaks are supported.",
"选择翻译语言后,渲染时会翻译片头文字与字幕。": "Choosing a language translates the opening title and subtitles when rendering.",
"关闭后成片静音。": "Off mutes the final video.",
"保留完整画面,两侧留边或模糊背景。": "Keeps the full picture, with bars or a blurred background on the sides.",
"满屏": "Fill",
"模糊背景": "Blurred background",
"留边": "Bars",
"发布时按平台生成带标题的封面,也可以用视频截帧。": "A titled cover is generated per platform when publishing; a video frame works too.",
"导出成片后,在发布页生成带标题的封面。": "After exporting, generate a titled cover on the publish page.",
"去生成": "Generate",
"渲染完成后,在发布页按平台生成带标题的封面。": "Once rendered, generate a titled cover per platform on the publish page.",
"‹ 项目": "‹ Projects",
"确认要做什么": "Confirm what to make",
"AI 先看一遍素材给出建议;你确认后才开始正式剪辑。": "AI reviews the footage and suggests a plan; editing only starts after you confirm.",
"分析方式": "Analysis",
"会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。": "Samples a few frames alongside the text for a better read on action and scenes; billed by the model provider.",
"当前模型不支持画面分析;换一个多模态模型后可开启。": "The current model can’t analyse frames; switch to a multimodal model to enable this.",
"只分析字幕文本,成本最低;没有字幕时会先转写。": "Analyses subtitle text only — the cheapest option; speech is transcribed first when there are no subtitles.",
"字幕 + 画面": "Subtitles + frames",
"「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。": "“Content clips” use subtitles only; to analyse frames, also select Highlights or Promo.",
"仅字幕": "Subtitles only",
"当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。": "The current model can’t analyse frames. Choose “Subtitles only”, or switch to a multimodal model in Settings.",
"按字幕找到高潮句,保留前后关键过程": "Find peak moments from the subtitles and keep the key run-up",
"把字幕压进画面,整条成片统一样式。": "Show subtitles on the video, one look for the whole clip.",
"字幕样式": "Subtitle style",
"这里是字幕效果": "Subtitle preview",
"简洁描边": "Clean outline",
"粗体大字": "Bold large",
"底色字幕条": "Caption bar",
"醒目黄字": "Bold yellow",
"主体铺满画面,自动对准说话的人。": "The subject fills the frame, centred on whoever is speaking.",
"取景": "Framing",
"首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。": "First use downloads the person-detection component (about {{size}} MB); after that, framing follows the speaker automatically.",
"正在下载人物识别组件…": "Downloading the person-detection component…",
"正在识别人物位置…": "Locating people in the frame…",
"没有识别到人物,请手动调整取景位置。": "No one was detected; adjust the framing manually.",
"拖动调整当前镜头的取景位置。": "Drag to adjust the framing of the current shot.",
"下载并自动取景": "Download and auto-frame",
"重新自动取景": "Auto-frame again",
"自动取景": "Auto-frame",
"片头文字(可选)": "Opening text (optional)",
"片头文字": "Opening text",
"在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。": "Large text over the first shot for up to 4 seconds — good for gameplay and promos; leave empty for interviews and explainers.",
"缩略图为设计参考,实际文字效果见左侧预览。": "Thumbnails are design references; the actual text shows in the preview on the left.",
"片头": "Title",
"已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。": "Framing follows the speaker ({{framed}}/{{total}} shots, {{switches}} switches). Dragging the slider switches to a fixed frame.",
"正在跟随说话人取景;拖动滑块会改为固定取景。": "Framing follows the speaker; dragging the slider switches to a fixed frame."
}
+237 -30
View File
@@ -853,9 +853,6 @@
"保持原画幅": "Conservar la proporción original",
"9:16 竖屏": "9:16 vertical",
"16:9 横屏": "16:9 horizontal",
"返回导入": "Volver a importar",
"确认制作内容": "Confirma qué crear",
"导入视频 → 识别与确认 → 开始制作": "Importar vídeo → Identificar y confirmar → Crear",
"制作已经开始": "La creación ha comenzado",
"正在快速识别素材": "Identificando el material",
"可进入项目查看进度。": "Abre el proyecto para ver el progreso.",
@@ -941,37 +938,21 @@
"查看原片": "Ver original",
"播放成片": "Reproducir vídeo exportado",
"渲染预览": "Renderizar vista previa",
"改到满意,就导出。": "Ajústalo a tu gusto y exporta.",
"应用竖屏推荐": "Aplicar ajuste vertical",
"成片名称": "Nombre del vídeo",
"开头文字": "Texto inicial",
"可留空;在首镜头最多显示 4 秒": "Opcional; se muestra hasta 4 segundos en la primera escena",
"标题模板": "Plantilla de título",
"缩略图为设计参考,当前文案效果见预览。": "Las miniaturas muestran el diseño. La vista previa muestra tu texto.",
"调整文字样式": "Ajustar estilo del texto",
"样式版本": "Versión del estilo",
"强调色": "Color de acento",
"标题强调色": "Color de acento del título",
"文字大小": "Tamaño del texto",
"文字位置": "Posición del texto",
"开启入场动效": "Activar animación de entrada",
"预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。": "La vista previa usa la capa real. La traducción y animación aparecen al renderizar; admite saltos de línea.",
"输出文字语言": "Idioma del texto de salida",
"选择翻译语言后,渲染时会翻译开头文字与所选原字幕。": "El texto inicial y los subtítulos seleccionados se traducen al renderizar.",
"烧录原字幕": "Incrustar subtítulos originales",
"声音": "Audio",
"保留原声": "Conservar audio original",
"画面设置": "Ajustes de imagen",
"原画幅": "Proporción original",
"构图": "Encuadre",
"完整画面 · 留边": "Imagen completa · Con bandas",
"完整画面 · 模糊背景": "Imagen completa · Fondo difuminado",
"满屏取景": "Llenar encuadre",
"取景位置 · 左右移动": "Posición · Mover a izquierda o derecha",
"取景位置": "Posición del encuadre",
"主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。": "Llena el encuadre. Ajusta horizontalmente y comprueba el personaje, los obstáculos y el HUD.",
"当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。": "Conservar el formato horizontal añade bandas. Elige Llenar encuadre para llenar el vertical.",
"保留原画面构图。": "Conservar el encuadre original.",
"文案修改要求": "Instrucciones para editar el texto",
"告诉 AI 怎么改文案,例如:开头改成一个简短的问题": "Dile a la IA cómo cambiarlo, p. ej. empezar con una pregunta breve",
"改一版文案": "Reescribir texto",
@@ -997,10 +978,8 @@
"成片": "Vídeo",
"格式": "Formato",
"保持原尺寸": "Conservar tamaño original",
"开头包装": "Estilo de apertura",
"原声": "Audio original",
"保留": "Conservar",
"烧录已有字幕": "Incrustar subtítulos existentes",
"当前版本已渲染完成,可以直接下载。": "Esta versión está renderizada y lista para descargar.",
"任务在后台继续,关闭面板不会取消渲染。": "El renderizado continúa en segundo plano al cerrar este panel.",
"追加一个镜头": "Añadir una escena",
@@ -1100,21 +1079,13 @@
"制作任务未能启动,请重试确认;原素材与已有成片已保留": "No se pudo iniciar la creación. Confirma de nuevo; el original y las exportaciones existentes se han conservado.",
"这个切片当前播放器无法解码,请下载后用系统播放器打开。": "Este clip no se puede decodificar en el reproductor. Descárgalo y ábrelo con el reproductor del sistema.",
"切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。": "El análisis de clips desactiva el modo de razonamiento de DeepSeek para no facturar trazas largas como salida. Un vídeo largo sigue siendo varias llamadas, unas por cada 30 minutos de subtítulos y una por paso. Actualizar la página no lo vuelve a ejecutar; volver a iniciar el procesamiento sí.",
"分析方式": "Método de análisis",
"本次分析方式": "Análisis de esta importación",
"默认分析方式": "Método predeterminado",
"字幕分析 · 低成本": "Subtítulos · Menor coste",
"视觉分析": "Análisis visual",
"智能选择": "Selección inteligente",
"配置视觉模型不会自动切换分析方式。": "Configurar un modelo visual no cambia el método de análisis.",
"仅分析字幕文本;无字幕时需要转写。": "Solo analiza subtítulos; si faltan, se necesita transcripción.",
"发送抽样画面与文本,按模型服务商计费。": "Envía fotogramas y texto. El proveedor puede cobrar por el uso.",
"允许付费视觉初筛": "Permitir evaluación visual de pago",
"关闭时不调用视觉初筛,确认后才开始正式分析。": "Desactivado: sin evaluación visual. El análisis completo comienza tras confirmar.",
"分析偏好已保存": "Preferencia guardada",
"视觉模型不可用,请前往模型设置。": "Modelo visual no disponible. Abre los ajustes del modelo.",
"按字幕链路处理,未调用视觉初筛;正式制作时使用已有字幕或转写,尚未验证语音内容。": "Usa subtítulos sin evaluación visual. La producción usará subtítulos o transcripción; el habla aún no se ha verificado.",
"内容切片使用字幕分析,请调整分析方式或制作类型。": "Los clips de contenido usan subtítulos. Ajusta el método o los tipos.",
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Las promociones con subtítulos hacen una petición adicional al modelo de texto, facturada por el proveedor. Revisa antes de usar.",
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Se encontraron subtítulos utilizables. Se sugiere edición semántica; no se evaluó la calidad ni se llamó a un modelo.",
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "No hay subtítulos. Se necesita transcripción o un archivo; aún no se confirmó habla utilizable. Puedes elegir tipos manualmente.",
@@ -1193,5 +1164,241 @@
"同一服务商": "Mismo proveedor",
"用其他服务生图…": "Usar otro servicio de imágenes…",
"生图服务": "Servicio de imágenes",
"和上面是同一家服务时可以留空,自动复用。": "Déjala vacía para reutilizar la clave si es el mismo servicio de arriba."
"和上面是同一家服务时可以留空,自动复用。": "Déjala vacía para reutilizar la clave si es el mismo servicio de arriba.",
"高光分析": "Highlight analysis",
"画面理解": "Visual understanding",
"请填写连接名称": "Enter a connection name",
"请填写完整的 HTTP(S) 接口地址": "Enter a complete HTTP(S) API URL",
"服务地址已改变,请重新填写 API Key": "The endpoint changed. Enter the API key again.",
"请先选择模型": "Select a model first",
"连接测试失败,请检查接口、密钥和模型": "Connection test failed. Check the endpoint, key and model.",
"AI 模型": "AI models",
"加载中…": "Loading…",
"模型服务": "Model services",
"选择模型服务": "Select a model service",
"添加模型服务…": "Add a model service…",
"自动获取模型列表,也可以直接输入模型 ID。": "Models are discovered automatically. You can also enter a model ID.",
"已从服务获取模型列表": "Model list fetched from the service",
"使用缓存的模型列表": "Using the cached model list",
"模型列表加载中…": "Loading model list…",
"刷新列表": "Refresh models",
"编辑服务": "Edit service",
"自定义模型能力": "Custom model capabilities",
"模型能力": "Model capability",
"已知模型自动识别;自定义型号可手动指定。": "Known models are identified automatically. You can specify custom model capabilities.",
"自动识别": "Detect automatically",
"测试会发起少量模型调用,按服务商计费。": "Tests make small model requests billed by your provider.",
"接入模型服务,为高光分析和封面生成分别选择模型。": "Connect model services and choose models independently for highlights and covers.",
"尚未使用": "Not in use",
"编辑": "Edit",
"使用中的服务需先更换模型引用才能移除": "Reassign models before removing a service in use",
"添加模型服务": "Add model service",
"使用字幕分析;没有字幕时需要先转写。": "Analyze subtitles. Transcription is needed when subtitles are missing.",
"可使用字幕和抽样画面分析,画面调用按服务商计费。": "Can analyze subtitles and sampled frames. Visual requests are billed by your provider.",
"尚未确认画面能力,当前使用字幕分析;自定义模型可在下方指定能力。": "Visual capability is not confirmed; subtitle analysis is used. Specify custom capabilities below.",
"封面方式": "Cover method",
"视频截帧": "Video frame",
"AI 生图": "AI image generation",
"生图失败时使用视频截帧;不会自动改用其他供应商。": "If image generation fails, use a video frame. Providers are never switched automatically.",
"单独指定画面理解模型": "Use a separate vision model",
"默认复用高光分析模型,也可以选择其他服务。": "Reuse the highlight model by default, or choose another service.",
"导入时允许画面初筛": "Allow visual screening on import",
"自动推荐制作类型时发送抽样画面,按服务商计费。": "Send sampled frames to recommend a workflow. Provider charges apply.",
"本地 Whisper;安装和下载操作立即执行。": "Local Whisper. Installation and download actions take effect immediately.",
"有未保存的更改": "Unsaved changes",
"当前配置": "Current configuration",
"保存设置": "Save settings",
"同一供应商可以添加多个账号;服务连接可被多个功能复用。": "Add multiple accounts for one provider. Connections can be shared across features.",
"应用到配置": "Apply to draft",
"修改此连接会影响:{{uses}}": "Changing this connection affects: {{uses}}",
"连接名称": "Connection name",
"例如:个人账号、本机服务": "For example: Personal account, Local server",
"供应商": "Provider",
"清除密钥": "Clear key",
"获取 API Key": "Get an API key",
"留空使用供应商默认地址": "Leave blank to use the provider default",
"生图接口设置": "Image API settings",
"自定义生图服务需与所选接口协议兼容。": "Custom image services must support the selected API protocol.",
"接口协议": "API protocol",
"使用供应商默认协议": "Use provider default",
"生图接口地址": "Image API URL",
"模型列表刷新失败,正在使用上次成功的列表": "Could not refresh models. Using the last successful list.",
"尚未获取服务模型列表,可以重试或手动输入模型 ID": "Could not fetch models. Retry or enter a model ID manually.",
"高光分析模型": "Highlight analysis model",
"画面理解模型": "Vision model",
"封面生图模型": "Cover image model",
"默认使用同一供应商,可在高级设置中单独配置。": "Uses the same provider by default. Customize it in advanced settings.",
"填写 API Key 后自动选择,也可手动输入模型名": "Selected automatically after entering your API key, or enter a model ID",
"选择供应商并填写 API Key,自动配置分析和封面模型;需要时再调整。": "Choose a provider and enter your API key. Analysis and cover models are selected automatically; adjust them if needed.",
"封面使用其他供应商": "Use another provider for covers",
"默认复用上面的供应商和 API Key。": "Uses the provider and API key above by default.",
"官方供应商": "Official providers",
"OpenAI 官方服务,接口地址已预设。": "Official OpenAI service. The API endpoint is preset.",
"自定义 OpenAI 兼容接口": "Custom OpenAI-compatible API",
"自定义兼容接口": "Custom compatible API",
"适用于 OpenRouter、第三方网关或自建服务。": "For OpenRouter, third-party gateways or self-hosted services.",
"接口设置": "API settings",
"修改接口地址后,请重新填写 API Key。": "Re-enter the API key after changing an endpoint.",
"选择或搜索模型,也可手动输入模型名": "Select or search models, or enter a model ID",
"公开目录预览,填写 API Key 后确认账号可用模型。": "Public catalog preview. Enter an API key to confirm models available to your account.",
"公开目录预览,填写 API Key 后确认账号可用模型。 当前展示缓存目录。": "Public catalog preview from cache. Enter an API key to confirm account availability.",
"账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。": "Could not load account models. Showing a reference catalog; check your key and refresh.",
"Whisper · 本地": "Whisper · Local",
"使用字幕分析;选择多模态模型后可同时理解画面。": "Analyzes subtitles. A multimodal model can also understand video frames.",
"使用独立的画面模型": "Use a separate visual model",
"保存设置后,新任务将使用这个模型。": "New tasks will use this model after you save.",
"共用 API Key,无需重复填写。": "Share the API key without entering it again.",
"准备本地模型": "Prepare local model",
"准备模型": "Prepare model",
"分析模型只能读文字时,可增加一个多模态模型来理解视频画面。": "Add a multimodal model for video frames when the analysis model only reads text.",
"在本机把音频转成字幕,不上传音频,无需 API Key。": "Transcribe audio locally. No audio uploads or API key required.",
"字幕转写": "Subtitle transcription",
"已就绪": "Ready",
"暂时无法读取本地模型状态。": "Local model status is temporarily unavailable.",
"本地模型管理": "Manage local models",
"本地转写组件": "Local transcription components",
"模型已就绪": "Model ready",
"正在下载模型…": "Downloading model…",
"正在准备组件…": "Preparing components…",
"自定义模型类型": "Custom model type",
"补充画面识别": "Add visual understanding",
"视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。": "Used when a video has no subtitles. Larger models generally improve accuracy but require more time and memory.",
"转写模型": "Transcription model",
"首次使用需下载模型和必要组件,之后可在本机转写。": "Download the model and required components once, then transcribe locally.",
"配置高光分析、封面生成和字幕转写使用的模型。": "Choose models for highlight analysis, cover generation, and subtitle transcription.",
"通过专属链接注册,可领取 $5 免费体验额度。": "Register through the dedicated link to claim $5 in free trial credit.",
"$5 免费体验额度": "$5 free trial credit",
"本地": "本地",
"已配置的独立画面模型": "Existing separate vision model",
"视频已有字幕时直接使用;没有字幕时才调用转写模型。": "Use existing subtitles when available; transcribe only when subtitles are missing.",
"改用高光分析模型识别画面": "Use the highlight model for vision",
"会发送抽样画面给多模态模型,费用通常高于仅文字分析。": "Sampled frames will be sent to the multimodal model. This usually costs more than text-only analysis.",
"该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。": "Subtitle timestamps are not integrated for this provider yet. Choose Alibaba Cloud, OpenAI, or local Whisper.",
"选择转写模型": "Select a transcription model",
"需要公网音频地址,暂未接入": "Requires a public audio URL; not integrated yet",
"实时转写接口,暂未接入": "Realtime API; not integrated yet",
"字幕时间戳接口尚未适配": "Subtitle timestamps are not integrated yet",
"模型目录可预览;实际可用性以账号权限和服务商开通状态为准。": "Preview the model catalog. Actual availability depends on your account permissions and activated services.",
"按下面三步完成设置。已有字幕可跳过第 1 步;封面可先使用视频截帧。": "Follow these three steps. Skip step 1 if you already have subtitles; video frames can serve as covers.",
"供应商提供 AI 服务,模型负责具体任务。同一供应商的密钥可以复用。": "Providers host AI services; models perform each task. You can reuse a key for the same provider.",
"把视频中的说话声转成字幕。视频已有字幕时不会调用,可以直接继续第 2 步。": "Turn speech into subtitles. If the video already has subtitles, skip to step 2.",
"必需。选择分析方式,再选择供应商、填写密钥;模型会自动推荐,也可以更换。": "Required. Choose an analysis method, then a provider and key. A model will be suggested; you can change it.",
"服务密钥(API Key)": "Service key (API Key)",
"在供应商网站创建密钥,复制后粘贴到右侧。": "Create a key on the provider website, then copy and paste it here.",
"请填写 API Key": "Enter an API key",
"已复用字幕转写的密钥,点击可修改": "Using the transcription key; click to edit",
"使用自定义型号": "Use custom model",
"可选。使用视频截帧无需配置模型,也不会产生生图费用。": "Optional. Video frames need no model and incur no image generation charges.",
"封面来源": "Cover source",
"AI 生成": "AI generation",
"保存后,返回项目导入视频即可开始。": "After saving, return to Projects and import a video to begin.",
"设置已保存": "Settings saved",
"首次使用,保存后生效": "First setup; save to apply",
"阿里云百炼": "Alibaba Cloud Model Studio",
"阿里云百炼官方服务,接口地址已预设。": "Alibaba Cloud Model Studio. The API endpoint is preset.",
"修改密钥": "Edit key",
"正在获取可用模型…": "Obteniendo modelos disponibles…",
"已为你选好推荐模型,可随时更换。": "Se eligió un modelo recomendado; puedes cambiarlo cuando quieras.",
"填写 API Key 后自动选择推荐模型。": "Al introducir la clave API se elige automáticamente un modelo recomendado.",
"画面识别": "Análisis de imagen",
"视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。": "Si el vídeo no tiene subtítulos, primero se transcribe la voz. Por defecto se hace en tu equipo sin coste; no se sube audio.",
"转写方式": "Transcripción",
"本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。": "La transcripción local es gratis pero descarga un modelo la primera vez; la de nube no descarga nada y se cobra por uso.",
"将音频发送给所选服务转写,按服务商计费。": "El audio se envía al servicio elegido para transcribirlo; lo factura el proveedor.",
"封面使用其他服务": "Usar otro servicio para portadas",
"默认与 AI 服务共用 API Key,无需重复填写。": "Por defecto comparte la clave del servicio de IA; no hay que rellenar nada más.",
"{{name}} 没有生图模型,可以为封面单独选一家服务。": "{{name}} no tiene modelo de imagen; elige otro servicio para las portadas.",
"已自动选择推荐的生图模型,可更换。": "Se eligió un modelo de imagen recomendado; puedes cambiarlo.",
"参考视频画面": "Usar un fotograma como referencia",
"开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。": "Envía un fotograma al modelo de portada para que el resultado se parezca más al contenido.",
"AI 服务": "Servicio de IA",
"选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。": "Elige un servicio y pega su clave API; el modelo se elige por ti. La transcripción y las portadas reutilizan esta clave por defecto.",
"高级": "Avanzado",
"先连接一个 AI 服务": "Conecta primero un servicio de IA",
"选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。": "Elige un servicio y pega su clave API para empezar; el modelo se elige por ti. Podrás ajustarlo después en Ajustes.",
"已选好模型:{{model}}": "Modelo elegido: {{model}}",
"等待 API Key": "Esperando la clave API",
"更多选项": "Más opciones",
"连接并保存": "Conectar y guardar",
"已复用 AI 服务的密钥": "Reutilizando la clave del servicio de IA",
"本机服务无需 API Key,保持默认地址即可。": "Los servicios locales no necesitan clave API; deja la dirección por defecto.",
"推荐": "Recomendado",
"请确认 API Key,或直接输入模型名": "Comprueba la clave API o escribe un nombre de modelo",
"请先连接 AI 服务,再导入视频。": "Conecta un servicio de IA antes de importar un vídeo.",
"稍后再说": "Más tarde",
"当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。": "El modelo actual solo admite texto, así que los clips se cortan solo con los subtítulos. Para gameplay o contenido con poca voz, cambia a un modelo multimodal y actívalo.",
"抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。": "Muestrea algunos fotogramas para entender acciones y escenas. Recomendado para gameplay o contenido con poca voz: cortes más precisos, coste algo mayor. Desactivado, solo se envían subtítulos.",
"默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。": "Por defecto el servicio de arriba dibuja una portada con título; también puedes usar un fotograma del vídeo.",
"请先选择一家 AI 服务": "Elige primero un servicio de IA",
"选择一家 AI 服务": "Elige un servicio de IA",
"先选择一家 AI 服务": "Elige un servicio de IA para empezar",
"国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。": "Puedes elegir servicios de China, internacionales o modelos locales gratuitos; si no estás seguro, empieza por “Recomendado”.",
"先在上面选择一家 AI 服务,生图模型会自动选好。": "Elige primero un servicio de IA arriba; el modelo de imagen se elige automáticamente.",
"示例": "Ejemplo",
"示例项目暂时不可用": "El proyecto de ejemplo no está disponible ahora",
"还没有项目": "Aún no hay proyectos",
"从上方导入一段视频,或者先用示例项目看看出片效果。": "Importa un vídeo arriba o abre primero el proyecto de ejemplo para ver el resultado.",
"用示例项目看看效果": "Ver el proyecto de ejemplo",
"来源": "Fuente",
"成片设置": "Ajustes del vídeo final",
"片头标题文字": "Texto del título inicial",
"片头标题样式": "Estilo del título inicial",
"把字幕压进画面": "Mostrar subtítulos sobre el vídeo",
"片头标题": "Título inicial",
"字幕压进画面": "Subtítulos sobre el vídeo",
"把这段的字幕压进画面": "Muestra los subtítulos de este segmento sobre el vídeo.",
"不显示": "No mostrar",
"片头约 4 秒显示标题文字": "Muestra el texto del título unos 4 segundos al inicio.",
"示例项目 · 原片只保留了三段": "Proyecto de ejemplo · el vídeo original solo conserva tres pasajes",
"一键竖屏": "Vertical en un clic",
"例如:一个问题,或一句结论": "Por ejemplo: una pregunta o una conclusión en una línea",
"片头样式": "Estilo del título",
"入场动效": "Animación de entrada",
"翻译与动效以渲染结果为准;支持手动换行。": "La traducción y la animación se ven en el vídeo renderizado; se admiten saltos de línea manuales.",
"选择翻译语言后,渲染时会翻译片头文字与字幕。": "Al elegir un idioma, el título inicial y los subtítulos se traducen al renderizar.",
"关闭后成片静音。": "Desactivado, el vídeo final queda sin sonido.",
"保留完整画面,两侧留边或模糊背景。": "Mantiene toda la imagen, con bandas o fondo difuminado a los lados.",
"满屏": "Rellenar",
"模糊背景": "Fondo difuminado",
"留边": "Bandas",
"发布时按平台生成带标题的封面,也可以用视频截帧。": "Al publicar se genera una portada con título por plataforma; también sirve un fotograma.",
"导出成片后,在发布页生成带标题的封面。": "Tras exportar, genera una portada con título en la página de publicación.",
"去生成": "Generar",
"渲染完成后,在发布页按平台生成带标题的封面。": "Una vez renderizado, genera una portada con título por plataforma en la página de publicación.",
"‹ 项目": "‹ Proyectos",
"确认要做什么": "Confirma qué crear",
"AI 先看一遍素材给出建议;你确认后才开始正式剪辑。": "La IA revisa el material y propone un plan; la edición empieza solo cuando confirmas.",
"分析方式": "Análisis",
"会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。": "Analiza también algunos fotogramas para entender mejor acciones y escenas; lo factura el proveedor del modelo.",
"当前模型不支持画面分析;换一个多模态模型后可开启。": "El modelo actual no analiza imágenes; cambia a un modelo multimodal para activarlo.",
"只分析字幕文本,成本最低;没有字幕时会先转写。": "Analiza solo el texto de los subtítulos, la opción más barata; sin subtítulos, primero se transcribe.",
"字幕 + 画面": "Subtítulos + imágenes",
"「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。": "“Clips de contenido” solo usa subtítulos; para analizar imágenes, marca también Momentos destacados o Promo.",
"仅字幕": "Solo subtítulos",
"当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。": "El modelo actual no analiza imágenes. Elige “Solo subtítulos” o cambia a un modelo multimodal en Ajustes.",
"按字幕找到高潮句,保留前后关键过程": "Encuentra los momentos clave en los subtítulos y conserva el contexto",
"把字幕压进画面,整条成片统一样式。": "Muestra los subtítulos sobre el vídeo con un mismo estilo en todo el clip.",
"字幕样式": "Estilo de subtítulos",
"这里是字幕效果": "Vista previa del subtítulo",
"简洁描边": "Contorno limpio",
"粗体大字": "Negrita grande",
"底色字幕条": "Barra de subtítulo",
"醒目黄字": "Amarillo llamativo",
"主体铺满画面,自动对准说话的人。": "El sujeto llena el encuadre, centrado en quien habla.",
"取景": "Encuadre",
"首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。": "La primera vez se descarga el componente de detección de personas (unos {{size}} MB); después el encuadre sigue automáticamente a quien habla.",
"正在下载人物识别组件…": "Descargando el componente de detección de personas…",
"正在识别人物位置…": "Localizando personas en la imagen…",
"没有识别到人物,请手动调整取景位置。": "No se detectó a nadie; ajusta el encuadre manualmente.",
"拖动调整当前镜头的取景位置。": "Arrastra para ajustar el encuadre del plano actual.",
"下载并自动取景": "Descargar y encuadrar",
"重新自动取景": "Volver a encuadrar",
"自动取景": "Encuadre automático",
"片头文字(可选)": "Texto inicial (opcional)",
"片头文字": "Texto inicial",
"在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。": "Texto grande sobre el primer plano hasta 4 segundos; útil para gameplay y promos, déjalo vacío en entrevistas y explicaciones.",
"缩略图为设计参考,实际文字效果见左侧预览。": "Las miniaturas son referencias de diseño; el texto real se ve en la vista previa.",
"片头": "Título",
"已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。": "El encuadre sigue a quien habla ({{framed}}/{{total}} planos, {{switches}} cambios). Mover el control lo fija.",
"正在跟随说话人取景;拖动滑块会改为固定取景。": "El encuadre sigue a quien habla; mover el control lo fija."
}
+237 -30
View File
@@ -853,9 +853,6 @@
"保持原画幅": "Conserver le format d’origine",
"9:16 竖屏": "9:16 vertical",
"16:9 横屏": "16:9 horizontal",
"返回导入": "Retour à l’import",
"确认制作内容": "Confirmer les contenus à créer",
"导入视频 → 识别与确认 → 开始制作": "Importer → Identifier et confirmer → Créer",
"制作已经开始": "La création a commencé",
"正在快速识别素材": "Identification rapide de la source",
"可进入项目查看进度。": "Ouvrez le projet pour suivre l’avancement.",
@@ -941,37 +938,21 @@
"查看原片": "Voir la source",
"播放成片": "Lire la vidéo exportée",
"渲染预览": "Calculer l’aperçu",
"改到满意,就导出。": "Ajustez à votre goût, puis exportez.",
"应用竖屏推荐": "Appliquer le réglage vertical",
"成片名称": "Nom de la vidéo",
"开头文字": "Texte d’accroche",
"可留空;在首镜头最多显示 4 秒": "Facultatif ; affiché jusqu’à 4 secondes dans la première scène",
"标题模板": "Modèle de titre",
"缩略图为设计参考,当前文案效果见预览。": "Les miniatures illustrent le style. L’aperçu affiche votre texte.",
"调整文字样式": "Ajuster le style du texte",
"样式版本": "Version du style",
"强调色": "Couleur d’accent",
"标题强调色": "Couleur d’accent du titre",
"文字大小": "Taille du texte",
"文字位置": "Position du texte",
"开启入场动效": "Activer l’animation d’entrée",
"预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。": "L’aperçu utilise le vrai calque de texte. Traduction et animation apparaissent au rendu ; retours à la ligne acceptés.",
"输出文字语言": "Langue du texte exporté",
"选择翻译语言后,渲染时会翻译开头文字与所选原字幕。": "Le texte d’accroche et les sous-titres sélectionnés sont traduits au rendu.",
"烧录原字幕": "Incruster les sous-titres source",
"声音": "Audio",
"保留原声": "Conserver le son original",
"画面设置": "Réglages de l’image",
"原画幅": "Format d’origine",
"构图": "Cadrage",
"完整画面 · 留边": "Image entière · Bandes",
"完整画面 · 模糊背景": "Image entière · Fond flouté",
"满屏取景": "Remplir le cadre",
"取景位置 · 左右移动": "Position · Déplacer à gauche ou à droite",
"取景位置": "Position du cadrage",
"主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。": "Remplissez le cadre. Ajustez horizontalement et vérifiez personnage, obstacles et HUD.",
"当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。": "Conserver l’image horizontale ajoute des bandes. Choisissez Remplir le cadre pour une sortie verticale pleine.",
"保留原画面构图。": "Conserver le cadrage original.",
"文案修改要求": "Consignes de réécriture",
"告诉 AI 怎么改文案,例如:开头改成一个简短的问题": "Indiquez la modification à l’IA, ex. : commencer par une courte question",
"改一版文案": "Réécrire le texte",
@@ -997,10 +978,8 @@
"成片": "Vidéo",
"格式": "Format",
"保持原尺寸": "Conserver les dimensions d’origine",
"开头包装": "Style d’accroche",
"原声": "Son original",
"保留": "Conserver",
"烧录已有字幕": "Incruster les sous-titres existants",
"当前版本已渲染完成,可以直接下载。": "Cette version est prête à télécharger.",
"任务在后台继续,关闭面板不会取消渲染。": "Le rendu continue en arrière-plan si vous fermez ce panneau.",
"追加一个镜头": "Ajouter une scène",
@@ -1100,21 +1079,13 @@
"制作任务未能启动,请重试确认;原素材与已有成片已保留": "La création n’a pas pu démarrer. Confirmez à nouveau ; la source et les exports existants ont été conservés.",
"这个切片当前播放器无法解码,请下载后用系统播放器打开。": "Ce clip ne peut pas être décodé dans le lecteur. Téléchargez-le et ouvrez-le avec le lecteur du système.",
"切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。": "L'analyse des extraits désactive le mode réflexion de DeepSeek, pour ne pas facturer de longues traces comme sortie. Une longue vidéo reste plusieurs appels, environ un par 30 minutes de sous-titres et un par étape. Actualiser la page ne relance pas l'analyse ; relancer le traitement, si.",
"分析方式": "Méthode d’analyse",
"本次分析方式": "Analyse de cet import",
"默认分析方式": "Méthode par défaut",
"字幕分析 · 低成本": "Sous-titres · Coût réduit",
"视觉分析": "Analyse visuelle",
"智能选择": "Sélection intelligente",
"配置视觉模型不会自动切换分析方式。": "Configurer un modèle visuel ne change pas votre méthode d’analyse.",
"仅分析字幕文本;无字幕时需要转写。": "Analyse le texte des sous-titres ; sinon, une transcription est nécessaire.",
"发送抽样画面与文本,按模型服务商计费。": "Envoie des images échantillonnées et du texte. Le fournisseur peut facturer l’utilisation.",
"允许付费视觉初筛": "Autoriser le préexamen visuel payant",
"关闭时不调用视觉初筛,确认后才开始正式分析。": "Désactivé : aucun préexamen visuel. L’analyse complète attend votre confirmation.",
"分析偏好已保存": "Préférence enregistrée",
"视觉模型不可用,请前往模型设置。": "Modèle visuel indisponible. Ouvrez les paramètres du modèle.",
"按字幕链路处理,未调用视觉初筛;正式制作时使用已有字幕或转写,尚未验证语音内容。": "Utilise les sous-titres sans préexamen visuel. La production utilisera les sous-titres ou une transcription ; la parole n’a pas été vérifiée.",
"内容切片使用字幕分析,请调整分析方式或制作类型。": "Les extraits utilisent les sous-titres. Ajustez la méthode ou les types.",
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Les promos basées sur les sous-titres font un appel texte supplémentaire, facturé par le fournisseur. Relisez avant utilisation.",
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Sous-titres exploitables détectés. Montage sémantique suggéré ; qualité non évaluée, aucun modèle appelé.",
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "Aucun sous-titre trouvé. Transcription ou fichier requis ; parole exploitable non confirmée. Vous pouvez choisir les types manuellement.",
@@ -1193,5 +1164,241 @@
"同一服务商": "Même fournisseur",
"用其他服务生图…": "Utiliser un autre service d’images…",
"生图服务": "Service d’images",
"和上面是同一家服务时可以留空,自动复用。": "Laissez vide pour réutiliser la clé si c’est le même service."
"和上面是同一家服务时可以留空,自动复用。": "Laissez vide pour réutiliser la clé si c’est le même service.",
"高光分析": "Highlight analysis",
"画面理解": "Visual understanding",
"请填写连接名称": "Enter a connection name",
"请填写完整的 HTTP(S) 接口地址": "Enter a complete HTTP(S) API URL",
"服务地址已改变,请重新填写 API Key": "The endpoint changed. Enter the API key again.",
"请先选择模型": "Select a model first",
"连接测试失败,请检查接口、密钥和模型": "Connection test failed. Check the endpoint, key and model.",
"AI 模型": "AI models",
"加载中…": "Loading…",
"模型服务": "Model services",
"选择模型服务": "Select a model service",
"添加模型服务…": "Add a model service…",
"自动获取模型列表,也可以直接输入模型 ID。": "Models are discovered automatically. You can also enter a model ID.",
"已从服务获取模型列表": "Model list fetched from the service",
"使用缓存的模型列表": "Using the cached model list",
"模型列表加载中…": "Loading model list…",
"刷新列表": "Refresh models",
"编辑服务": "Edit service",
"自定义模型能力": "Custom model capabilities",
"模型能力": "Model capability",
"已知模型自动识别;自定义型号可手动指定。": "Known models are identified automatically. You can specify custom model capabilities.",
"自动识别": "Detect automatically",
"测试会发起少量模型调用,按服务商计费。": "Tests make small model requests billed by your provider.",
"接入模型服务,为高光分析和封面生成分别选择模型。": "Connect model services and choose models independently for highlights and covers.",
"尚未使用": "Not in use",
"编辑": "Edit",
"使用中的服务需先更换模型引用才能移除": "Reassign models before removing a service in use",
"添加模型服务": "Add model service",
"使用字幕分析;没有字幕时需要先转写。": "Analyze subtitles. Transcription is needed when subtitles are missing.",
"可使用字幕和抽样画面分析,画面调用按服务商计费。": "Can analyze subtitles and sampled frames. Visual requests are billed by your provider.",
"尚未确认画面能力,当前使用字幕分析;自定义模型可在下方指定能力。": "Visual capability is not confirmed; subtitle analysis is used. Specify custom capabilities below.",
"封面方式": "Cover method",
"视频截帧": "Video frame",
"AI 生图": "AI image generation",
"生图失败时使用视频截帧;不会自动改用其他供应商。": "If image generation fails, use a video frame. Providers are never switched automatically.",
"单独指定画面理解模型": "Use a separate vision model",
"默认复用高光分析模型,也可以选择其他服务。": "Reuse the highlight model by default, or choose another service.",
"导入时允许画面初筛": "Allow visual screening on import",
"自动推荐制作类型时发送抽样画面,按服务商计费。": "Send sampled frames to recommend a workflow. Provider charges apply.",
"本地 Whisper;安装和下载操作立即执行。": "Local Whisper. Installation and download actions take effect immediately.",
"有未保存的更改": "Unsaved changes",
"当前配置": "Current configuration",
"保存设置": "Save settings",
"同一供应商可以添加多个账号;服务连接可被多个功能复用。": "Add multiple accounts for one provider. Connections can be shared across features.",
"应用到配置": "Apply to draft",
"修改此连接会影响:{{uses}}": "Changing this connection affects: {{uses}}",
"连接名称": "Connection name",
"例如:个人账号、本机服务": "For example: Personal account, Local server",
"供应商": "Provider",
"清除密钥": "Clear key",
"获取 API Key": "Get an API key",
"留空使用供应商默认地址": "Leave blank to use the provider default",
"生图接口设置": "Image API settings",
"自定义生图服务需与所选接口协议兼容。": "Custom image services must support the selected API protocol.",
"接口协议": "API protocol",
"使用供应商默认协议": "Use provider default",
"生图接口地址": "Image API URL",
"模型列表刷新失败,正在使用上次成功的列表": "Could not refresh models. Using the last successful list.",
"尚未获取服务模型列表,可以重试或手动输入模型 ID": "Could not fetch models. Retry or enter a model ID manually.",
"高光分析模型": "Highlight analysis model",
"画面理解模型": "Vision model",
"封面生图模型": "Cover image model",
"默认使用同一供应商,可在高级设置中单独配置。": "Uses the same provider by default. Customize it in advanced settings.",
"填写 API Key 后自动选择,也可手动输入模型名": "Selected automatically after entering your API key, or enter a model ID",
"选择供应商并填写 API Key,自动配置分析和封面模型;需要时再调整。": "Choose a provider and enter your API key. Analysis and cover models are selected automatically; adjust them if needed.",
"封面使用其他供应商": "Use another provider for covers",
"默认复用上面的供应商和 API Key。": "Uses the provider and API key above by default.",
"官方供应商": "Official providers",
"OpenAI 官方服务,接口地址已预设。": "Official OpenAI service. The API endpoint is preset.",
"自定义 OpenAI 兼容接口": "Custom OpenAI-compatible API",
"自定义兼容接口": "Custom compatible API",
"适用于 OpenRouter、第三方网关或自建服务。": "For OpenRouter, third-party gateways or self-hosted services.",
"接口设置": "API settings",
"修改接口地址后,请重新填写 API Key。": "Re-enter the API key after changing an endpoint.",
"选择或搜索模型,也可手动输入模型名": "Select or search models, or enter a model ID",
"公开目录预览,填写 API Key 后确认账号可用模型。": "Public catalog preview. Enter an API key to confirm models available to your account.",
"公开目录预览,填写 API Key 后确认账号可用模型。 当前展示缓存目录。": "Public catalog preview from cache. Enter an API key to confirm account availability.",
"账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。": "Could not load account models. Showing a reference catalog; check your key and refresh.",
"Whisper · 本地": "Whisper · Local",
"使用字幕分析;选择多模态模型后可同时理解画面。": "Analyzes subtitles. A multimodal model can also understand video frames.",
"使用独立的画面模型": "Use a separate visual model",
"保存设置后,新任务将使用这个模型。": "New tasks will use this model after you save.",
"共用 API Key,无需重复填写。": "Share the API key without entering it again.",
"准备本地模型": "Prepare local model",
"准备模型": "Prepare model",
"分析模型只能读文字时,可增加一个多模态模型来理解视频画面。": "Add a multimodal model for video frames when the analysis model only reads text.",
"在本机把音频转成字幕,不上传音频,无需 API Key。": "Transcribe audio locally. No audio uploads or API key required.",
"字幕转写": "Subtitle transcription",
"已就绪": "Ready",
"暂时无法读取本地模型状态。": "Local model status is temporarily unavailable.",
"本地模型管理": "Manage local models",
"本地转写组件": "Local transcription components",
"模型已就绪": "Model ready",
"正在下载模型…": "Downloading model…",
"正在准备组件…": "Preparing components…",
"自定义模型类型": "Custom model type",
"补充画面识别": "Add visual understanding",
"视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。": "Used when a video has no subtitles. Larger models generally improve accuracy but require more time and memory.",
"转写模型": "Transcription model",
"首次使用需下载模型和必要组件,之后可在本机转写。": "Download the model and required components once, then transcribe locally.",
"配置高光分析、封面生成和字幕转写使用的模型。": "Choose models for highlight analysis, cover generation, and subtitle transcription.",
"通过专属链接注册,可领取 $5 免费体验额度。": "Register through the dedicated link to claim $5 in free trial credit.",
"$5 免费体验额度": "$5 free trial credit",
"本地": "本地",
"已配置的独立画面模型": "Existing separate vision model",
"视频已有字幕时直接使用;没有字幕时才调用转写模型。": "Use existing subtitles when available; transcribe only when subtitles are missing.",
"改用高光分析模型识别画面": "Use the highlight model for vision",
"会发送抽样画面给多模态模型,费用通常高于仅文字分析。": "Sampled frames will be sent to the multimodal model. This usually costs more than text-only analysis.",
"该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。": "Subtitle timestamps are not integrated for this provider yet. Choose Alibaba Cloud, OpenAI, or local Whisper.",
"选择转写模型": "Select a transcription model",
"需要公网音频地址,暂未接入": "Requires a public audio URL; not integrated yet",
"实时转写接口,暂未接入": "Realtime API; not integrated yet",
"字幕时间戳接口尚未适配": "Subtitle timestamps are not integrated yet",
"模型目录可预览;实际可用性以账号权限和服务商开通状态为准。": "Preview the model catalog. Actual availability depends on your account permissions and activated services.",
"按下面三步完成设置。已有字幕可跳过第 1 步;封面可先使用视频截帧。": "Follow these three steps. Skip step 1 if you already have subtitles; video frames can serve as covers.",
"供应商提供 AI 服务,模型负责具体任务。同一供应商的密钥可以复用。": "Providers host AI services; models perform each task. You can reuse a key for the same provider.",
"把视频中的说话声转成字幕。视频已有字幕时不会调用,可以直接继续第 2 步。": "Turn speech into subtitles. If the video already has subtitles, skip to step 2.",
"必需。选择分析方式,再选择供应商、填写密钥;模型会自动推荐,也可以更换。": "Required. Choose an analysis method, then a provider and key. A model will be suggested; you can change it.",
"服务密钥(API Key)": "Service key (API Key)",
"在供应商网站创建密钥,复制后粘贴到右侧。": "Create a key on the provider website, then copy and paste it here.",
"请填写 API Key": "Enter an API key",
"已复用字幕转写的密钥,点击可修改": "Using the transcription key; click to edit",
"使用自定义型号": "Use custom model",
"可选。使用视频截帧无需配置模型,也不会产生生图费用。": "Optional. Video frames need no model and incur no image generation charges.",
"封面来源": "Cover source",
"AI 生成": "AI generation",
"保存后,返回项目导入视频即可开始。": "After saving, return to Projects and import a video to begin.",
"设置已保存": "Settings saved",
"首次使用,保存后生效": "First setup; save to apply",
"阿里云百炼": "Alibaba Cloud Model Studio",
"阿里云百炼官方服务,接口地址已预设。": "Alibaba Cloud Model Studio. The API endpoint is preset.",
"修改密钥": "Edit key",
"正在获取可用模型…": "Récupération des modèles disponibles…",
"已为你选好推荐模型,可随时更换。": "Un modèle recommandé est sélectionné ; changez-le à tout moment.",
"填写 API Key 后自动选择推荐模型。": "Un modèle recommandé est choisi automatiquement une fois la clé API saisie.",
"画面识别": "Analyse d’image",
"视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。": "Sans sous-titres, la parole est d’abord transcrite. Par défaut, cela se fait localement et gratuitement ; aucun audio n’est envoyé.",
"转写方式": "Transcription",
"本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。": "La transcription locale est gratuite mais télécharge un modèle au départ ; la transcription cloud ne télécharge rien et est facturée à l’usage.",
"将音频发送给所选服务转写,按服务商计费。": "L’audio est envoyé au service choisi pour transcription, facturé par le fournisseur.",
"封面使用其他服务": "Autre service pour les couvertures",
"默认与 AI 服务共用 API Key,无需重复填写。": "Partage la clé du service IA par défaut ; rien d’autre à saisir.",
"{{name}} 没有生图模型,可以为封面单独选一家服务。": "{{name}} n’a pas de modèle d’image ; choisissez un autre service pour les couvertures.",
"已自动选择推荐的生图模型,可更换。": "Un modèle d’image recommandé est sélectionné ; changez-le si besoin.",
"参考视频画面": "S’inspirer d’une image du film",
"开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。": "Envoie une image du film au modèle de couverture pour un résultat plus fidèle au contenu.",
"AI 服务": "Service IA",
"选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。": "Choisissez un service et collez sa clé API ; le modèle est choisi pour vous. Transcription et couvertures réutilisent cette clé par défaut.",
"高级": "Avancé",
"先连接一个 AI 服务": "Connectez d’abord un service IA",
"选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。": "Choisissez un service et collez sa clé API pour commencer ; le modèle est choisi pour vous. Tout reste modifiable dans les réglages.",
"已选好模型:{{model}}": "Modèle choisi : {{model}}",
"等待 API Key": "En attente de la clé API",
"更多选项": "Plus d’options",
"连接并保存": "Connecter et enregistrer",
"已复用 AI 服务的密钥": "Clé du service IA réutilisée",
"本机服务无需 API Key,保持默认地址即可。": "Les services locaux n’ont pas besoin de clé API ; gardez l’adresse par défaut.",
"推荐": "Recommandé",
"请确认 API Key,或直接输入模型名": "Vérifiez la clé API ou saisissez un nom de modèle",
"请先连接 AI 服务,再导入视频。": "Connectez un service IA avant d’importer une vidéo.",
"稍后再说": "Plus tard",
"当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。": "Le modèle actuel est texte seul : les clips sont découpés d’après les sous-titres uniquement. Pour du gameplay ou du contenu peu parlé, passez à un modèle multimodal et activez cette option.",
"抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。": "Échantillonne quelques images pour comprendre l’action et les scènes. Recommandé pour le gameplay ou le contenu peu parlé : découpes plus précises, coût légèrement plus élevé. Désactivé, seuls les sous-titres sont envoyés.",
"默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。": "Par défaut, le service ci-dessus dessine une couverture titrée ; vous pouvez aussi utiliser une image du film.",
"请先选择一家 AI 服务": "Choisissez d’abord un service IA",
"选择一家 AI 服务": "Choisir un service IA",
"先选择一家 AI 服务": "Choisissez un service IA pour commencer",
"国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。": "Services chinois, internationaux ou modèles locaux gratuits : tout est possible ; en cas de doute, commencez par « Recommandé ».",
"先在上面选择一家 AI 服务,生图模型会自动选好。": "Choisissez d’abord un service IA ci-dessus ; le modèle d’image est choisi automatiquement.",
"示例": "Exemple",
"示例项目暂时不可用": "Le projet d’exemple est indisponible pour le moment",
"还没有项目": "Aucun projet pour l’instant",
"从上方导入一段视频,或者先用示例项目看看出片效果。": "Importez une vidéo ci-dessus, ou ouvrez d’abord le projet d’exemple pour voir le résultat.",
"用示例项目看看效果": "Voir le projet d’exemple",
"来源": "Source",
"成片设置": "Réglages du montage",
"片头标题文字": "Texte du titre d’ouverture",
"片头标题样式": "Style du titre d’ouverture",
"把字幕压进画面": "Afficher les sous-titres sur la vidéo",
"片头标题": "Titre d’ouverture",
"字幕压进画面": "Sous-titres sur la vidéo",
"把这段的字幕压进画面": "Affiche les sous-titres de ce passage sur la vidéo.",
"不显示": "Masquer",
"片头约 4 秒显示标题文字": "Affiche le texte du titre pendant environ 4 secondes au début.",
"示例项目 · 原片只保留了三段": "Projet d’exemple · la source ne garde que trois passages",
"一键竖屏": "Vertical en un clic",
"例如:一个问题,或一句结论": "Par exemple : une question, ou une conclusion en une ligne",
"片头样式": "Style du titre",
"入场动效": "Animation d’entrée",
"翻译与动效以渲染结果为准;支持手动换行。": "Traduction et animation sont visibles dans le rendu final ; les retours à la ligne manuels sont pris en charge.",
"选择翻译语言后,渲染时会翻译片头文字与字幕。": "Choisir une langue traduit le titre d’ouverture et les sous-titres au rendu.",
"关闭后成片静音。": "Désactivé, la vidéo finale est muette.",
"保留完整画面,两侧留边或模糊背景。": "Conserve l’image entière, avec des bandes ou un fond flou sur les côtés.",
"满屏": "Remplir",
"模糊背景": "Fond flou",
"留边": "Bandes",
"发布时按平台生成带标题的封面,也可以用视频截帧。": "Une couverture titrée est générée par plateforme à la publication ; une image du film convient aussi.",
"导出成片后,在发布页生成带标题的封面。": "Après l’export, générez une couverture titrée sur la page de publication.",
"去生成": "Générer",
"渲染完成后,在发布页按平台生成带标题的封面。": "Une fois le rendu terminé, générez une couverture titrée par plateforme sur la page de publication.",
"‹ 项目": "‹ Projets",
"确认要做什么": "Confirmez ce que vous voulez produire",
"AI 先看一遍素材给出建议;你确认后才开始正式剪辑。": "L’IA examine la séquence et propose un plan ; le montage ne démarre qu’après votre confirmation.",
"分析方式": "Analyse",
"会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。": "Analyse aussi quelques images pour mieux saisir l’action et les scènes ; facturé par le fournisseur du modèle.",
"当前模型不支持画面分析;换一个多模态模型后可开启。": "Le modèle actuel n’analyse pas les images ; passez à un modèle multimodal pour l’activer.",
"只分析字幕文本,成本最低;没有字幕时会先转写。": "Analyse uniquement le texte des sous-titres, l’option la moins chère ; sans sous-titres, la parole est d’abord transcrite.",
"字幕 + 画面": "Sous-titres + images",
"「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。": "« Extraits de contenu » n’utilise que les sous-titres ; pour analyser les images, cochez aussi Moments forts ou Promo.",
"仅字幕": "Sous-titres seuls",
"当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。": "Le modèle actuel n’analyse pas les images. Choisissez « Sous-titres seuls » ou passez à un modèle multimodal dans les réglages.",
"按字幕找到高潮句,保留前后关键过程": "Repère les moments forts dans les sous-titres et garde le contexte clé",
"把字幕压进画面,整条成片统一样式。": "Affiche les sous-titres sur la vidéo avec un style unique sur tout l’extrait.",
"字幕样式": "Style des sous-titres",
"这里是字幕效果": "Aperçu des sous-titres",
"简洁描边": "Contour simple",
"粗体大字": "Gras et grand",
"底色字幕条": "Bandeau",
"醒目黄字": "Jaune accrocheur",
"主体铺满画面,自动对准说话的人。": "Le sujet remplit le cadre, centré sur la personne qui parle.",
"取景": "Cadrage",
"首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。": "La première fois, le composant de détection de personnes (environ {{size}} Mo) est téléchargé ; ensuite le cadrage suit automatiquement la personne qui parle.",
"正在下载人物识别组件…": "Téléchargement du composant de détection de personnes…",
"正在识别人物位置…": "Repérage des personnes dans l’image…",
"没有识别到人物,请手动调整取景位置。": "Personne détectée ; ajustez le cadrage manuellement.",
"拖动调整当前镜头的取景位置。": "Faites glisser pour ajuster le cadrage du plan actuel.",
"下载并自动取景": "Télécharger et cadrer",
"重新自动取景": "Recadrer automatiquement",
"自动取景": "Cadrage automatique",
"片头文字(可选)": "Texte d’ouverture (facultatif)",
"片头文字": "Texte d’ouverture",
"在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。": "Grand texte sur le premier plan pendant 4 secondes max ; utile pour le gameplay et les promos, à laisser vide pour les interviews et explications.",
"缩略图为设计参考,实际文字效果见左侧预览。": "Les vignettes sont des références de design ; le texte réel s’affiche dans l’aperçu.",
"片头": "Titre",
"已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。": "Le cadrage suit la personne qui parle ({{framed}}/{{total}} plans, {{switches}} changements). Déplacer le curseur fige le cadrage.",
"正在跟随说话人取景;拖动滑块会改为固定取景。": "Le cadrage suit la personne qui parle ; déplacer le curseur fige le cadrage."
}
+237 -30
View File
@@ -853,9 +853,6 @@
"保持原画幅": "元の画面比率を保持",
"9:16 竖屏": "9:16 縦型",
"16:9 横屏": "16:9 横型",
"返回导入": "インポートに戻る",
"确认制作内容": "制作内容を確認",
"导入视频 → 识别与确认 → 开始制作": "動画をインポート → 判定・確認 → 制作開始",
"制作已经开始": "制作を開始しました",
"正在快速识别素材": "素材を簡易判定中",
"可进入项目查看进度。": "プロジェクトで進捗を確認できます。",
@@ -941,37 +938,21 @@
"查看原片": "元動画を見る",
"播放成片": "完成動画を再生",
"渲染预览": "プレビューを書き出す",
"改到满意,就导出。": "好みに調整して書き出し。",
"应用竖屏推荐": "縦型の推奨設定を適用",
"成片名称": "動画名",
"开头文字": "冒頭のテキスト",
"可留空;在首镜头最多显示 4 秒": "空欄可。最初のシーンで最大4秒表示",
"标题模板": "タイトルテンプレート",
"缩略图为设计参考,当前文案效果见预览。": "サムネイルはデザイン参考です。実際の文案はプレビューで確認できます。",
"调整文字样式": "文字スタイルを調整",
"样式版本": "スタイルの版",
"强调色": "アクセントカラー",
"标题强调色": "タイトルのアクセントカラー",
"文字大小": "文字サイズ",
"文字位置": "文字の位置",
"开启入场动效": "登場アニメーションを有効化",
"预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。": "実際の文字レイヤーでプレビュー。翻訳とアニメーションはレンダリング後に反映。手動改行に対応。",
"输出文字语言": "出力テキストの言語",
"选择翻译语言后,渲染时会翻译开头文字与所选原字幕。": "翻訳先言語を選ぶと、レンダリング時に冒頭テキストと選択した字幕を翻訳します。",
"烧录原字幕": "元の字幕を焼き込む",
"声音": "音声",
"保留原声": "元の音声を保持",
"画面设置": "画面設定",
"原画幅": "元の画面比率",
"构图": "構図",
"完整画面 · 留边": "全画面を保持 · 余白",
"完整画面 · 模糊背景": "全画面を保持 · 背景ぼかし",
"满屏取景": "画面いっぱいに配置",
"取景位置 · 左右移动": "切り取り位置 · 左右に移動",
"取景位置": "切り取り位置",
"主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。": "画面いっぱいに表示。左右を調整し、キャラクター・障害物・HUDが見えるか確認してください。",
"当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。": "横型映像全体を保持するため余白ができます。縦画面いっぱいにするには「画面いっぱいに配置」を選択。",
"保留原画面构图。": "元の構図を保持します。",
"文案修改要求": "文案の修正指示",
"告诉 AI 怎么改文案,例如:开头改成一个简短的问题": "AIに修正内容を指示。例:冒頭を短い質問にする",
"改一版文案": "文案を書き直す",
@@ -997,10 +978,8 @@
"成片": "完成動画",
"格式": "形式",
"保持原尺寸": "元のサイズを保持",
"开头包装": "冒頭の装飾",
"原声": "元の音声",
"保留": "保持",
"烧录已有字幕": "既存の字幕を焼き込む",
"当前版本已渲染完成,可以直接下载。": "この版はレンダリング済みで、ダウンロードできます。",
"任务在后台继续,关闭面板不会取消渲染。": "パネルを閉じてもバックグラウンドでレンダリングが続きます。",
"追加一个镜头": "シーンを追加",
@@ -1100,21 +1079,13 @@
"制作任务未能启动,请重试确认;原素材与已有成片已保留": "制作を開始できませんでした。もう一度確認してください。元の素材と既存の動画は保持されています。",
"这个切片当前播放器无法解码,请下载后用系统播放器打开。": "このクリップはプレーヤーで復号できません。ダウンロードしてシステムのプレーヤーで開いてください。",
"切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。": "クリップ分析では DeepSeek の思考モードを切り、長い推論が出力として課金されないようにします。長い動画は、字幕およそ30分ごと、工程ごとに複数回呼び出します。ページを更新しても再実行されず、処理をやり直したときだけ再び課金されます。",
"分析方式": "分析方法",
"本次分析方式": "今回の分析方法",
"默认分析方式": "既定の分析方法",
"字幕分析 · 低成本": "字幕分析 · 低コスト",
"视觉分析": "映像分析",
"智能选择": "自動選択",
"配置视觉模型不会自动切换分析方式。": "映像モデルを設定しても分析方法は自動で変わりません。",
"仅分析字幕文本;无字幕时需要转写。": "字幕テキストを分析します。字幕がない場合は文字起こしが必要です。",
"发送抽样画面与文本,按模型服务商计费。": "抽出したフレームとテキストを送信します。モデル提供元の利用料金がかかる場合があります。",
"允许付费视觉初筛": "有料の映像予備分析を許可",
"关闭时不调用视觉初筛,确认后才开始正式分析。": "オフの場合は映像の予備分析を行いません。本分析は確認後に始まります。",
"分析偏好已保存": "分析設定を保存しました",
"视觉模型不可用,请前往模型设置。": "映像モデルが利用できません。モデル設定を確認してください。",
"按字幕链路处理,未调用视觉初筛;正式制作时使用已有字幕或转写,尚未验证语音内容。": "映像の予備分析なしで字幕処理を使います。制作時に字幕または文字起こしを使用します。音声内容は未確認です。",
"内容切片使用字幕分析,请调整分析方式或制作类型。": "コンテンツ切り抜きには字幕分析を使います。方法または制作タイプを変更してください。",
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "字幕プロモーションは文案生成のため文字モデルを追加で1回呼び出します。提供元の料金が適用されます。使用前に確認してください。",
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "利用可能な字幕を検出しました。意味に基づく編集を推奨します。品質は未評価で、モデルは呼び出していません。",
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "字幕がありません。文字起こしまたは字幕ファイルが必要です。利用可能な音声は未確認です。制作タイプを手動で選べます。",
@@ -1193,5 +1164,241 @@
"同一服务商": "同じサービス",
"用其他服务生图…": "別のサービスで生成…",
"生图服务": "画像生成サービス",
"和上面是同一家服务时可以留空,自动复用。": "上と同じサービスなら空欄で自動的に再利用します。"
"和上面是同一家服务时可以留空,自动复用。": "上と同じサービスなら空欄で自動的に再利用します。",
"高光分析": "Highlight analysis",
"画面理解": "Visual understanding",
"请填写连接名称": "Enter a connection name",
"请填写完整的 HTTP(S) 接口地址": "Enter a complete HTTP(S) API URL",
"服务地址已改变,请重新填写 API Key": "The endpoint changed. Enter the API key again.",
"请先选择模型": "Select a model first",
"连接测试失败,请检查接口、密钥和模型": "Connection test failed. Check the endpoint, key and model.",
"AI 模型": "AI models",
"加载中…": "Loading…",
"模型服务": "Model services",
"选择模型服务": "Select a model service",
"添加模型服务…": "Add a model service…",
"自动获取模型列表,也可以直接输入模型 ID。": "Models are discovered automatically. You can also enter a model ID.",
"已从服务获取模型列表": "Model list fetched from the service",
"使用缓存的模型列表": "Using the cached model list",
"模型列表加载中…": "Loading model list…",
"刷新列表": "Refresh models",
"编辑服务": "Edit service",
"自定义模型能力": "Custom model capabilities",
"模型能力": "Model capability",
"已知模型自动识别;自定义型号可手动指定。": "Known models are identified automatically. You can specify custom model capabilities.",
"自动识别": "Detect automatically",
"测试会发起少量模型调用,按服务商计费。": "Tests make small model requests billed by your provider.",
"接入模型服务,为高光分析和封面生成分别选择模型。": "Connect model services and choose models independently for highlights and covers.",
"尚未使用": "Not in use",
"编辑": "Edit",
"使用中的服务需先更换模型引用才能移除": "Reassign models before removing a service in use",
"添加模型服务": "Add model service",
"使用字幕分析;没有字幕时需要先转写。": "Analyze subtitles. Transcription is needed when subtitles are missing.",
"可使用字幕和抽样画面分析,画面调用按服务商计费。": "Can analyze subtitles and sampled frames. Visual requests are billed by your provider.",
"尚未确认画面能力,当前使用字幕分析;自定义模型可在下方指定能力。": "Visual capability is not confirmed; subtitle analysis is used. Specify custom capabilities below.",
"封面方式": "Cover method",
"视频截帧": "Video frame",
"AI 生图": "AI image generation",
"生图失败时使用视频截帧;不会自动改用其他供应商。": "If image generation fails, use a video frame. Providers are never switched automatically.",
"单独指定画面理解模型": "Use a separate vision model",
"默认复用高光分析模型,也可以选择其他服务。": "Reuse the highlight model by default, or choose another service.",
"导入时允许画面初筛": "Allow visual screening on import",
"自动推荐制作类型时发送抽样画面,按服务商计费。": "Send sampled frames to recommend a workflow. Provider charges apply.",
"本地 Whisper;安装和下载操作立即执行。": "Local Whisper. Installation and download actions take effect immediately.",
"有未保存的更改": "Unsaved changes",
"当前配置": "Current configuration",
"保存设置": "Save settings",
"同一供应商可以添加多个账号;服务连接可被多个功能复用。": "Add multiple accounts for one provider. Connections can be shared across features.",
"应用到配置": "Apply to draft",
"修改此连接会影响:{{uses}}": "Changing this connection affects: {{uses}}",
"连接名称": "Connection name",
"例如:个人账号、本机服务": "For example: Personal account, Local server",
"供应商": "Provider",
"清除密钥": "Clear key",
"获取 API Key": "Get an API key",
"留空使用供应商默认地址": "Leave blank to use the provider default",
"生图接口设置": "Image API settings",
"自定义生图服务需与所选接口协议兼容。": "Custom image services must support the selected API protocol.",
"接口协议": "API protocol",
"使用供应商默认协议": "Use provider default",
"生图接口地址": "Image API URL",
"模型列表刷新失败,正在使用上次成功的列表": "Could not refresh models. Using the last successful list.",
"尚未获取服务模型列表,可以重试或手动输入模型 ID": "Could not fetch models. Retry or enter a model ID manually.",
"高光分析模型": "Highlight analysis model",
"画面理解模型": "Vision model",
"封面生图模型": "Cover image model",
"默认使用同一供应商,可在高级设置中单独配置。": "Uses the same provider by default. Customize it in advanced settings.",
"填写 API Key 后自动选择,也可手动输入模型名": "Selected automatically after entering your API key, or enter a model ID",
"选择供应商并填写 API Key,自动配置分析和封面模型;需要时再调整。": "Choose a provider and enter your API key. Analysis and cover models are selected automatically; adjust them if needed.",
"封面使用其他供应商": "Use another provider for covers",
"默认复用上面的供应商和 API Key。": "Uses the provider and API key above by default.",
"官方供应商": "Official providers",
"OpenAI 官方服务,接口地址已预设。": "Official OpenAI service. The API endpoint is preset.",
"自定义 OpenAI 兼容接口": "Custom OpenAI-compatible API",
"自定义兼容接口": "Custom compatible API",
"适用于 OpenRouter、第三方网关或自建服务。": "For OpenRouter, third-party gateways or self-hosted services.",
"接口设置": "API settings",
"修改接口地址后,请重新填写 API Key。": "Re-enter the API key after changing an endpoint.",
"选择或搜索模型,也可手动输入模型名": "Select or search models, or enter a model ID",
"公开目录预览,填写 API Key 后确认账号可用模型。": "Public catalog preview. Enter an API key to confirm models available to your account.",
"公开目录预览,填写 API Key 后确认账号可用模型。 当前展示缓存目录。": "Public catalog preview from cache. Enter an API key to confirm account availability.",
"账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。": "Could not load account models. Showing a reference catalog; check your key and refresh.",
"Whisper · 本地": "Whisper · Local",
"使用字幕分析;选择多模态模型后可同时理解画面。": "Analyzes subtitles. A multimodal model can also understand video frames.",
"使用独立的画面模型": "Use a separate visual model",
"保存设置后,新任务将使用这个模型。": "New tasks will use this model after you save.",
"共用 API Key,无需重复填写。": "Share the API key without entering it again.",
"准备本地模型": "Prepare local model",
"准备模型": "Prepare model",
"分析模型只能读文字时,可增加一个多模态模型来理解视频画面。": "Add a multimodal model for video frames when the analysis model only reads text.",
"在本机把音频转成字幕,不上传音频,无需 API Key。": "Transcribe audio locally. No audio uploads or API key required.",
"字幕转写": "Subtitle transcription",
"已就绪": "Ready",
"暂时无法读取本地模型状态。": "Local model status is temporarily unavailable.",
"本地模型管理": "Manage local models",
"本地转写组件": "Local transcription components",
"模型已就绪": "Model ready",
"正在下载模型…": "Downloading model…",
"正在准备组件…": "Preparing components…",
"自定义模型类型": "Custom model type",
"补充画面识别": "Add visual understanding",
"视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。": "Used when a video has no subtitles. Larger models generally improve accuracy but require more time and memory.",
"转写模型": "Transcription model",
"首次使用需下载模型和必要组件,之后可在本机转写。": "Download the model and required components once, then transcribe locally.",
"配置高光分析、封面生成和字幕转写使用的模型。": "Choose models for highlight analysis, cover generation, and subtitle transcription.",
"通过专属链接注册,可领取 $5 免费体验额度。": "Register through the dedicated link to claim $5 in free trial credit.",
"$5 免费体验额度": "$5 free trial credit",
"本地": "本地",
"已配置的独立画面模型": "Existing separate vision model",
"视频已有字幕时直接使用;没有字幕时才调用转写模型。": "Use existing subtitles when available; transcribe only when subtitles are missing.",
"改用高光分析模型识别画面": "Use the highlight model for vision",
"会发送抽样画面给多模态模型,费用通常高于仅文字分析。": "Sampled frames will be sent to the multimodal model. This usually costs more than text-only analysis.",
"该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。": "Subtitle timestamps are not integrated for this provider yet. Choose Alibaba Cloud, OpenAI, or local Whisper.",
"选择转写模型": "Select a transcription model",
"需要公网音频地址,暂未接入": "Requires a public audio URL; not integrated yet",
"实时转写接口,暂未接入": "Realtime API; not integrated yet",
"字幕时间戳接口尚未适配": "Subtitle timestamps are not integrated yet",
"模型目录可预览;实际可用性以账号权限和服务商开通状态为准。": "Preview the model catalog. Actual availability depends on your account permissions and activated services.",
"按下面三步完成设置。已有字幕可跳过第 1 步;封面可先使用视频截帧。": "Follow these three steps. Skip step 1 if you already have subtitles; video frames can serve as covers.",
"供应商提供 AI 服务,模型负责具体任务。同一供应商的密钥可以复用。": "Providers host AI services; models perform each task. You can reuse a key for the same provider.",
"把视频中的说话声转成字幕。视频已有字幕时不会调用,可以直接继续第 2 步。": "Turn speech into subtitles. If the video already has subtitles, skip to step 2.",
"必需。选择分析方式,再选择供应商、填写密钥;模型会自动推荐,也可以更换。": "Required. Choose an analysis method, then a provider and key. A model will be suggested; you can change it.",
"服务密钥(API Key)": "Service key (API Key)",
"在供应商网站创建密钥,复制后粘贴到右侧。": "Create a key on the provider website, then copy and paste it here.",
"请填写 API Key": "Enter an API key",
"已复用字幕转写的密钥,点击可修改": "Using the transcription key; click to edit",
"使用自定义型号": "Use custom model",
"可选。使用视频截帧无需配置模型,也不会产生生图费用。": "Optional. Video frames need no model and incur no image generation charges.",
"封面来源": "Cover source",
"AI 生成": "AI generation",
"保存后,返回项目导入视频即可开始。": "After saving, return to Projects and import a video to begin.",
"设置已保存": "Settings saved",
"首次使用,保存后生效": "First setup; save to apply",
"阿里云百炼": "Alibaba Cloud Model Studio",
"阿里云百炼官方服务,接口地址已预设。": "Alibaba Cloud Model Studio. The API endpoint is preset.",
"修改密钥": "Edit key",
"正在获取可用模型…": "利用可能なモデルを取得中…",
"已为你选好推荐模型,可随时更换。": "推奨モデルを選択済みです。いつでも変更できます。",
"填写 API Key 后自动选择推荐模型。": "API キーを入力すると推奨モデルが自動で選ばれます。",
"画面识别": "映像認識",
"视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。": "字幕がない動画は、まず音声を字幕に変換します。既定ではローカルで無料に処理し、音声はアップロードしません。",
"转写方式": "文字起こし方法",
"本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。": "ローカル文字起こしは無料ですが初回にモデルをダウンロードします。クラウドはダウンロード不要で使用量課金です。",
"将音频发送给所选服务转写,按服务商计费。": "音声を選択したサービスに送信して文字起こしします。料金は提供元の課金です。",
"封面使用其他服务": "カバーに別のサービスを使う",
"默认与 AI 服务共用 API Key,无需重复填写。": "既定では AI サービスの API キーを共用するため、再入力は不要です。",
"{{name}} 没有生图模型,可以为封面单独选一家服务。": "{{name}} には画像生成モデルがありません。カバー用に別のサービスを選べます。",
"已自动选择推荐的生图模型,可更换。": "推奨の画像生成モデルを選択済みです。変更できます。",
"参考视频画面": "動画のフレームを参考にする",
"开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。": "オンにすると動画のスクリーンショット 1 枚をカバーモデルに送り、内容に近いカバーを生成します。",
"AI 服务": "AI サービス",
"选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。": "サービスを選んで API キーを入力すると、モデルは自動で選ばれます。文字起こしとカバーも既定でこのキーを使います。",
"高级": "詳細設定",
"先连接一个 AI 服务": "まず AI サービスを接続",
"选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。": "サービスを選んで API キーを入力すれば始められます。モデルは自動で選ばれ、あとから設定でいつでも調整できます。",
"已选好模型:{{model}}": "モデル選択済み:{{model}}",
"等待 API Key": "API キー待ち",
"更多选项": "その他のオプション",
"连接并保存": "接続して保存",
"已复用 AI 服务的密钥": "AI サービスのキーを共用中",
"本机服务无需 API Key,保持默认地址即可。": "ローカルサービスには API キーは不要です。既定のアドレスのままで構いません。",
"推荐": "おすすめ",
"请确认 API Key,或直接输入模型名": "API キーを確認するか、モデル名を直接入力してください",
"请先连接 AI 服务,再导入视频。": "動画を取り込む前に AI サービスを接続してください。",
"稍后再说": "あとで",
"当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。": "現在のモデルはテキスト専用のため、字幕だけで切り出します。ゲーム映像やトークの少ない素材では、マルチモーダルモデルに切り替えてオンにすることをおすすめします。",
"抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。": "数枚のフレームを抽出して動きや場面を理解します。ゲーム映像やトークの少ない素材ではオンを推奨(切り出しがより正確、費用はやや高め)。オフにすると字幕のみ送信します。",
"默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。": "既定では上のサービスがタイトル付きカバーを描きます。動画のフレームに切り替えることもできます。",
"请先选择一家 AI 服务": "まず AI サービスを選択してください",
"选择一家 AI 服务": "AI サービスを選択",
"先选择一家 AI 服务": "まず AI サービスを選択",
"国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。": "中国国内直結・海外サービス・ローカル無料モデルから選べます。迷ったら「おすすめ」から始めてください。",
"先在上面选择一家 AI 服务,生图模型会自动选好。": "まず上で AI サービスを選択してください。画像生成モデルは自動で選ばれます。",
"示例": "サンプル",
"示例项目暂时不可用": "サンプルプロジェクトは現在利用できません",
"还没有项目": "まだプロジェクトがありません",
"从上方导入一段视频,或者先用示例项目看看出片效果。": "上から動画を取り込むか、まずサンプルプロジェクトで出力の雰囲気を確認できます。",
"用示例项目看看效果": "サンプルプロジェクトを見る",
"来源": "ソース",
"成片设置": "成片の設定",
"片头标题文字": "冒頭タイトルの文字",
"片头标题样式": "冒頭タイトルのスタイル",
"把字幕压进画面": "字幕を映像に焼き込む",
"片头标题": "冒頭タイトル",
"字幕压进画面": "字幕を焼き込み",
"把这段的字幕压进画面": "この区間の字幕を映像に焼き込みます。",
"不显示": "表示しない",
"片头约 4 秒显示标题文字": "冒頭に約 4 秒間タイトル文字を表示します。",
"示例项目 · 原片只保留了三段": "サンプルプロジェクト · 元動画は 3 区間のみ",
"一键竖屏": "縦型にワンタップ",
"例如:一个问题,或一句结论": "例:問いかけ、または一言の結論",
"片头样式": "タイトルのスタイル",
"入场动效": "入場アニメーション",
"翻译与动效以渲染结果为准;支持手动换行。": "翻訳とアニメーションはレンダリング結果が基準です。手動改行に対応します。",
"选择翻译语言后,渲染时会翻译片头文字与字幕。": "言語を選ぶと、レンダリング時に冒頭タイトルと字幕を翻訳します。",
"关闭后成片静音。": "オフにすると成片は無音になります。",
"保留完整画面,两侧留边或模糊背景。": "全体を保ち、両側は余白またはぼかし背景にします。",
"满屏": "全画面",
"模糊背景": "ぼかし背景",
"留边": "余白",
"发布时按平台生成带标题的封面,也可以用视频截帧。": "公開時にプラットフォームごとにタイトル付きカバーを生成します。動画のフレームも使えます。",
"导出成片后,在发布页生成带标题的封面。": "書き出し後、公開ページでタイトル付きカバーを生成できます。",
"去生成": "生成へ",
"渲染完成后,在发布页按平台生成带标题的封面。": "レンダリング後、公開ページでプラットフォームごとにタイトル付きカバーを生成できます。",
"‹ 项目": "‹ プロジェクト",
"确认要做什么": "何を作るか確認",
"AI 先看一遍素材给出建议;你确认后才开始正式剪辑。": "AI が素材を確認して提案します。確認後に本番の編集が始まります。",
"分析方式": "分析方法",
"会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。": "数枚のフレームもあわせて分析し、動きや場面をより正確に把握します。料金はモデル提供元の課金です。",
"当前模型不支持画面分析;换一个多模态模型后可开启。": "現在のモデルは映像分析に対応していません。マルチモーダルモデルに切り替えると使えます。",
"只分析字幕文本,成本最低;没有字幕时会先转写。": "字幕テキストのみを分析する最も低コストの方法です。字幕がない場合は先に文字起こしします。",
"字幕 + 画面": "字幕 + 映像",
"「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。": "「コンテンツ切り出し」は字幕のみで分析します。映像分析を使うにはハイライトかプロモも選んでください。",
"仅字幕": "字幕のみ",
"当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。": "現在のモデルは映像分析に対応していません。「字幕のみ」を選ぶか、設定でマルチモーダルモデルに切り替えてください。",
"按字幕找到高潮句,保留前后关键过程": "字幕から盛り上がりを見つけ、前後の重要な流れを残す",
"把字幕压进画面,整条成片统一样式。": "字幕を映像に焼き込み、全体で統一したスタイルにします。",
"字幕样式": "字幕スタイル",
"这里是字幕效果": "字幕のプレビュー",
"简洁描边": "シンプル縁取り",
"粗体大字": "太字大きめ",
"底色字幕条": "帯付き字幕",
"醒目黄字": "目立つ黄色",
"主体铺满画面,自动对准说话的人。": "被写体を画面いっぱいに、話している人に自動で合わせます。",
"取景": "構図",
"首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。": "初回のみ人物認識コンポーネント(約 {{size}} MB)をダウンロードします。以降は話している人に自動で合わせます。",
"正在下载人物识别组件…": "人物認識コンポーネントをダウンロード中…",
"正在识别人物位置…": "人物の位置を認識中…",
"没有识别到人物,请手动调整取景位置。": "人物を認識できませんでした。構図を手動で調整してください。",
"拖动调整当前镜头的取景位置。": "ドラッグして現在のショットの構図を調整します。",
"下载并自动取景": "ダウンロードして自動構図",
"重新自动取景": "もう一度自動構図",
"自动取景": "自動構図",
"片头文字(可选)": "冒頭テキスト(任意)",
"片头文字": "冒頭テキスト",
"在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。": "最初のショットに最長 4 秒の大きな文字を表示します。ゲームやプロモ向け。インタビューや解説では空欄で構いません。",
"缩略图为设计参考,实际文字效果见左侧预览。": "サムネイルはデザインの参考です。実際の文字は左のプレビューで確認できます。",
"片头": "タイトル",
"已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。": "話している人に合わせて自動で構図を設定しました({{framed}}/{{total}} ショット、切り替え {{switches}} 回)。スライダーを動かすと固定構図になります。",
"正在跟随说话人取景;拖动滑块会改为固定取景。": "話している人に合わせて構図を追従中。スライダーを動かすと固定構図になります。"
}
+237 -30
View File
@@ -853,9 +853,6 @@
"保持原画幅": "원본 화면 비율 유지",
"9:16 竖屏": "9:16 세로",
"16:9 横屏": "16:9 가로",
"返回导入": "가져오기로 돌아가기",
"确认制作内容": "제작할 콘텐츠 확인",
"导入视频 → 识别与确认 → 开始制作": "영상 가져오기 → 식별 및 확인 → 제작 시작",
"制作已经开始": "제작이 시작되었습니다",
"正在快速识别素材": "소스를 빠르게 분석하는 중",
"可进入项目查看进度。": "프로젝트에서 진행 상황을 확인하세요.",
@@ -941,37 +938,21 @@
"查看原片": "원본 보기",
"播放成片": "완성 영상 재생",
"渲染预览": "미리보기 렌더링",
"改到满意,就导出。": "원하는 대로 수정하고 내보내세요.",
"应用竖屏推荐": "세로형 추천 설정 적용",
"成片名称": "영상 이름",
"开头文字": "오프닝 텍스트",
"可留空;在首镜头最多显示 4 秒": "선택 사항. 첫 장면에 최대 4초 표시",
"标题模板": "제목 템플릿",
"缩略图为设计参考,当前文案效果见预览。": "썸네일은 디자인 참고용입니다. 실제 문구는 미리보기에서 확인하세요.",
"调整文字样式": "텍스트 스타일 조정",
"样式版本": "스타일 버전",
"强调色": "강조색",
"标题强调色": "제목 강조색",
"文字大小": "텍스트 크기",
"文字位置": "텍스트 위치",
"开启入场动效": "등장 애니메이션 사용",
"预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。": "미리보기는 실제 텍스트 레이어를 사용합니다. 번역과 애니메이션은 렌더링에 적용되며 수동 줄바꿈을 지원합니다.",
"输出文字语言": "출력 텍스트 언어",
"选择翻译语言后,渲染时会翻译开头文字与所选原字幕。": "번역 언어를 선택하면 렌더링 시 오프닝 텍스트와 선택한 원본 자막을 번역합니다.",
"烧录原字幕": "원본 자막 입히기",
"声音": "오디오",
"保留原声": "원본 오디오 유지",
"画面设置": "화면 설정",
"原画幅": "원본 화면 비율",
"构图": "구도",
"完整画面 · 留边": "전체 화면 · 여백",
"完整画面 · 模糊背景": "전체 화면 · 흐린 배경",
"满屏取景": "화면 채우기",
"取景位置 · 左右移动": "구도 위치 · 좌우 이동",
"取景位置": "구도 위치",
"主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。": "화면을 채웁니다. 좌우로 조정해 캐릭터, 장애물, HUD가 보이는지 확인하세요.",
"当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。": "가로 화면 전체를 유지하면 여백이 생깁니다. 세로 화면을 채우려면 '화면 채우기'를 선택하세요.",
"保留原画面构图。": "원본 구도를 유지합니다.",
"文案修改要求": "문구 수정 요청",
"告诉 AI 怎么改文案,例如:开头改成一个简短的问题": "AI에 수정 요청. 예: 짧은 질문으로 시작하기",
"改一版文案": "문구 다시 작성",
@@ -997,10 +978,8 @@
"成片": "완성 영상",
"格式": "형식",
"保持原尺寸": "원본 크기 유지",
"开头包装": "오프닝 스타일",
"原声": "원본 오디오",
"保留": "유지",
"烧录已有字幕": "기존 자막 입히기",
"当前版本已渲染完成,可以直接下载。": "현재 버전은 렌더링이 완료되어 다운로드할 수 있습니다.",
"任务在后台继续,关闭面板不会取消渲染。": "패널을 닫아도 백그라운드에서 렌더링이 계속됩니다.",
"追加一个镜头": "장면 추가",
@@ -1100,21 +1079,13 @@
"制作任务未能启动,请重试确认;原素材与已有成片已保留": "제작을 시작하지 못했습니다. 다시 확인해 주세요. 원본과 기존 영상은 보존되었습니다.",
"这个切片当前播放器无法解码,请下载后用系统播放器打开。": "이 클립은 플레이어에서 디코딩할 수 없습니다. 다운로드한 뒤 시스템 플레이어로 여세요.",
"切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。": "클립 분석은 DeepSeek 사고 모드를 꺼서, 긴 추론이 출력 토큰으로 과금되지 않게 합니다. 긴 영상은 자막 약 30분마다, 단계마다 여러 번 호출됩니다. 페이지를 새로고침해도 다시 실행되지 않고, 처리를 다시 시작할 때만 과금됩니다.",
"分析方式": "분석 방식",
"本次分析方式": "이번 분석 방식",
"默认分析方式": "기본 분석 방식",
"字幕分析 · 低成本": "자막 분석 · 저비용",
"视觉分析": "영상 분석",
"智能选择": "스마트 선택",
"配置视觉模型不会自动切换分析方式。": "영상 모델을 설정해도 분석 방식은 자동으로 바뀌지 않습니다.",
"仅分析字幕文本;无字幕时需要转写。": "자막 텍스트만 분석합니다. 자막이 없으면 전사가 필요합니다.",
"发送抽样画面与文本,按模型服务商计费。": "추출한 프레임과 텍스트를 전송합니다. 모델 제공업체가 사용 요금을 부과할 수 있습니다.",
"允许付费视觉初筛": "유료 영상 사전 분석 허용",
"关闭时不调用视觉初筛,确认后才开始正式分析。": "끄면 영상 사전 분석을 호출하지 않습니다. 본 분석은 확인 후 시작됩니다.",
"分析偏好已保存": "분석 설정 저장됨",
"视觉模型不可用,请前往模型设置。": "영상 모델을 사용할 수 없습니다. 모델 설정을 확인하세요.",
"按字幕链路处理,未调用视觉初筛;正式制作时使用已有字幕或转写,尚未验证语音内容。": "영상 사전 분석 없이 자막 경로를 사용합니다. 제작 시 자막 또는 전사를 사용하며 음성 내용은 아직 확인되지 않았습니다.",
"内容切片使用字幕分析,请调整分析方式或制作类型。": "콘텐츠 클립은 자막 분석을 사용합니다. 방식 또는 유형을 조정하세요.",
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "자막 홍보는 문구 생성을 위해 텍스트 모델을 한 번 추가 호출하며 제공업체 요금이 적용됩니다. 사용 전에 검토하세요.",
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "사용 가능한 자막을 찾았습니다. 의미 기반 편집을 제안합니다. 품질은 평가하지 않았으며 모델을 호출하지 않았습니다.",
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "자막이 없습니다. 전사 또는 자막 파일이 필요합니다. 사용 가능한 음성은 아직 확인되지 않았습니다. 제작 유형을 직접 선택할 수 있습니다.",
@@ -1193,5 +1164,241 @@
"同一服务商": "같은 서비스",
"用其他服务生图…": "다른 서비스로 생성…",
"生图服务": "이미지 서비스",
"和上面是同一家服务时可以留空,自动复用。": "위와 같은 서비스라면 비워 두면 자동으로 재사용합니다."
"和上面是同一家服务时可以留空,自动复用。": "위와 같은 서비스라면 비워 두면 자동으로 재사용합니다.",
"高光分析": "Highlight analysis",
"画面理解": "Visual understanding",
"请填写连接名称": "Enter a connection name",
"请填写完整的 HTTP(S) 接口地址": "Enter a complete HTTP(S) API URL",
"服务地址已改变,请重新填写 API Key": "The endpoint changed. Enter the API key again.",
"请先选择模型": "Select a model first",
"连接测试失败,请检查接口、密钥和模型": "Connection test failed. Check the endpoint, key and model.",
"AI 模型": "AI models",
"加载中…": "Loading…",
"模型服务": "Model services",
"选择模型服务": "Select a model service",
"添加模型服务…": "Add a model service…",
"自动获取模型列表,也可以直接输入模型 ID。": "Models are discovered automatically. You can also enter a model ID.",
"已从服务获取模型列表": "Model list fetched from the service",
"使用缓存的模型列表": "Using the cached model list",
"模型列表加载中…": "Loading model list…",
"刷新列表": "Refresh models",
"编辑服务": "Edit service",
"自定义模型能力": "Custom model capabilities",
"模型能力": "Model capability",
"已知模型自动识别;自定义型号可手动指定。": "Known models are identified automatically. You can specify custom model capabilities.",
"自动识别": "Detect automatically",
"测试会发起少量模型调用,按服务商计费。": "Tests make small model requests billed by your provider.",
"接入模型服务,为高光分析和封面生成分别选择模型。": "Connect model services and choose models independently for highlights and covers.",
"尚未使用": "Not in use",
"编辑": "Edit",
"使用中的服务需先更换模型引用才能移除": "Reassign models before removing a service in use",
"添加模型服务": "Add model service",
"使用字幕分析;没有字幕时需要先转写。": "Analyze subtitles. Transcription is needed when subtitles are missing.",
"可使用字幕和抽样画面分析,画面调用按服务商计费。": "Can analyze subtitles and sampled frames. Visual requests are billed by your provider.",
"尚未确认画面能力,当前使用字幕分析;自定义模型可在下方指定能力。": "Visual capability is not confirmed; subtitle analysis is used. Specify custom capabilities below.",
"封面方式": "Cover method",
"视频截帧": "Video frame",
"AI 生图": "AI image generation",
"生图失败时使用视频截帧;不会自动改用其他供应商。": "If image generation fails, use a video frame. Providers are never switched automatically.",
"单独指定画面理解模型": "Use a separate vision model",
"默认复用高光分析模型,也可以选择其他服务。": "Reuse the highlight model by default, or choose another service.",
"导入时允许画面初筛": "Allow visual screening on import",
"自动推荐制作类型时发送抽样画面,按服务商计费。": "Send sampled frames to recommend a workflow. Provider charges apply.",
"本地 Whisper;安装和下载操作立即执行。": "Local Whisper. Installation and download actions take effect immediately.",
"有未保存的更改": "Unsaved changes",
"当前配置": "Current configuration",
"保存设置": "Save settings",
"同一供应商可以添加多个账号;服务连接可被多个功能复用。": "Add multiple accounts for one provider. Connections can be shared across features.",
"应用到配置": "Apply to draft",
"修改此连接会影响:{{uses}}": "Changing this connection affects: {{uses}}",
"连接名称": "Connection name",
"例如:个人账号、本机服务": "For example: Personal account, Local server",
"供应商": "Provider",
"清除密钥": "Clear key",
"获取 API Key": "Get an API key",
"留空使用供应商默认地址": "Leave blank to use the provider default",
"生图接口设置": "Image API settings",
"自定义生图服务需与所选接口协议兼容。": "Custom image services must support the selected API protocol.",
"接口协议": "API protocol",
"使用供应商默认协议": "Use provider default",
"生图接口地址": "Image API URL",
"模型列表刷新失败,正在使用上次成功的列表": "Could not refresh models. Using the last successful list.",
"尚未获取服务模型列表,可以重试或手动输入模型 ID": "Could not fetch models. Retry or enter a model ID manually.",
"高光分析模型": "Highlight analysis model",
"画面理解模型": "Vision model",
"封面生图模型": "Cover image model",
"默认使用同一供应商,可在高级设置中单独配置。": "Uses the same provider by default. Customize it in advanced settings.",
"填写 API Key 后自动选择,也可手动输入模型名": "Selected automatically after entering your API key, or enter a model ID",
"选择供应商并填写 API Key,自动配置分析和封面模型;需要时再调整。": "Choose a provider and enter your API key. Analysis and cover models are selected automatically; adjust them if needed.",
"封面使用其他供应商": "Use another provider for covers",
"默认复用上面的供应商和 API Key。": "Uses the provider and API key above by default.",
"官方供应商": "Official providers",
"OpenAI 官方服务,接口地址已预设。": "Official OpenAI service. The API endpoint is preset.",
"自定义 OpenAI 兼容接口": "Custom OpenAI-compatible API",
"自定义兼容接口": "Custom compatible API",
"适用于 OpenRouter、第三方网关或自建服务。": "For OpenRouter, third-party gateways or self-hosted services.",
"接口设置": "API settings",
"修改接口地址后,请重新填写 API Key。": "Re-enter the API key after changing an endpoint.",
"选择或搜索模型,也可手动输入模型名": "Select or search models, or enter a model ID",
"公开目录预览,填写 API Key 后确认账号可用模型。": "Public catalog preview. Enter an API key to confirm models available to your account.",
"公开目录预览,填写 API Key 后确认账号可用模型。 当前展示缓存目录。": "Public catalog preview from cache. Enter an API key to confirm account availability.",
"账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。": "Could not load account models. Showing a reference catalog; check your key and refresh.",
"Whisper · 本地": "Whisper · Local",
"使用字幕分析;选择多模态模型后可同时理解画面。": "Analyzes subtitles. A multimodal model can also understand video frames.",
"使用独立的画面模型": "Use a separate visual model",
"保存设置后,新任务将使用这个模型。": "New tasks will use this model after you save.",
"共用 API Key,无需重复填写。": "Share the API key without entering it again.",
"准备本地模型": "Prepare local model",
"准备模型": "Prepare model",
"分析模型只能读文字时,可增加一个多模态模型来理解视频画面。": "Add a multimodal model for video frames when the analysis model only reads text.",
"在本机把音频转成字幕,不上传音频,无需 API Key。": "Transcribe audio locally. No audio uploads or API key required.",
"字幕转写": "Subtitle transcription",
"已就绪": "Ready",
"暂时无法读取本地模型状态。": "Local model status is temporarily unavailable.",
"本地模型管理": "Manage local models",
"本地转写组件": "Local transcription components",
"模型已就绪": "Model ready",
"正在下载模型…": "Downloading model…",
"正在准备组件…": "Preparing components…",
"自定义模型类型": "Custom model type",
"补充画面识别": "Add visual understanding",
"视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。": "Used when a video has no subtitles. Larger models generally improve accuracy but require more time and memory.",
"转写模型": "Transcription model",
"首次使用需下载模型和必要组件,之后可在本机转写。": "Download the model and required components once, then transcribe locally.",
"配置高光分析、封面生成和字幕转写使用的模型。": "Choose models for highlight analysis, cover generation, and subtitle transcription.",
"通过专属链接注册,可领取 $5 免费体验额度。": "Register through the dedicated link to claim $5 in free trial credit.",
"$5 免费体验额度": "$5 free trial credit",
"本地": "本地",
"已配置的独立画面模型": "Existing separate vision model",
"视频已有字幕时直接使用;没有字幕时才调用转写模型。": "Use existing subtitles when available; transcribe only when subtitles are missing.",
"改用高光分析模型识别画面": "Use the highlight model for vision",
"会发送抽样画面给多模态模型,费用通常高于仅文字分析。": "Sampled frames will be sent to the multimodal model. This usually costs more than text-only analysis.",
"该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。": "Subtitle timestamps are not integrated for this provider yet. Choose Alibaba Cloud, OpenAI, or local Whisper.",
"选择转写模型": "Select a transcription model",
"需要公网音频地址,暂未接入": "Requires a public audio URL; not integrated yet",
"实时转写接口,暂未接入": "Realtime API; not integrated yet",
"字幕时间戳接口尚未适配": "Subtitle timestamps are not integrated yet",
"模型目录可预览;实际可用性以账号权限和服务商开通状态为准。": "Preview the model catalog. Actual availability depends on your account permissions and activated services.",
"按下面三步完成设置。已有字幕可跳过第 1 步;封面可先使用视频截帧。": "Follow these three steps. Skip step 1 if you already have subtitles; video frames can serve as covers.",
"供应商提供 AI 服务,模型负责具体任务。同一供应商的密钥可以复用。": "Providers host AI services; models perform each task. You can reuse a key for the same provider.",
"把视频中的说话声转成字幕。视频已有字幕时不会调用,可以直接继续第 2 步。": "Turn speech into subtitles. If the video already has subtitles, skip to step 2.",
"必需。选择分析方式,再选择供应商、填写密钥;模型会自动推荐,也可以更换。": "Required. Choose an analysis method, then a provider and key. A model will be suggested; you can change it.",
"服务密钥(API Key)": "Service key (API Key)",
"在供应商网站创建密钥,复制后粘贴到右侧。": "Create a key on the provider website, then copy and paste it here.",
"请填写 API Key": "Enter an API key",
"已复用字幕转写的密钥,点击可修改": "Using the transcription key; click to edit",
"使用自定义型号": "Use custom model",
"可选。使用视频截帧无需配置模型,也不会产生生图费用。": "Optional. Video frames need no model and incur no image generation charges.",
"封面来源": "Cover source",
"AI 生成": "AI generation",
"保存后,返回项目导入视频即可开始。": "After saving, return to Projects and import a video to begin.",
"设置已保存": "Settings saved",
"首次使用,保存后生效": "First setup; save to apply",
"阿里云百炼": "Alibaba Cloud Model Studio",
"阿里云百炼官方服务,接口地址已预设。": "Alibaba Cloud Model Studio. The API endpoint is preset.",
"修改密钥": "Edit key",
"正在获取可用模型…": "사용 가능한 모델을 가져오는 중…",
"已为你选好推荐模型,可随时更换。": "추천 모델이 선택되었습니다. 언제든 변경할 수 있습니다.",
"填写 API Key 后自动选择推荐模型。": "API 키를 입력하면 추천 모델이 자동으로 선택됩니다.",
"画面识别": "화면 인식",
"视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。": "자막이 없는 영상은 먼저 음성을 자막으로 변환합니다. 기본적으로 로컬에서 무료로 처리하며 오디오를 업로드하지 않습니다.",
"转写方式": "전사 방식",
"本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。": "로컬 전사는 무료지만 처음에 모델을 다운로드합니다. 클라우드 전사는 다운로드가 필요 없고 사용량에 따라 과금됩니다.",
"将音频发送给所选服务转写,按服务商计费。": "오디오를 선택한 서비스로 보내 전사합니다. 요금은 제공사 기준입니다.",
"封面使用其他服务": "커버에 다른 서비스 사용",
"默认与 AI 服务共用 API Key,无需重复填写。": "기본적으로 AI 서비스의 API 키를 공유하므로 다시 입력할 필요가 없습니다.",
"{{name}} 没有生图模型,可以为封面单独选一家服务。": "{{name}}에는 이미지 생성 모델이 없습니다. 커버용으로 다른 서비스를 선택할 수 있습니다.",
"已自动选择推荐的生图模型,可更换。": "추천 이미지 생성 모델이 선택되었습니다. 변경할 수 있습니다.",
"参考视频画面": "영상 화면 참고",
"开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。": "켜면 영상 스크린샷 한 장을 커버 모델로 보내 내용에 더 가까운 커버를 만듭니다.",
"AI 服务": "AI 서비스",
"选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。": "서비스를 고르고 API 키를 입력하면 모델이 자동으로 선택됩니다. 전사와 커버도 기본적으로 이 키를 사용합니다.",
"高级": "고급",
"先连接一个 AI 服务": "먼저 AI 서비스를 연결하세요",
"选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。": "서비스를 고르고 API 키를 입력하면 바로 시작할 수 있습니다. 모델은 자동으로 선택되며 나중에 설정에서 조정할 수 있습니다.",
"已选好模型:{{model}}": "선택된 모델: {{model}}",
"等待 API Key": "API 키 대기 중",
"更多选项": "더 많은 옵션",
"连接并保存": "연결 후 저장",
"已复用 AI 服务的密钥": "AI 서비스 키를 재사용 중",
"本机服务无需 API Key,保持默认地址即可。": "로컬 서비스는 API 키가 필요 없습니다. 기본 주소를 그대로 두세요.",
"推荐": "추천",
"请确认 API Key,或直接输入模型名": "API 키를 확인하거나 모델 이름을 직접 입력하세요",
"请先连接 AI 服务,再导入视频。": "영상을 가져오기 전에 AI 서비스를 먼저 연결하세요.",
"稍后再说": "나중에",
"当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。": "현재 모델은 텍스트 전용이라 자막만으로 잘라냅니다. 게임 화면이나 말이 적은 콘텐츠는 멀티모달 모델로 바꾼 뒤 켜는 것을 권장합니다.",
"抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。": "몇 장의 화면을 샘플링해 동작과 장면을 이해합니다. 게임 화면이나 말이 적은 콘텐츠에서는 켜기를 권장합니다(더 정확한 컷, 비용 조금 증가). 끄면 자막만 전송합니다.",
"默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。": "기본적으로 위 서비스가 제목이 들어간 커버를 그립니다. 영상 프레임으로 바꿀 수도 있습니다.",
"请先选择一家 AI 服务": "먼저 AI 서비스를 선택하세요",
"选择一家 AI 服务": "AI 서비스 선택",
"先选择一家 AI 服务": "먼저 AI 서비스를 선택하세요",
"国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。": "중국 직결, 해외 서비스, 로컬 무료 모델 모두 선택할 수 있습니다. 잘 모르면 「추천」부터 시작하세요.",
"先在上面选择一家 AI 服务,生图模型会自动选好。": "먼저 위에서 AI 서비스를 선택하세요. 이미지 생성 모델은 자동으로 선택됩니다.",
"示例": "예시",
"示例项目暂时不可用": "예시 프로젝트를 지금은 사용할 수 없습니다",
"还没有项目": "아직 프로젝트가 없습니다",
"从上方导入一段视频,或者先用示例项目看看出片效果。": "위에서 영상을 가져오거나, 먼저 예시 프로젝트로 결과를 확인해 보세요.",
"用示例项目看看效果": "예시 프로젝트 보기",
"来源": "출처",
"成片设置": "완성본 설정",
"片头标题文字": "오프닝 제목 문구",
"片头标题样式": "오프닝 제목 스타일",
"把字幕压进画面": "자막을 영상에 표시",
"片头标题": "오프닝 제목",
"字幕压进画面": "자막 표시",
"把这段的字幕压进画面": "이 구간의 자막을 영상에 표시합니다.",
"不显示": "표시 안 함",
"片头约 4 秒显示标题文字": "시작 부분에 약 4초간 제목 문구를 표시합니다.",
"示例项目 · 原片只保留了三段": "예시 프로젝트 · 원본은 세 구간만 포함",
"一键竖屏": "세로형 한 번에",
"例如:一个问题,或一句结论": "예: 질문 하나, 또는 한 줄 결론",
"片头样式": "제목 스타일",
"入场动效": "등장 애니메이션",
"翻译与动效以渲染结果为准;支持手动换行。": "번역과 애니메이션은 렌더링 결과 기준이며, 수동 줄바꿈을 지원합니다.",
"选择翻译语言后,渲染时会翻译片头文字与字幕。": "언어를 선택하면 렌더링할 때 오프닝 제목과 자막을 번역합니다.",
"关闭后成片静音。": "끄면 완성본이 무음이 됩니다.",
"保留完整画面,两侧留边或模糊背景。": "전체 화면을 유지하고 양옆은 여백 또는 흐린 배경으로 채웁니다.",
"满屏": "채우기",
"模糊背景": "흐린 배경",
"留边": "여백",
"发布时按平台生成带标题的封面,也可以用视频截帧。": "게시할 때 플랫폼별로 제목이 들어간 커버를 생성합니다. 영상 프레임도 사용할 수 있습니다.",
"导出成片后,在发布页生成带标题的封面。": "내보낸 뒤 게시 페이지에서 제목이 들어간 커버를 생성할 수 있습니다.",
"去生成": "생성하기",
"渲染完成后,在发布页按平台生成带标题的封面。": "렌더링이 끝나면 게시 페이지에서 플랫폼별 제목 커버를 생성할 수 있습니다.",
"‹ 项目": "‹ 프로젝트",
"确认要做什么": "무엇을 만들지 확인",
"AI 先看一遍素材给出建议;你确认后才开始正式剪辑。": "AI가 소재를 살펴본 뒤 제안합니다. 확인한 다음에야 실제 편집이 시작됩니다.",
"分析方式": "분석 방식",
"会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。": "몇 장의 화면을 함께 분석해 동작과 장면을 더 잘 이해합니다. 모델 제공사 기준으로 과금됩니다.",
"当前模型不支持画面分析;换一个多模态模型后可开启。": "현재 모델은 화면 분석을 지원하지 않습니다. 멀티모달 모델로 바꾸면 사용할 수 있습니다.",
"只分析字幕文本,成本最低;没有字幕时会先转写。": "자막 텍스트만 분석하는 가장 저렴한 방식입니다. 자막이 없으면 먼저 전사합니다.",
"字幕 + 画面": "자막 + 화면",
"「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。": "「내용 클립」은 자막만 분석합니다. 화면 분석을 쓰려면 하이라이트나 홍보도 함께 선택하세요.",
"仅字幕": "자막만",
"当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。": "현재 모델은 화면 분석을 지원하지 않습니다. 「자막만」을 선택하거나 설정에서 멀티모달 모델로 바꾸세요.",
"按字幕找到高潮句,保留前后关键过程": "자막에서 하이라이트 문장을 찾아 앞뒤 핵심 과정을 남김",
"把字幕压进画面,整条成片统一样式。": "자막을 영상에 표시하고 전체를 같은 스타일로 맞춥니다.",
"字幕样式": "자막 스타일",
"这里是字幕效果": "자막 미리보기",
"简洁描边": "기본 외곽선",
"粗体大字": "굵은 큰 글자",
"底色字幕条": "배경 자막 바",
"醒目黄字": "눈에 띄는 노란 글자",
"主体铺满画面,自动对准说话的人。": "주체가 화면을 채우며 말하는 사람에 자동으로 맞춥니다.",
"取景": "구도",
"首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。": "처음 한 번 인물 인식 구성 요소(약 {{size}} MB)를 다운로드하면, 이후에는 말하는 사람에 자동으로 맞춥니다.",
"正在下载人物识别组件…": "인물 인식 구성 요소를 다운로드하는 중…",
"正在识别人物位置…": "인물 위치를 인식하는 중…",
"没有识别到人物,请手动调整取景位置。": "인물을 인식하지 못했습니다. 구도를 직접 조정하세요.",
"拖动调整当前镜头的取景位置。": "드래그해서 현재 장면의 구도를 조정합니다.",
"下载并自动取景": "다운로드 후 자동 구도",
"重新自动取景": "다시 자동 구도",
"自动取景": "자동 구도",
"片头文字(可选)": "오프닝 문구(선택)",
"片头文字": "오프닝 문구",
"在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。": "첫 장면에 최대 4초간 큰 글자를 표시합니다. 게임·홍보 콘텐츠에 적합하며, 인터뷰·해설은 비워 두세요.",
"缩略图为设计参考,实际文字效果见左侧预览。": "썸네일은 디자인 참고용입니다. 실제 문구는 왼쪽 미리보기에서 확인하세요.",
"片头": "제목",
"已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。": "말하는 사람을 따라 자동으로 구도를 잡았습니다({{framed}}/{{total}}개 장면, {{switches}}회 전환). 슬라이더를 움직이면 고정 구도로 바뀝니다.",
"正在跟随说话人取景;拖动滑块会改为固定取景。": "말하는 사람을 따라 구도를 잡고 있습니다. 슬라이더를 움직이면 고정 구도로 바뀝니다."
}
+237 -30
View File
@@ -853,9 +853,6 @@
"保持原画幅": "Manter a proporção original",
"9:16 竖屏": "9:16 vertical",
"16:9 横屏": "16:9 horizontal",
"返回导入": "Voltar à importação",
"确认制作内容": "Confirme o que criar",
"导入视频 → 识别与确认 → 开始制作": "Importar vídeo → Identificar e confirmar → Produzir",
"制作已经开始": "A produção começou",
"正在快速识别素材": "Identificando o material",
"可进入项目查看进度。": "Abra o projeto para ver o andamento.",
@@ -941,37 +938,21 @@
"查看原片": "Ver original",
"播放成片": "Reproduzir vídeo exportado",
"渲染预览": "Renderizar prévia",
"改到满意,就导出。": "Ajuste como quiser e exporte.",
"应用竖屏推荐": "Aplicar ajuste vertical",
"成片名称": "Nome do vídeo",
"开头文字": "Texto de abertura",
"可留空;在首镜头最多显示 4 秒": "Opcional; exibido por até 4 segundos na primeira cena",
"标题模板": "Modelo de título",
"缩略图为设计参考,当前文案效果见预览。": "As miniaturas mostram o design. A prévia mostra seu texto.",
"调整文字样式": "Ajustar estilo do texto",
"样式版本": "Versão do estilo",
"强调色": "Cor de destaque",
"标题强调色": "Cor de destaque do título",
"文字大小": "Tamanho do texto",
"文字位置": "Posição do texto",
"开启入场动效": "Ativar animação de entrada",
"预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。": "A prévia usa a camada real. Tradução e animação aparecem na renderização; aceita quebras de linha.",
"输出文字语言": "Idioma do texto de saída",
"选择翻译语言后,渲染时会翻译开头文字与所选原字幕。": "O texto de abertura e as legendas selecionadas são traduzidos na renderização.",
"烧录原字幕": "Incorporar legendas originais",
"声音": "Áudio",
"保留原声": "Manter áudio original",
"画面设置": "Configurações de imagem",
"原画幅": "Proporção original",
"构图": "Enquadramento",
"完整画面 · 留边": "Quadro completo · Com barras",
"完整画面 · 模糊背景": "Quadro completo · Fundo desfocado",
"满屏取景": "Preencher quadro",
"取景位置 · 左右移动": "Posição · Mover para os lados",
"取景位置": "Posição do enquadramento",
"主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。": "Preencha o quadro. Ajuste horizontalmente e confira personagem, obstáculos e HUD.",
"当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。": "Manter o quadro horizontal cria barras. Escolha Preencher quadro para preencher a saída vertical.",
"保留原画面构图。": "Manter o enquadramento original.",
"文案修改要求": "Instruções para editar o texto",
"告诉 AI 怎么改文案,例如:开头改成一个简短的问题": "Diga à IA como alterar, ex.: começar com uma pergunta curta",
"改一版文案": "Reescrever texto",
@@ -997,10 +978,8 @@
"成片": "Vídeo",
"格式": "Formato",
"保持原尺寸": "Manter dimensões originais",
"开头包装": "Estilo de abertura",
"原声": "Áudio original",
"保留": "Manter",
"烧录已有字幕": "Incorporar legendas existentes",
"当前版本已渲染完成,可以直接下载。": "Esta versão está renderizada e pronta para baixar.",
"任务在后台继续,关闭面板不会取消渲染。": "A renderização continua em segundo plano ao fechar este painel.",
"追加一个镜头": "Adicionar uma cena",
@@ -1100,21 +1079,13 @@
"制作任务未能启动,请重试确认;原素材与已有成片已保留": "Não foi possível iniciar a criação. Confirme novamente; o original e as exportações existentes foram preservados.",
"这个切片当前播放器无法解码,请下载后用系统播放器打开。": "Este clipe não pode ser decodificado no player. Baixe e abra no player do sistema.",
"切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。": "A análise de clipes desliga o modo de raciocínio do DeepSeek para não cobrar raciocínios longos como saída. Um vídeo longo ainda gera várias chamadas, cerca de uma a cada 30 minutos de legendas e uma por etapa. Atualizar a página não executa de novo; iniciar o processamento outra vez sim.",
"分析方式": "Método de análise",
"本次分析方式": "Análise desta importação",
"默认分析方式": "Método padrão",
"字幕分析 · 低成本": "Legendas · Menor custo",
"视觉分析": "Análise visual",
"智能选择": "Seleção inteligente",
"配置视觉模型不会自动切换分析方式。": "Configurar um modelo visual não altera sua preferência de análise.",
"仅分析字幕文本;无字幕时需要转写。": "Analisa apenas as legendas; sem elas, é necessária transcrição.",
"发送抽样画面与文本,按模型服务商计费。": "Envia quadros amostrados e texto. O provedor pode cobrar pelo uso.",
"允许付费视觉初筛": "Permitir triagem visual paga",
"关闭时不调用视觉初筛,确认后才开始正式分析。": "Desativado: sem triagem visual. A análise completa começa após a confirmação.",
"分析偏好已保存": "Preferência salva",
"视觉模型不可用,请前往模型设置。": "Modelo visual indisponível. Abra as configurações do modelo.",
"按字幕链路处理,未调用视觉初筛;正式制作时使用已有字幕或转写,尚未验证语音内容。": "Usa legendas sem triagem visual. A produção usará legendas ou transcrição; a fala ainda não foi verificada.",
"内容切片使用字幕分析,请调整分析方式或制作类型。": "Clipes de conteúdo usam legendas. Ajuste o método ou os tipos.",
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Promoções com legendas fazem uma chamada extra ao modelo de texto, cobrada pelo provedor. Revise antes de usar.",
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Legendas utilizáveis encontradas. Sugere-se edição semântica; qualidade não avaliada e nenhum modelo chamado.",
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "Sem legendas. É necessária transcrição ou arquivo; fala utilizável ainda não confirmada. Você pode escolher os tipos manualmente.",
@@ -1193,5 +1164,241 @@
"同一服务商": "Mesmo provedor",
"用其他服务生图…": "Usar outro serviço de imagem…",
"生图服务": "Serviço de imagem",
"和上面是同一家服务时可以留空,自动复用。": "Deixe vazio para reutilizar a chave se for o mesmo serviço acima."
"和上面是同一家服务时可以留空,自动复用。": "Deixe vazio para reutilizar a chave se for o mesmo serviço acima.",
"高光分析": "Highlight analysis",
"画面理解": "Visual understanding",
"请填写连接名称": "Enter a connection name",
"请填写完整的 HTTP(S) 接口地址": "Enter a complete HTTP(S) API URL",
"服务地址已改变,请重新填写 API Key": "The endpoint changed. Enter the API key again.",
"请先选择模型": "Select a model first",
"连接测试失败,请检查接口、密钥和模型": "Connection test failed. Check the endpoint, key and model.",
"AI 模型": "AI models",
"加载中…": "Loading…",
"模型服务": "Model services",
"选择模型服务": "Select a model service",
"添加模型服务…": "Add a model service…",
"自动获取模型列表,也可以直接输入模型 ID。": "Models are discovered automatically. You can also enter a model ID.",
"已从服务获取模型列表": "Model list fetched from the service",
"使用缓存的模型列表": "Using the cached model list",
"模型列表加载中…": "Loading model list…",
"刷新列表": "Refresh models",
"编辑服务": "Edit service",
"自定义模型能力": "Custom model capabilities",
"模型能力": "Model capability",
"已知模型自动识别;自定义型号可手动指定。": "Known models are identified automatically. You can specify custom model capabilities.",
"自动识别": "Detect automatically",
"测试会发起少量模型调用,按服务商计费。": "Tests make small model requests billed by your provider.",
"接入模型服务,为高光分析和封面生成分别选择模型。": "Connect model services and choose models independently for highlights and covers.",
"尚未使用": "Not in use",
"编辑": "Edit",
"使用中的服务需先更换模型引用才能移除": "Reassign models before removing a service in use",
"添加模型服务": "Add model service",
"使用字幕分析;没有字幕时需要先转写。": "Analyze subtitles. Transcription is needed when subtitles are missing.",
"可使用字幕和抽样画面分析,画面调用按服务商计费。": "Can analyze subtitles and sampled frames. Visual requests are billed by your provider.",
"尚未确认画面能力,当前使用字幕分析;自定义模型可在下方指定能力。": "Visual capability is not confirmed; subtitle analysis is used. Specify custom capabilities below.",
"封面方式": "Cover method",
"视频截帧": "Video frame",
"AI 生图": "AI image generation",
"生图失败时使用视频截帧;不会自动改用其他供应商。": "If image generation fails, use a video frame. Providers are never switched automatically.",
"单独指定画面理解模型": "Use a separate vision model",
"默认复用高光分析模型,也可以选择其他服务。": "Reuse the highlight model by default, or choose another service.",
"导入时允许画面初筛": "Allow visual screening on import",
"自动推荐制作类型时发送抽样画面,按服务商计费。": "Send sampled frames to recommend a workflow. Provider charges apply.",
"本地 Whisper;安装和下载操作立即执行。": "Local Whisper. Installation and download actions take effect immediately.",
"有未保存的更改": "Unsaved changes",
"当前配置": "Current configuration",
"保存设置": "Save settings",
"同一供应商可以添加多个账号;服务连接可被多个功能复用。": "Add multiple accounts for one provider. Connections can be shared across features.",
"应用到配置": "Apply to draft",
"修改此连接会影响:{{uses}}": "Changing this connection affects: {{uses}}",
"连接名称": "Connection name",
"例如:个人账号、本机服务": "For example: Personal account, Local server",
"供应商": "Provider",
"清除密钥": "Clear key",
"获取 API Key": "Get an API key",
"留空使用供应商默认地址": "Leave blank to use the provider default",
"生图接口设置": "Image API settings",
"自定义生图服务需与所选接口协议兼容。": "Custom image services must support the selected API protocol.",
"接口协议": "API protocol",
"使用供应商默认协议": "Use provider default",
"生图接口地址": "Image API URL",
"模型列表刷新失败,正在使用上次成功的列表": "Could not refresh models. Using the last successful list.",
"尚未获取服务模型列表,可以重试或手动输入模型 ID": "Could not fetch models. Retry or enter a model ID manually.",
"高光分析模型": "Highlight analysis model",
"画面理解模型": "Vision model",
"封面生图模型": "Cover image model",
"默认使用同一供应商,可在高级设置中单独配置。": "Uses the same provider by default. Customize it in advanced settings.",
"填写 API Key 后自动选择,也可手动输入模型名": "Selected automatically after entering your API key, or enter a model ID",
"选择供应商并填写 API Key,自动配置分析和封面模型;需要时再调整。": "Choose a provider and enter your API key. Analysis and cover models are selected automatically; adjust them if needed.",
"封面使用其他供应商": "Use another provider for covers",
"默认复用上面的供应商和 API Key。": "Uses the provider and API key above by default.",
"官方供应商": "Official providers",
"OpenAI 官方服务,接口地址已预设。": "Official OpenAI service. The API endpoint is preset.",
"自定义 OpenAI 兼容接口": "Custom OpenAI-compatible API",
"自定义兼容接口": "Custom compatible API",
"适用于 OpenRouter、第三方网关或自建服务。": "For OpenRouter, third-party gateways or self-hosted services.",
"接口设置": "API settings",
"修改接口地址后,请重新填写 API Key。": "Re-enter the API key after changing an endpoint.",
"选择或搜索模型,也可手动输入模型名": "Select or search models, or enter a model ID",
"公开目录预览,填写 API Key 后确认账号可用模型。": "Public catalog preview. Enter an API key to confirm models available to your account.",
"公开目录预览,填写 API Key 后确认账号可用模型。 当前展示缓存目录。": "Public catalog preview from cache. Enter an API key to confirm account availability.",
"账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。": "Could not load account models. Showing a reference catalog; check your key and refresh.",
"Whisper · 本地": "Whisper · Local",
"使用字幕分析;选择多模态模型后可同时理解画面。": "Analyzes subtitles. A multimodal model can also understand video frames.",
"使用独立的画面模型": "Use a separate visual model",
"保存设置后,新任务将使用这个模型。": "New tasks will use this model after you save.",
"共用 API Key,无需重复填写。": "Share the API key without entering it again.",
"准备本地模型": "Prepare local model",
"准备模型": "Prepare model",
"分析模型只能读文字时,可增加一个多模态模型来理解视频画面。": "Add a multimodal model for video frames when the analysis model only reads text.",
"在本机把音频转成字幕,不上传音频,无需 API Key。": "Transcribe audio locally. No audio uploads or API key required.",
"字幕转写": "Subtitle transcription",
"已就绪": "Ready",
"暂时无法读取本地模型状态。": "Local model status is temporarily unavailable.",
"本地模型管理": "Manage local models",
"本地转写组件": "Local transcription components",
"模型已就绪": "Model ready",
"正在下载模型…": "Downloading model…",
"正在准备组件…": "Preparing components…",
"自定义模型类型": "Custom model type",
"补充画面识别": "Add visual understanding",
"视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。": "Used when a video has no subtitles. Larger models generally improve accuracy but require more time and memory.",
"转写模型": "Transcription model",
"首次使用需下载模型和必要组件,之后可在本机转写。": "Download the model and required components once, then transcribe locally.",
"配置高光分析、封面生成和字幕转写使用的模型。": "Choose models for highlight analysis, cover generation, and subtitle transcription.",
"通过专属链接注册,可领取 $5 免费体验额度。": "Register through the dedicated link to claim $5 in free trial credit.",
"$5 免费体验额度": "$5 free trial credit",
"本地": "本地",
"已配置的独立画面模型": "Existing separate vision model",
"视频已有字幕时直接使用;没有字幕时才调用转写模型。": "Use existing subtitles when available; transcribe only when subtitles are missing.",
"改用高光分析模型识别画面": "Use the highlight model for vision",
"会发送抽样画面给多模态模型,费用通常高于仅文字分析。": "Sampled frames will be sent to the multimodal model. This usually costs more than text-only analysis.",
"该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。": "Subtitle timestamps are not integrated for this provider yet. Choose Alibaba Cloud, OpenAI, or local Whisper.",
"选择转写模型": "Select a transcription model",
"需要公网音频地址,暂未接入": "Requires a public audio URL; not integrated yet",
"实时转写接口,暂未接入": "Realtime API; not integrated yet",
"字幕时间戳接口尚未适配": "Subtitle timestamps are not integrated yet",
"模型目录可预览;实际可用性以账号权限和服务商开通状态为准。": "Preview the model catalog. Actual availability depends on your account permissions and activated services.",
"按下面三步完成设置。已有字幕可跳过第 1 步;封面可先使用视频截帧。": "Follow these three steps. Skip step 1 if you already have subtitles; video frames can serve as covers.",
"供应商提供 AI 服务,模型负责具体任务。同一供应商的密钥可以复用。": "Providers host AI services; models perform each task. You can reuse a key for the same provider.",
"把视频中的说话声转成字幕。视频已有字幕时不会调用,可以直接继续第 2 步。": "Turn speech into subtitles. If the video already has subtitles, skip to step 2.",
"必需。选择分析方式,再选择供应商、填写密钥;模型会自动推荐,也可以更换。": "Required. Choose an analysis method, then a provider and key. A model will be suggested; you can change it.",
"服务密钥(API Key)": "Service key (API Key)",
"在供应商网站创建密钥,复制后粘贴到右侧。": "Create a key on the provider website, then copy and paste it here.",
"请填写 API Key": "Enter an API key",
"已复用字幕转写的密钥,点击可修改": "Using the transcription key; click to edit",
"使用自定义型号": "Use custom model",
"可选。使用视频截帧无需配置模型,也不会产生生图费用。": "Optional. Video frames need no model and incur no image generation charges.",
"封面来源": "Cover source",
"AI 生成": "AI generation",
"保存后,返回项目导入视频即可开始。": "After saving, return to Projects and import a video to begin.",
"设置已保存": "Settings saved",
"首次使用,保存后生效": "First setup; save to apply",
"阿里云百炼": "Alibaba Cloud Model Studio",
"阿里云百炼官方服务,接口地址已预设。": "Alibaba Cloud Model Studio. The API endpoint is preset.",
"修改密钥": "Edit key",
"正在获取可用模型…": "Buscando modelos disponíveis…",
"已为你选好推荐模型,可随时更换。": "Um modelo recomendado foi escolhido; troque quando quiser.",
"填写 API Key 后自动选择推荐模型。": "Ao informar a chave de API, um modelo recomendado é escolhido automaticamente.",
"画面识别": "Análise de imagem",
"视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。": "Se o vídeo não tiver legendas, a fala é transcrita primeiro. Por padrão isso roda localmente e de graça; nenhum áudio é enviado.",
"转写方式": "Transcrição",
"本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。": "A transcrição local é gratuita, mas baixa um modelo na primeira vez; a em nuvem não precisa de download e é cobrada por uso.",
"将音频发送给所选服务转写,按服务商计费。": "O áudio é enviado ao serviço escolhido para transcrição, cobrado pelo provedor.",
"封面使用其他服务": "Usar outro serviço para capas",
"默认与 AI 服务共用 API Key,无需重复填写。": "Por padrão compartilha a chave do serviço de IA; nada mais a preencher.",
"{{name}} 没有生图模型,可以为封面单独选一家服务。": "{{name}} não tem modelo de imagem; escolha outro serviço para as capas.",
"已自动选择推荐的生图模型,可更换。": "Um modelo de imagem recomendado foi escolhido; troque se quiser.",
"参考视频画面": "Usar um quadro como referência",
"开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。": "Envia um quadro do vídeo ao modelo de capa para que o resultado fique mais fiel ao conteúdo.",
"AI 服务": "Serviço de IA",
"选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。": "Escolha um serviço e cole a chave de API; o modelo é escolhido para você. Transcrição e capas reutilizam essa chave por padrão.",
"高级": "Avançado",
"先连接一个 AI 服务": "Conecte um serviço de IA primeiro",
"选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。": "Escolha um serviço e cole a chave de API para começar; o modelo é escolhido para você. Ajuste depois em Configurações.",
"已选好模型:{{model}}": "Modelo escolhido: {{model}}",
"等待 API Key": "Aguardando a chave de API",
"更多选项": "Mais opções",
"连接并保存": "Conectar e salvar",
"已复用 AI 服务的密钥": "Reutilizando a chave do serviço de IA",
"本机服务无需 API Key,保持默认地址即可。": "Serviços locais não precisam de chave de API; mantenha o endereço padrão.",
"推荐": "Recomendado",
"请确认 API Key,或直接输入模型名": "Verifique a chave de API ou digite um nome de modelo",
"请先连接 AI 服务,再导入视频。": "Conecte um serviço de IA antes de importar um vídeo.",
"稍后再说": "Mais tarde",
"当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。": "O modelo atual é apenas texto, então os cortes usam só as legendas. Para gameplay ou conteúdo com pouca fala, troque para um modelo multimodal e ative isto.",
"抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。": "Amostra alguns quadros para entender ações e cenas. Recomendado para gameplay ou conteúdo com pouca fala — cortes mais precisos, custo um pouco maior. Desligado, envia apenas legendas.",
"默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。": "Por padrão o serviço acima desenha uma capa com título; você também pode usar um quadro do vídeo.",
"请先选择一家 AI 服务": "Escolha um serviço de IA primeiro",
"选择一家 AI 服务": "Escolha um serviço de IA",
"先选择一家 AI 服务": "Escolha um serviço de IA para começar",
"国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。": "Serviços da China, internacionais ou modelos locais gratuitos, todos disponíveis; em dúvida, comece por “Recomendado”.",
"先在上面选择一家 AI 服务,生图模型会自动选好。": "Escolha um serviço de IA acima primeiro; o modelo de imagem é escolhido automaticamente.",
"示例": "Exemplo",
"示例项目暂时不可用": "O projeto de exemplo não está disponível no momento",
"还没有项目": "Ainda não há projetos",
"从上方导入一段视频,或者先用示例项目看看出片效果。": "Importe um vídeo acima ou abra primeiro o projeto de exemplo para ver o resultado.",
"用示例项目看看效果": "Ver o projeto de exemplo",
"来源": "Fonte",
"成片设置": "Configurações do vídeo final",
"片头标题文字": "Texto do título de abertura",
"片头标题样式": "Estilo do título de abertura",
"把字幕压进画面": "Mostrar legendas no vídeo",
"片头标题": "Título de abertura",
"字幕压进画面": "Legendas no vídeo",
"把这段的字幕压进画面": "Mostra as legendas deste trecho no vídeo.",
"不显示": "Não mostrar",
"片头约 4 秒显示标题文字": "Mostra o texto do título por cerca de 4 segundos no início.",
"示例项目 · 原片只保留了三段": "Projeto de exemplo · o vídeo original mantém só três trechos",
"一键竖屏": "Vertical em um clique",
"例如:一个问题,或一句结论": "Por exemplo: uma pergunta ou uma conclusão em uma linha",
"片头样式": "Estilo do título",
"入场动效": "Animação de entrada",
"翻译与动效以渲染结果为准;支持手动换行。": "Tradução e animação aparecem no vídeo renderizado; quebras de linha manuais são aceitas.",
"选择翻译语言后,渲染时会翻译片头文字与字幕。": "Ao escolher um idioma, o título de abertura e as legendas são traduzidos na renderização.",
"关闭后成片静音。": "Desligado, o vídeo final fica mudo.",
"保留完整画面,两侧留边或模糊背景。": "Mantém a imagem inteira, com barras ou fundo desfocado nas laterais.",
"满屏": "Preencher",
"模糊背景": "Fundo desfocado",
"留边": "Barras",
"发布时按平台生成带标题的封面,也可以用视频截帧。": "Ao publicar, uma capa com título é gerada por plataforma; um quadro do vídeo também serve.",
"导出成片后,在发布页生成带标题的封面。": "Depois de exportar, gere uma capa com título na página de publicação.",
"去生成": "Gerar",
"渲染完成后,在发布页按平台生成带标题的封面。": "Depois de renderizar, gere uma capa com título por plataforma na página de publicação.",
"‹ 项目": "‹ Projetos",
"确认要做什么": "Confirme o que criar",
"AI 先看一遍素材给出建议;你确认后才开始正式剪辑。": "A IA analisa o material e sugere um plano; a edição só começa depois que você confirmar.",
"分析方式": "Análise",
"会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。": "Analisa também alguns quadros para entender melhor ações e cenas; cobrado pelo provedor do modelo.",
"当前模型不支持画面分析;换一个多模态模型后可开启。": "O modelo atual não analisa imagens; troque para um modelo multimodal para ativar.",
"只分析字幕文本,成本最低;没有字幕时会先转写。": "Analisa apenas o texto das legendas, a opção mais barata; sem legendas, transcreve primeiro.",
"字幕 + 画面": "Legendas + imagens",
"「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。": "“Clipes de conteúdo” usa só legendas; para analisar imagens, marque também Destaques ou Promo.",
"仅字幕": "Só legendas",
"当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。": "O modelo atual não analisa imagens. Escolha “Só legendas” ou troque para um modelo multimodal nas Configurações.",
"按字幕找到高潮句,保留前后关键过程": "Encontra os momentos altos pelas legendas e mantém o contexto essencial",
"把字幕压进画面,整条成片统一样式。": "Mostra as legendas no vídeo com um só estilo em todo o clipe.",
"字幕样式": "Estilo das legendas",
"这里是字幕效果": "Prévia da legenda",
"简洁描边": "Contorno simples",
"粗体大字": "Negrito grande",
"底色字幕条": "Barra de legenda",
"醒目黄字": "Amarelo em destaque",
"主体铺满画面,自动对准说话的人。": "O assunto preenche o quadro, centrado em quem está falando.",
"取景": "Enquadramento",
"首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。": "No primeiro uso, baixa o componente de detecção de pessoas (cerca de {{size}} MB); depois o enquadramento segue quem fala automaticamente.",
"正在下载人物识别组件…": "Baixando o componente de detecção de pessoas…",
"正在识别人物位置…": "Localizando pessoas no quadro…",
"没有识别到人物,请手动调整取景位置。": "Ninguém foi detectado; ajuste o enquadramento manualmente.",
"拖动调整当前镜头的取景位置。": "Arraste para ajustar o enquadramento da cena atual.",
"下载并自动取景": "Baixar e enquadrar",
"重新自动取景": "Enquadrar de novo",
"自动取景": "Enquadramento automático",
"片头文字(可选)": "Texto de abertura (opcional)",
"片头文字": "Texto de abertura",
"在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。": "Texto grande sobre a primeira cena por até 4 segundos; bom para gameplay e promos, deixe vazio em entrevistas e explicações.",
"缩略图为设计参考,实际文字效果见左侧预览。": "As miniaturas são referências de design; o texto real aparece na prévia à esquerda.",
"片头": "Título",
"已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。": "O enquadramento segue quem fala ({{framed}}/{{total}} cenas, {{switches}} trocas). Mover o controle fixa o enquadramento.",
"正在跟随说话人取景;拖动滑块会改为固定取景。": "O enquadramento segue quem fala; mover o controle fixa o enquadramento."
}
+237 -30
View File
@@ -853,9 +853,6 @@
"保持原画幅": "Сохранить исходный формат",
"9:16 竖屏": "9:16 вертикально",
"16:9 横屏": "16:9 горизонтально",
"返回导入": "Назад к импорту",
"确认制作内容": "Подтвердите, что создать",
"导入视频 → 识别与确认 → 开始制作": "Импорт видео → Распознавание и подтверждение → Монтаж",
"制作已经开始": "Монтаж начат",
"正在快速识别素材": "Быстрое распознавание материала",
"可进入项目查看进度。": "Откройте проект, чтобы увидеть прогресс.",
@@ -941,37 +938,21 @@
"查看原片": "Посмотреть исходник",
"播放成片": "Воспроизвести готовое видео",
"渲染预览": "Создать предпросмотр",
"改到满意,就导出。": "Настройте по своему вкусу и экспортируйте.",
"应用竖屏推荐": "Применить вертикальный пресет",
"成片名称": "Название видео",
"开头文字": "Текст в начале",
"可留空;在首镜头最多显示 4 秒": "Необязательно; до 4 секунд в первой сцене",
"标题模板": "Шаблон заголовка",
"缩略图为设计参考,当前文案效果见预览。": "Миниатюры показывают дизайн. Ваш текст виден в предпросмотре.",
"调整文字样式": "Настроить стиль текста",
"样式版本": "Версия стиля",
"强调色": "Акцентный цвет",
"标题强调色": "Акцентный цвет заголовка",
"文字大小": "Размер текста",
"文字位置": "Положение текста",
"开启入场动效": "Включить анимацию появления",
"预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。": "Предпросмотр использует реальный слой текста. Перевод и анимация видны после рендеринга; переносы строк поддерживаются.",
"输出文字语言": "Язык текста на выходе",
"选择翻译语言后,渲染时会翻译开头文字与所选原字幕。": "Вступительный текст и выбранные субтитры переводятся при рендеринге.",
"烧录原字幕": "Встроить исходные субтитры",
"声音": "Звук",
"保留原声": "Сохранить исходный звук",
"画面设置": "Настройки кадра",
"原画幅": "Исходный формат",
"构图": "Кадрирование",
"完整画面 · 留边": "Весь кадр · С полями",
"完整画面 · 模糊背景": "Весь кадр · Размытый фон",
"满屏取景": "Заполнить кадр",
"取景位置 · 左右移动": "Положение кадра · Сдвиг влево или вправо",
"取景位置": "Положение кадра",
"主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。": "Заполните кадр. Сдвиньте по горизонтали и проверьте персонажа, препятствия и HUD.",
"当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。": "Сохранение широкого кадра добавит поля. Выберите заполнение кадра для вертикального видео.",
"保留原画面构图。": "Сохранить исходное кадрирование.",
"文案修改要求": "Пожелания к правке текста",
"告诉 AI 怎么改文案,例如:开头改成一个简短的问题": "Опишите правку ИИ, например: начни с короткого вопроса",
"改一版文案": "Переписать текст",
@@ -997,10 +978,8 @@
"成片": "Видео",
"格式": "Формат",
"保持原尺寸": "Сохранить исходный размер",
"开头包装": "Оформление вступления",
"原声": "Исходный звук",
"保留": "Сохранить",
"烧录已有字幕": "Встроить готовые субтитры",
"当前版本已渲染完成,可以直接下载。": "Эта версия готова к скачиванию.",
"任务在后台继续,关闭面板不会取消渲染。": "Рендеринг продолжится в фоне после закрытия панели.",
"追加一个镜头": "Добавить сцену",
@@ -1100,21 +1079,13 @@
"制作任务未能启动,请重试确认;原素材与已有成片已保留": "Не удалось начать создание. Подтвердите ещё раз; исходник и предыдущие результаты сохранены.",
"这个切片当前播放器无法解码,请下载后用系统播放器打开。": "Плеер не может декодировать этот клип. Скачайте его и откройте в системном плеере.",
"切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。": "Анализ клипов отключает режим размышления DeepSeek, чтобы длинные цепочки не тарифицировались как вывод. Длинное видео всё равно даёт несколько вызовов: примерно по одному на 30 минут субтитров и по одному на шаг. Обновление страницы не запускает анализ снова; повторный запуск обработки — да.",
"分析方式": "Способ анализа",
"本次分析方式": "Анализ этого импорта",
"默认分析方式": "Способ по умолчанию",
"字幕分析 · 低成本": "Анализ субтитров · Экономно",
"视觉分析": "Визуальный анализ",
"智能选择": "Умный выбор",
"配置视觉模型不会自动切换分析方式。": "Настройка визуальной модели не меняет способ анализа.",
"仅分析字幕文本;无字幕时需要转写。": "Анализирует текст субтитров; при их отсутствии нужна транскрипция.",
"发送抽样画面与文本,按模型服务商计费。": "Отправляет выборку кадров и текст. Провайдер модели может взимать плату.",
"允许付费视觉初筛": "Разрешить платный визуальный отбор",
"关闭时不调用视觉初筛,确认后才开始正式分析。": "Если выключено, визуальный отбор не выполняется. Полный анализ — после подтверждения.",
"分析偏好已保存": "Настройки анализа сохранены",
"视觉模型不可用,请前往模型设置。": "Визуальная модель недоступна. Откройте настройки модели.",
"按字幕链路处理,未调用视觉初筛;正式制作时使用已有字幕或转写,尚未验证语音内容。": "Используется обработка субтитров без визуального отбора. При создании используются субтитры или транскрипция; речь ещё не проверена.",
"内容切片使用字幕分析,请调整分析方式或制作类型。": "Для фрагментов используется анализ субтитров. Измените способ или тип.",
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "Промо по субтитрам делает дополнительный запрос к текстовой модели для текста. Оплата по тарифу провайдера; проверьте перед использованием.",
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "Найдены пригодные субтитры. Предлагается смысловой монтаж; качество не оценивалось, модель не вызывалась.",
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "Субтитры не найдены. Нужна транскрипция или файл; наличие пригодной речи не подтверждено. Типы можно выбрать вручную.",
@@ -1193,5 +1164,241 @@
"同一服务商": "Тот же провайдер",
"用其他服务生图…": "Другой сервис изображений…",
"生图服务": "Сервис изображений",
"和上面是同一家服务时可以留空,自动复用。": "Оставьте пустым, чтобы использовать ключ, если сервис тот же."
"和上面是同一家服务时可以留空,自动复用。": "Оставьте пустым, чтобы использовать ключ, если сервис тот же.",
"高光分析": "Highlight analysis",
"画面理解": "Visual understanding",
"请填写连接名称": "Enter a connection name",
"请填写完整的 HTTP(S) 接口地址": "Enter a complete HTTP(S) API URL",
"服务地址已改变,请重新填写 API Key": "The endpoint changed. Enter the API key again.",
"请先选择模型": "Select a model first",
"连接测试失败,请检查接口、密钥和模型": "Connection test failed. Check the endpoint, key and model.",
"AI 模型": "AI models",
"加载中…": "Loading…",
"模型服务": "Model services",
"选择模型服务": "Select a model service",
"添加模型服务…": "Add a model service…",
"自动获取模型列表,也可以直接输入模型 ID。": "Models are discovered automatically. You can also enter a model ID.",
"已从服务获取模型列表": "Model list fetched from the service",
"使用缓存的模型列表": "Using the cached model list",
"模型列表加载中…": "Loading model list…",
"刷新列表": "Refresh models",
"编辑服务": "Edit service",
"自定义模型能力": "Custom model capabilities",
"模型能力": "Model capability",
"已知模型自动识别;自定义型号可手动指定。": "Known models are identified automatically. You can specify custom model capabilities.",
"自动识别": "Detect automatically",
"测试会发起少量模型调用,按服务商计费。": "Tests make small model requests billed by your provider.",
"接入模型服务,为高光分析和封面生成分别选择模型。": "Connect model services and choose models independently for highlights and covers.",
"尚未使用": "Not in use",
"编辑": "Edit",
"使用中的服务需先更换模型引用才能移除": "Reassign models before removing a service in use",
"添加模型服务": "Add model service",
"使用字幕分析;没有字幕时需要先转写。": "Analyze subtitles. Transcription is needed when subtitles are missing.",
"可使用字幕和抽样画面分析,画面调用按服务商计费。": "Can analyze subtitles and sampled frames. Visual requests are billed by your provider.",
"尚未确认画面能力,当前使用字幕分析;自定义模型可在下方指定能力。": "Visual capability is not confirmed; subtitle analysis is used. Specify custom capabilities below.",
"封面方式": "Cover method",
"视频截帧": "Video frame",
"AI 生图": "AI image generation",
"生图失败时使用视频截帧;不会自动改用其他供应商。": "If image generation fails, use a video frame. Providers are never switched automatically.",
"单独指定画面理解模型": "Use a separate vision model",
"默认复用高光分析模型,也可以选择其他服务。": "Reuse the highlight model by default, or choose another service.",
"导入时允许画面初筛": "Allow visual screening on import",
"自动推荐制作类型时发送抽样画面,按服务商计费。": "Send sampled frames to recommend a workflow. Provider charges apply.",
"本地 Whisper;安装和下载操作立即执行。": "Local Whisper. Installation and download actions take effect immediately.",
"有未保存的更改": "Unsaved changes",
"当前配置": "Current configuration",
"保存设置": "Save settings",
"同一供应商可以添加多个账号;服务连接可被多个功能复用。": "Add multiple accounts for one provider. Connections can be shared across features.",
"应用到配置": "Apply to draft",
"修改此连接会影响:{{uses}}": "Changing this connection affects: {{uses}}",
"连接名称": "Connection name",
"例如:个人账号、本机服务": "For example: Personal account, Local server",
"供应商": "Provider",
"清除密钥": "Clear key",
"获取 API Key": "Get an API key",
"留空使用供应商默认地址": "Leave blank to use the provider default",
"生图接口设置": "Image API settings",
"自定义生图服务需与所选接口协议兼容。": "Custom image services must support the selected API protocol.",
"接口协议": "API protocol",
"使用供应商默认协议": "Use provider default",
"生图接口地址": "Image API URL",
"模型列表刷新失败,正在使用上次成功的列表": "Could not refresh models. Using the last successful list.",
"尚未获取服务模型列表,可以重试或手动输入模型 ID": "Could not fetch models. Retry or enter a model ID manually.",
"高光分析模型": "Highlight analysis model",
"画面理解模型": "Vision model",
"封面生图模型": "Cover image model",
"默认使用同一供应商,可在高级设置中单独配置。": "Uses the same provider by default. Customize it in advanced settings.",
"填写 API Key 后自动选择,也可手动输入模型名": "Selected automatically after entering your API key, or enter a model ID",
"选择供应商并填写 API Key,自动配置分析和封面模型;需要时再调整。": "Choose a provider and enter your API key. Analysis and cover models are selected automatically; adjust them if needed.",
"封面使用其他供应商": "Use another provider for covers",
"默认复用上面的供应商和 API Key。": "Uses the provider and API key above by default.",
"官方供应商": "Official providers",
"OpenAI 官方服务,接口地址已预设。": "Official OpenAI service. The API endpoint is preset.",
"自定义 OpenAI 兼容接口": "Custom OpenAI-compatible API",
"自定义兼容接口": "Custom compatible API",
"适用于 OpenRouter、第三方网关或自建服务。": "For OpenRouter, third-party gateways or self-hosted services.",
"接口设置": "API settings",
"修改接口地址后,请重新填写 API Key。": "Re-enter the API key after changing an endpoint.",
"选择或搜索模型,也可手动输入模型名": "Select or search models, or enter a model ID",
"公开目录预览,填写 API Key 后确认账号可用模型。": "Public catalog preview. Enter an API key to confirm models available to your account.",
"公开目录预览,填写 API Key 后确认账号可用模型。 当前展示缓存目录。": "Public catalog preview from cache. Enter an API key to confirm account availability.",
"账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。": "Could not load account models. Showing a reference catalog; check your key and refresh.",
"Whisper · 本地": "Whisper · Local",
"使用字幕分析;选择多模态模型后可同时理解画面。": "Analyzes subtitles. A multimodal model can also understand video frames.",
"使用独立的画面模型": "Use a separate visual model",
"保存设置后,新任务将使用这个模型。": "New tasks will use this model after you save.",
"共用 API Key,无需重复填写。": "Share the API key without entering it again.",
"准备本地模型": "Prepare local model",
"准备模型": "Prepare model",
"分析模型只能读文字时,可增加一个多模态模型来理解视频画面。": "Add a multimodal model for video frames when the analysis model only reads text.",
"在本机把音频转成字幕,不上传音频,无需 API Key。": "Transcribe audio locally. No audio uploads or API key required.",
"字幕转写": "Subtitle transcription",
"已就绪": "Ready",
"暂时无法读取本地模型状态。": "Local model status is temporarily unavailable.",
"本地模型管理": "Manage local models",
"本地转写组件": "Local transcription components",
"模型已就绪": "Model ready",
"正在下载模型…": "Downloading model…",
"正在准备组件…": "Preparing components…",
"自定义模型类型": "Custom model type",
"补充画面识别": "Add visual understanding",
"视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。": "Used when a video has no subtitles. Larger models generally improve accuracy but require more time and memory.",
"转写模型": "Transcription model",
"首次使用需下载模型和必要组件,之后可在本机转写。": "Download the model and required components once, then transcribe locally.",
"配置高光分析、封面生成和字幕转写使用的模型。": "Choose models for highlight analysis, cover generation, and subtitle transcription.",
"通过专属链接注册,可领取 $5 免费体验额度。": "Register through the dedicated link to claim $5 in free trial credit.",
"$5 免费体验额度": "$5 free trial credit",
"本地": "本地",
"已配置的独立画面模型": "Existing separate vision model",
"视频已有字幕时直接使用;没有字幕时才调用转写模型。": "Use existing subtitles when available; transcribe only when subtitles are missing.",
"改用高光分析模型识别画面": "Use the highlight model for vision",
"会发送抽样画面给多模态模型,费用通常高于仅文字分析。": "Sampled frames will be sent to the multimodal model. This usually costs more than text-only analysis.",
"该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。": "Subtitle timestamps are not integrated for this provider yet. Choose Alibaba Cloud, OpenAI, or local Whisper.",
"选择转写模型": "Select a transcription model",
"需要公网音频地址,暂未接入": "Requires a public audio URL; not integrated yet",
"实时转写接口,暂未接入": "Realtime API; not integrated yet",
"字幕时间戳接口尚未适配": "Subtitle timestamps are not integrated yet",
"模型目录可预览;实际可用性以账号权限和服务商开通状态为准。": "Preview the model catalog. Actual availability depends on your account permissions and activated services.",
"按下面三步完成设置。已有字幕可跳过第 1 步;封面可先使用视频截帧。": "Follow these three steps. Skip step 1 if you already have subtitles; video frames can serve as covers.",
"供应商提供 AI 服务,模型负责具体任务。同一供应商的密钥可以复用。": "Providers host AI services; models perform each task. You can reuse a key for the same provider.",
"把视频中的说话声转成字幕。视频已有字幕时不会调用,可以直接继续第 2 步。": "Turn speech into subtitles. If the video already has subtitles, skip to step 2.",
"必需。选择分析方式,再选择供应商、填写密钥;模型会自动推荐,也可以更换。": "Required. Choose an analysis method, then a provider and key. A model will be suggested; you can change it.",
"服务密钥(API Key)": "Service key (API Key)",
"在供应商网站创建密钥,复制后粘贴到右侧。": "Create a key on the provider website, then copy and paste it here.",
"请填写 API Key": "Enter an API key",
"已复用字幕转写的密钥,点击可修改": "Using the transcription key; click to edit",
"使用自定义型号": "Use custom model",
"可选。使用视频截帧无需配置模型,也不会产生生图费用。": "Optional. Video frames need no model and incur no image generation charges.",
"封面来源": "Cover source",
"AI 生成": "AI generation",
"保存后,返回项目导入视频即可开始。": "After saving, return to Projects and import a video to begin.",
"设置已保存": "Settings saved",
"首次使用,保存后生效": "First setup; save to apply",
"阿里云百炼": "Alibaba Cloud Model Studio",
"阿里云百炼官方服务,接口地址已预设。": "Alibaba Cloud Model Studio. The API endpoint is preset.",
"修改密钥": "Edit key",
"正在获取可用模型…": "Получаем список доступных моделей…",
"已为你选好推荐模型,可随时更换。": "Рекомендуемая модель выбрана; её можно сменить в любой момент.",
"填写 API Key 后自动选择推荐模型。": "После ввода API-ключа рекомендуемая модель выбирается автоматически.",
"画面识别": "Анализ кадров",
"视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。": "Если у видео нет субтитров, речь сначала транскрибируется. По умолчанию это делается локально и бесплатно, аудио не загружается.",
"转写方式": "Способ транскрипции",
"本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。": "Локальная транскрипция бесплатна, но сначала скачивает модель; облачная не требует загрузки и тарифицируется по использованию.",
"将音频发送给所选服务转写,按服务商计费。": "Аудио отправляется выбранному сервису для транскрипции; оплата по тарифам провайдера.",
"封面使用其他服务": "Другой сервис для обложек",
"默认与 AI 服务共用 API Key,无需重复填写。": "По умолчанию использует ключ AI-сервиса, повторно вводить не нужно.",
"{{name}} 没有生图模型,可以为封面单独选一家服务。": "У {{name}} нет модели генерации изображений; выберите для обложек отдельный сервис.",
"已自动选择推荐的生图模型,可更换。": "Выбрана рекомендуемая модель изображений; при желании её можно сменить.",
"参考视频画面": "Опираться на кадр видео",
"开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。": "Отправляет один кадр видео модели обложки, чтобы результат точнее соответствовал содержанию.",
"AI 服务": "AI-сервис",
"选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。": "Выберите сервис и вставьте API-ключ — модель подберётся сама. Транскрипция и обложки по умолчанию используют этот же ключ.",
"高级": "Дополнительно",
"先连接一个 AI 服务": "Сначала подключите AI-сервис",
"选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。": "Выберите сервис и вставьте API-ключ — можно начинать, модель подберётся сама. Всё можно изменить позже в настройках.",
"已选好模型:{{model}}": "Модель выбрана: {{model}}",
"等待 API Key": "Ожидание API-ключа",
"更多选项": "Больше настроек",
"连接并保存": "Подключить и сохранить",
"已复用 AI 服务的密钥": "Используется ключ AI-сервиса",
"本机服务无需 API Key,保持默认地址即可。": "Локальным сервисам ключ не нужен; оставьте адрес по умолчанию.",
"推荐": "Рекомендуем",
"请确认 API Key,或直接输入模型名": "Проверьте API-ключ или введите название модели",
"请先连接 AI 服务,再导入视频。": "Сначала подключите AI-сервис, затем импортируйте видео.",
"稍后再说": "Позже",
"当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。": "Текущая модель работает только с текстом, поэтому нарезка идёт только по субтитрам. Для геймплея и контента с малым количеством речи переключитесь на мультимодальную модель и включите эту опцию.",
"抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。": "Берёт несколько кадров, чтобы понять действие и сцену. Рекомендуется для геймплея и контента с малым количеством речи — нарезка точнее, стоимость чуть выше. При отключении отправляются только субтитры.",
"默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。": "По умолчанию сервис выше рисует обложку с заголовком; можно переключиться на кадр из видео.",
"请先选择一家 AI 服务": "Сначала выберите AI-сервис",
"选择一家 AI 服务": "Выберите AI-сервис",
"先选择一家 AI 服务": "Для начала выберите AI-сервис",
"国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。": "Доступны китайские, международные сервисы и бесплатные локальные модели; если не уверены, начните с раздела «Рекомендуем».",
"先在上面选择一家 AI 服务,生图模型会自动选好。": "Сначала выберите AI-сервис выше; модель изображений подберётся автоматически.",
"示例": "Пример",
"示例项目暂时不可用": "Пример проекта сейчас недоступен",
"还没有项目": "Проектов пока нет",
"从上方导入一段视频,或者先用示例项目看看出片效果。": "Импортируйте видео выше или сначала откройте пример проекта, чтобы увидеть результат.",
"用示例项目看看效果": "Открыть пример проекта",
"来源": "Источник",
"成片设置": "Настройки ролика",
"片头标题文字": "Текст заставки",
"片头标题样式": "Стиль заставки",
"把字幕压进画面": "Показывать субтитры на видео",
"片头标题": "Заставка",
"字幕压进画面": "Субтитры на видео",
"把这段的字幕压进画面": "Показывать субтитры этого фрагмента на видео.",
"不显示": "Не показывать",
"片头约 4 秒显示标题文字": "Показывает текст заголовка около 4 секунд в начале.",
"示例项目 · 原片只保留了三段": "Пример проекта · в исходнике оставлены только три фрагмента",
"一键竖屏": "Вертикально одним нажатием",
"例如:一个问题,或一句结论": "Например: вопрос или вывод в одну строку",
"片头样式": "Стиль заставки",
"入场动效": "Анимация появления",
"翻译与动效以渲染结果为准;支持手动换行。": "Перевод и анимация видны в готовом ролике; поддерживаются ручные переносы строк.",
"选择翻译语言后,渲染时会翻译片头文字与字幕。": "При выборе языка заставка и субтитры переводятся во время рендера.",
"关闭后成片静音。": "При отключении ролик будет без звука.",
"保留完整画面,两侧留边或模糊背景。": "Сохраняет всё изображение, по бокам — поля или размытый фон.",
"满屏": "Заполнить",
"模糊背景": "Размытый фон",
"留边": "Поля",
"发布时按平台生成带标题的封面,也可以用视频截帧。": "При публикации для каждой платформы создаётся обложка с заголовком; подойдёт и кадр из видео.",
"导出成片后,在发布页生成带标题的封面。": "После экспорта создайте обложку с заголовком на странице публикации.",
"去生成": "Создать",
"渲染完成后,在发布页按平台生成带标题的封面。": "После рендера создайте обложку с заголовком для каждой платформы на странице публикации.",
"‹ 项目": "‹ Проекты",
"确认要做什么": "Подтвердите, что делаем",
"AI 先看一遍素材给出建议;你确认后才开始正式剪辑。": "ИИ просматривает материал и предлагает план; монтаж начинается только после вашего подтверждения.",
"分析方式": "Анализ",
"会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。": "Дополнительно анализирует несколько кадров, лучше понимая действие и сцены; оплата по тарифам провайдера модели.",
"当前模型不支持画面分析;换一个多模态模型后可开启。": "Текущая модель не анализирует кадры; переключитесь на мультимодальную модель.",
"只分析字幕文本,成本最低;没有字幕时会先转写。": "Анализирует только текст субтитров — самый дешёвый вариант; без субтитров сначала выполняется транскрипция.",
"字幕 + 画面": "Субтитры + кадры",
"「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。": "«Контентные клипы» анализируются только по субтитрам; для анализа кадров отметьте также Хайлайты или Промо.",
"仅字幕": "Только субтитры",
"当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。": "Текущая модель не анализирует кадры. Выберите «Только субтитры» или смените модель на мультимодальную в настройках.",
"按字幕找到高潮句,保留前后关键过程": "Находит кульминации по субтитрам и сохраняет ключевой контекст",
"把字幕压进画面,整条成片统一样式。": "Показывать субтитры на видео в едином стиле на весь ролик.",
"字幕样式": "Стиль субтитров",
"这里是字幕效果": "Пример субтитров",
"简洁描边": "Тонкая обводка",
"粗体大字": "Крупный жирный",
"底色字幕条": "Плашка",
"醒目黄字": "Яркий жёлтый",
"主体铺满画面,自动对准说话的人。": "Объект заполняет кадр, центр — на говорящем.",
"取景": "Кадрирование",
"首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。": "При первом использовании загружается компонент распознавания людей (около {{size}} МБ); дальше кадрирование само следует за говорящим.",
"正在下载人物识别组件…": "Загружается компонент распознавания людей…",
"正在识别人物位置…": "Определяем положение людей…",
"没有识别到人物,请手动调整取景位置。": "Людей не найдено; настройте кадрирование вручную.",
"拖动调整当前镜头的取景位置。": "Перетащите, чтобы настроить кадрирование текущей сцены.",
"下载并自动取景": "Скачать и кадрировать",
"重新自动取景": "Кадрировать заново",
"自动取景": "Автокадрирование",
"片头文字(可选)": "Текст заставки (необязательно)",
"片头文字": "Текст заставки",
"在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。": "Крупный текст на первом кадре до 4 секунд — для геймплея и промо; для интервью и объяснений оставьте пустым.",
"缩略图为设计参考,实际文字效果见左侧预览。": "Миниатюры — образцы дизайна; реальный текст виден в превью слева.",
"片头": "Заставка",
"已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。": "Кадр следует за говорящим ({{framed}}/{{total}} сцен, {{switches}} переключений). Сдвиг ползунка фиксирует кадр.",
"正在跟随说话人取景;拖动滑块会改为固定取景。": "Кадр следует за говорящим; сдвиг ползунка фиксирует кадр."
}
+237 -30
View File
@@ -853,9 +853,6 @@
"保持原画幅": "保持原画幅",
"9:16 竖屏": "9:16 竖屏",
"16:9 横屏": "16:9 横屏",
"返回导入": "返回导入",
"确认制作内容": "确认制作内容",
"导入视频 → 识别与确认 → 开始制作": "导入视频 → 识别与确认 → 开始制作",
"制作已经开始": "制作已经开始",
"正在快速识别素材": "正在快速识别素材",
"可进入项目查看进度。": "可进入项目查看进度。",
@@ -941,37 +938,21 @@
"查看原片": "查看原片",
"播放成片": "播放成片",
"渲染预览": "渲染预览",
"改到满意,就导出。": "改到满意,就导出。",
"应用竖屏推荐": "应用竖屏推荐",
"成片名称": "成片名称",
"开头文字": "开头文字",
"可留空;在首镜头最多显示 4 秒": "可留空;在首镜头最多显示 4 秒",
"标题模板": "标题模板",
"缩略图为设计参考,当前文案效果见预览。": "缩略图为设计参考,当前文案效果见预览。",
"调整文字样式": "调整文字样式",
"样式版本": "样式版本",
"强调色": "强调色",
"标题强调色": "标题强调色",
"文字大小": "文字大小",
"文字位置": "文字位置",
"开启入场动效": "开启入场动效",
"预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。": "预览使用实际文字图层。翻译与入场动效以渲染结果为准;支持手动换行。",
"输出文字语言": "输出文字语言",
"选择翻译语言后,渲染时会翻译开头文字与所选原字幕。": "选择翻译语言后,渲染时会翻译开头文字与所选原字幕。",
"烧录原字幕": "烧录原字幕",
"声音": "声音",
"保留原声": "保留原声",
"画面设置": "画面设置",
"原画幅": "原画幅",
"构图": "构图",
"完整画面 · 留边": "完整画面 · 留边",
"完整画面 · 模糊背景": "完整画面 · 模糊背景",
"满屏取景": "满屏取景",
"取景位置 · 左右移动": "取景位置 · 左右移动",
"取景位置": "取景位置",
"主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。": "主体铺满画面。左右调整取景,检查角色、障碍与 HUD 是否完整。",
"当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。": "当前保留横屏全画面,会产生留边;想铺满竖屏请选择「满屏取景」。",
"保留原画面构图。": "保留原画面构图。",
"文案修改要求": "文案修改要求",
"告诉 AI 怎么改文案,例如:开头改成一个简短的问题": "告诉 AI 怎么改文案,例如:开头改成一个简短的问题",
"改一版文案": "改一版文案",
@@ -997,10 +978,8 @@
"成片": "成片",
"格式": "格式",
"保持原尺寸": "保持原尺寸",
"开头包装": "开头包装",
"原声": "原声",
"保留": "保留",
"烧录已有字幕": "烧录已有字幕",
"当前版本已渲染完成,可以直接下载。": "当前版本已渲染完成,可以直接下载。",
"任务在后台继续,关闭面板不会取消渲染。": "任务在后台继续,关闭面板不会取消渲染。",
"追加一个镜头": "追加一个镜头",
@@ -1100,21 +1079,13 @@
"制作任务未能启动,请重试确认;原素材与已有成片已保留": "制作任务未能启动,请重试确认;原素材与已有成片已保留",
"这个切片当前播放器无法解码,请下载后用系统播放器打开。": "这个切片当前播放器无法解码,请下载后用系统播放器打开。",
"切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。": "切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。",
"分析方式": "分析方式",
"本次分析方式": "本次分析方式",
"默认分析方式": "默认分析方式",
"字幕分析 · 低成本": "字幕分析 · 低成本",
"视觉分析": "视觉分析",
"智能选择": "智能选择",
"配置视觉模型不会自动切换分析方式。": "配置视觉模型不会自动切换分析方式。",
"仅分析字幕文本;无字幕时需要转写。": "仅分析字幕文本;无字幕时需要转写。",
"发送抽样画面与文本,按模型服务商计费。": "发送抽样画面与文本,按模型服务商计费。",
"允许付费视觉初筛": "允许付费视觉初筛",
"关闭时不调用视觉初筛,确认后才开始正式分析。": "关闭时不调用视觉初筛,确认后才开始正式分析。",
"分析偏好已保存": "分析偏好已保存",
"视觉模型不可用,请前往模型设置。": "视觉模型不可用,请前往模型设置。",
"按字幕链路处理,未调用视觉初筛;正式制作时使用已有字幕或转写,尚未验证语音内容。": "按字幕链路处理,未调用视觉初筛;正式制作时使用已有字幕或转写,尚未验证语音内容。",
"内容切片使用字幕分析,请调整分析方式或制作类型。": "内容切片使用字幕分析,请调整分析方式或制作类型。",
"字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。": "字幕推广会额外调用一次文字模型生成文案,按服务商计费;请复核后使用。",
"检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。": "检测到可用字幕,建议按语义制作;尚未判断内容质量,也未调用模型。",
"未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。": "未找到字幕,需要转写或提供字幕;尚未确认素材有可用语音,可手动选择制作类型。",
@@ -1193,5 +1164,241 @@
"同一服务商": "同一服务商",
"用其他服务生图…": "用其他服务生图…",
"生图服务": "生图服务",
"和上面是同一家服务时可以留空,自动复用。": "和上面是同一家服务时可以留空,自动复用。"
"和上面是同一家服务时可以留空,自动复用。": "和上面是同一家服务时可以留空,自动复用。",
"高光分析": "高光分析",
"画面理解": "画面理解",
"请填写连接名称": "请填写连接名称",
"请填写完整的 HTTP(S) 接口地址": "请填写完整的 HTTP(S) 接口地址",
"服务地址已改变,请重新填写 API Key": "服务地址已改变,请重新填写 API Key",
"请先选择模型": "请先选择模型",
"连接测试失败,请检查接口、密钥和模型": "连接测试失败,请检查接口、密钥和模型",
"AI 模型": "AI 模型",
"加载中…": "加载中…",
"模型服务": "模型服务",
"选择模型服务": "选择模型服务",
"添加模型服务…": "添加模型服务…",
"自动获取模型列表,也可以直接输入模型 ID。": "自动获取模型列表,也可以直接输入模型 ID。",
"已从服务获取模型列表": "已从服务获取模型列表",
"使用缓存的模型列表": "使用缓存的模型列表",
"模型列表加载中…": "模型列表加载中…",
"刷新列表": "刷新列表",
"编辑服务": "编辑服务",
"自定义模型能力": "自定义模型能力",
"模型能力": "模型能力",
"已知模型自动识别;自定义型号可手动指定。": "已知模型自动识别;自定义型号可手动指定。",
"自动识别": "自动识别",
"测试会发起少量模型调用,按服务商计费。": "测试会发起少量模型调用,按服务商计费。",
"接入模型服务,为高光分析和封面生成分别选择模型。": "接入模型服务,为高光分析和封面生成分别选择模型。",
"尚未使用": "尚未使用",
"编辑": "编辑",
"使用中的服务需先更换模型引用才能移除": "使用中的服务需先更换模型引用才能移除",
"添加模型服务": "添加模型服务",
"使用字幕分析;没有字幕时需要先转写。": "使用字幕分析;没有字幕时需要先转写。",
"可使用字幕和抽样画面分析,画面调用按服务商计费。": "可使用字幕和抽样画面分析,画面调用按服务商计费。",
"尚未确认画面能力,当前使用字幕分析;自定义模型可在下方指定能力。": "尚未确认画面能力,当前使用字幕分析;自定义模型可在下方指定能力。",
"封面方式": "封面方式",
"视频截帧": "视频截帧",
"AI 生图": "AI 生图",
"生图失败时使用视频截帧;不会自动改用其他供应商。": "生图失败时使用视频截帧;不会自动改用其他供应商。",
"单独指定画面理解模型": "单独指定画面理解模型",
"默认复用高光分析模型,也可以选择其他服务。": "默认复用高光分析模型,也可以选择其他服务。",
"导入时允许画面初筛": "导入时允许画面初筛",
"自动推荐制作类型时发送抽样画面,按服务商计费。": "自动推荐制作类型时发送抽样画面,按服务商计费。",
"本地 Whisper;安装和下载操作立即执行。": "本地 Whisper;安装和下载操作立即执行。",
"有未保存的更改": "有未保存的更改",
"当前配置": "当前配置",
"保存设置": "保存设置",
"同一供应商可以添加多个账号;服务连接可被多个功能复用。": "同一供应商可以添加多个账号;服务连接可被多个功能复用。",
"应用到配置": "应用到配置",
"修改此连接会影响:{{uses}}": "修改此连接会影响:{{uses}}",
"连接名称": "连接名称",
"例如:个人账号、本机服务": "例如:个人账号、本机服务",
"供应商": "供应商",
"清除密钥": "清除密钥",
"获取 API Key": "获取 API Key",
"留空使用供应商默认地址": "留空使用供应商默认地址",
"生图接口设置": "生图接口设置",
"自定义生图服务需与所选接口协议兼容。": "自定义生图服务需与所选接口协议兼容。",
"接口协议": "接口协议",
"使用供应商默认协议": "使用供应商默认协议",
"生图接口地址": "生图接口地址",
"模型列表刷新失败,正在使用上次成功的列表": "模型列表刷新失败,正在使用上次成功的列表",
"尚未获取服务模型列表,可以重试或手动输入模型 ID": "尚未获取服务模型列表,可以重试或手动输入模型 ID",
"高光分析模型": "高光分析模型",
"画面理解模型": "画面理解模型",
"封面生图模型": "封面生图模型",
"默认使用同一供应商,可在高级设置中单独配置。": "默认使用同一供应商,可在高级设置中单独配置。",
"填写 API Key 后自动选择,也可手动输入模型名": "填写 API Key 后自动选择,也可手动输入模型名",
"选择供应商并填写 API Key,自动配置分析和封面模型;需要时再调整。": "选择供应商并填写 API Key,自动配置分析和封面模型;需要时再调整。",
"封面使用其他供应商": "封面使用其他供应商",
"默认复用上面的供应商和 API Key。": "默认复用上面的供应商和 API Key。",
"官方供应商": "官方供应商",
"OpenAI 官方服务,接口地址已预设。": "OpenAI 官方服务,接口地址已预设。",
"自定义 OpenAI 兼容接口": "自定义 OpenAI 兼容接口",
"自定义兼容接口": "自定义兼容接口",
"适用于 OpenRouter、第三方网关或自建服务。": "适用于 OpenRouter、第三方网关或自建服务。",
"接口设置": "接口设置",
"修改接口地址后,请重新填写 API Key。": "修改接口地址后,请重新填写 API Key。",
"选择或搜索模型,也可手动输入模型名": "选择或搜索模型,也可手动输入模型名",
"公开目录预览,填写 API Key 后确认账号可用模型。": "公开目录预览,填写 API Key 后确认账号可用模型。",
"公开目录预览,填写 API Key 后确认账号可用模型。 当前展示缓存目录。": "公开目录预览,填写 API Key 后确认账号可用模型。 当前展示缓存目录。",
"账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。": "账号模型列表获取失败,当前为参考目录;请检查密钥后刷新。",
"Whisper · 本地": "Whisper · 本地",
"使用字幕分析;选择多模态模型后可同时理解画面。": "使用字幕分析;选择多模态模型后可同时理解画面。",
"使用独立的画面模型": "使用独立的画面模型",
"保存设置后,新任务将使用这个模型。": "保存设置后,新任务将使用这个模型。",
"共用 API Key,无需重复填写。": "共用 API Key,无需重复填写。",
"准备本地模型": "准备本地模型",
"准备模型": "准备模型",
"分析模型只能读文字时,可增加一个多模态模型来理解视频画面。": "分析模型只能读文字时,可增加一个多模态模型来理解视频画面。",
"在本机把音频转成字幕,不上传音频,无需 API Key。": "在本机把音频转成字幕,不上传音频,无需 API Key。",
"字幕转写": "字幕转写",
"已就绪": "已就绪",
"暂时无法读取本地模型状态。": "暂时无法读取本地模型状态。",
"本地模型管理": "本地模型管理",
"本地转写组件": "本地转写组件",
"模型已就绪": "模型已就绪",
"正在下载模型…": "正在下载模型…",
"正在准备组件…": "正在准备组件…",
"自定义模型类型": "自定义模型类型",
"补充画面识别": "补充画面识别",
"视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。": "视频没有字幕时使用。模型越大通常越准确,也需要更多时间和内存。",
"转写模型": "转写模型",
"首次使用需下载模型和必要组件,之后可在本机转写。": "首次使用需下载模型和必要组件,之后可在本机转写。",
"配置高光分析、封面生成和字幕转写使用的模型。": "配置高光分析、封面生成和字幕转写使用的模型。",
"通过专属链接注册,可领取 $5 免费体验额度。": "通过专属链接注册,可领取 $5 免费体验额度。",
"$5 免费体验额度": "$5 免费体验额度",
"本地": "本地",
"已配置的独立画面模型": "已配置的独立画面模型",
"视频已有字幕时直接使用;没有字幕时才调用转写模型。": "视频已有字幕时直接使用;没有字幕时才调用转写模型。",
"改用高光分析模型识别画面": "改用高光分析模型识别画面",
"会发送抽样画面给多模态模型,费用通常高于仅文字分析。": "会发送抽样画面给多模态模型,费用通常高于仅文字分析。",
"该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。": "该供应商的 ASR 尚未适配字幕时间戳,请选择阿里云、OpenAI 或本地 Whisper。",
"选择转写模型": "选择转写模型",
"需要公网音频地址,暂未接入": "需要公网音频地址,暂未接入",
"实时转写接口,暂未接入": "实时转写接口,暂未接入",
"字幕时间戳接口尚未适配": "字幕时间戳接口尚未适配",
"模型目录可预览;实际可用性以账号权限和服务商开通状态为准。": "模型目录可预览;实际可用性以账号权限和服务商开通状态为准。",
"按下面三步完成设置。已有字幕可跳过第 1 步;封面可先使用视频截帧。": "按下面三步完成设置。已有字幕可跳过第 1 步;封面可先使用视频截帧。",
"供应商提供 AI 服务,模型负责具体任务。同一供应商的密钥可以复用。": "供应商提供 AI 服务,模型负责具体任务。同一供应商的密钥可以复用。",
"把视频中的说话声转成字幕。视频已有字幕时不会调用,可以直接继续第 2 步。": "把视频中的说话声转成字幕。视频已有字幕时不会调用,可以直接继续第 2 步。",
"必需。选择分析方式,再选择供应商、填写密钥;模型会自动推荐,也可以更换。": "必需。选择分析方式,再选择供应商、填写密钥;模型会自动推荐,也可以更换。",
"服务密钥(API Key)": "服务密钥(API Key)",
"在供应商网站创建密钥,复制后粘贴到右侧。": "在供应商网站创建密钥,复制后粘贴到右侧。",
"请填写 API Key": "请填写 API Key",
"已复用字幕转写的密钥,点击可修改": "已复用字幕转写的密钥,点击可修改",
"使用自定义型号": "使用自定义型号",
"可选。使用视频截帧无需配置模型,也不会产生生图费用。": "可选。使用视频截帧无需配置模型,也不会产生生图费用。",
"封面来源": "封面来源",
"AI 生成": "AI 生成",
"保存后,返回项目导入视频即可开始。": "保存后,返回项目导入视频即可开始。",
"设置已保存": "设置已保存",
"首次使用,保存后生效": "首次使用,保存后生效",
"阿里云百炼": "阿里云百炼",
"阿里云百炼官方服务,接口地址已预设。": "阿里云百炼官方服务,接口地址已预设。",
"修改密钥": "修改密钥",
"正在获取可用模型…": "正在获取可用模型…",
"已为你选好推荐模型,可随时更换。": "已为你选好推荐模型,可随时更换。",
"填写 API Key 后自动选择推荐模型。": "填写 API Key 后自动选择推荐模型。",
"画面识别": "画面识别",
"视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。": "视频没有字幕时,先把说话声转成字幕。默认在本机免费转写,不上传音频。",
"转写方式": "转写方式",
"本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。": "本地转写免费,首次需下载模型;云端转写无需下载,按用量计费。",
"将音频发送给所选服务转写,按服务商计费。": "将音频发送给所选服务转写,按服务商计费。",
"封面使用其他服务": "封面使用其他服务",
"默认与 AI 服务共用 API Key,无需重复填写。": "默认与 AI 服务共用 API Key,无需重复填写。",
"{{name}} 没有生图模型,可以为封面单独选一家服务。": "{{name}} 没有生图模型,可以为封面单独选一家服务。",
"已自动选择推荐的生图模型,可更换。": "已自动选择推荐的生图模型,可更换。",
"参考视频画面": "参考视频画面",
"开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。": "开启后会把一张视频截图发送给封面模型,生成的封面更贴近内容。",
"AI 服务": "AI 服务",
"选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。": "选一家服务、填好 API Key,模型会自动选好。字幕转写和封面默认沿用这把 Key。",
"高级": "高级",
"先连接一个 AI 服务": "先连接一个 AI 服务",
"选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。": "选一家服务、填好 API Key 就能开始,模型会自动选好。之后随时可以在设置里调整。",
"已选好模型:{{model}}": "已选好模型:{{model}}",
"等待 API Key": "等待 API Key",
"更多选项": "更多选项",
"连接并保存": "连接并保存",
"已复用 AI 服务的密钥": "已复用 AI 服务的密钥",
"本机服务无需 API Key,保持默认地址即可。": "本机服务无需 API Key,保持默认地址即可。",
"推荐": "推荐",
"请确认 API Key,或直接输入模型名": "请确认 API Key,或直接输入模型名",
"请先连接 AI 服务,再导入视频。": "请先连接 AI 服务,再导入视频。",
"稍后再说": "稍后再说",
"当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。": "当前模型仅支持文字,只能靠字幕切分。游戏画面、口播较少的内容建议换一个多模态模型再开启。",
"抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。": "抽样几张画面理解动作与场景。游戏画面、口播较少的内容推荐开启,切分更准;费用略高。关闭后只发送字幕。",
"默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。": "默认由 AI 用上面的服务画一张带标题的封面;也可以改用视频截帧。",
"请先选择一家 AI 服务": "请先选择一家 AI 服务",
"选择一家 AI 服务": "选择一家 AI 服务",
"先选择一家 AI 服务": "先选择一家 AI 服务",
"国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。": "国内直连、国际服务和本机免费模型都可以选;不确定就从「推荐」开始。",
"先在上面选择一家 AI 服务,生图模型会自动选好。": "先在上面选择一家 AI 服务,生图模型会自动选好。",
"示例": "示例",
"示例项目暂时不可用": "示例项目暂时不可用",
"还没有项目": "还没有项目",
"从上方导入一段视频,或者先用示例项目看看出片效果。": "从上方导入一段视频,或者先用示例项目看看出片效果。",
"用示例项目看看效果": "用示例项目看看效果",
"来源": "来源",
"成片设置": "成片设置",
"片头标题文字": "片头标题文字",
"片头标题样式": "片头标题样式",
"把字幕压进画面": "把字幕压进画面",
"片头标题": "片头标题",
"字幕压进画面": "字幕压进画面",
"把这段的字幕压进画面": "把这段的字幕压进画面",
"不显示": "不显示",
"片头约 4 秒显示标题文字": "片头约 4 秒显示标题文字",
"示例项目 · 原片只保留了三段": "示例项目 · 原片只保留了三段",
"一键竖屏": "一键竖屏",
"例如:一个问题,或一句结论": "例如:一个问题,或一句结论",
"片头样式": "片头样式",
"入场动效": "入场动效",
"翻译与动效以渲染结果为准;支持手动换行。": "翻译与动效以渲染结果为准;支持手动换行。",
"选择翻译语言后,渲染时会翻译片头文字与字幕。": "选择翻译语言后,渲染时会翻译片头文字与字幕。",
"关闭后成片静音。": "关闭后成片静音。",
"保留完整画面,两侧留边或模糊背景。": "保留完整画面,两侧留边或模糊背景。",
"满屏": "满屏",
"模糊背景": "模糊背景",
"留边": "留边",
"发布时按平台生成带标题的封面,也可以用视频截帧。": "发布时按平台生成带标题的封面,也可以用视频截帧。",
"导出成片后,在发布页生成带标题的封面。": "导出成片后,在发布页生成带标题的封面。",
"去生成": "去生成",
"渲染完成后,在发布页按平台生成带标题的封面。": "渲染完成后,在发布页按平台生成带标题的封面。",
"‹ 项目": "‹ 项目",
"确认要做什么": "确认要做什么",
"AI 先看一遍素材给出建议;你确认后才开始正式剪辑。": "AI 先看一遍素材给出建议;你确认后才开始正式剪辑。",
"分析方式": "分析方式",
"会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。": "会抽样几张画面一起分析,更懂动作与场景;按模型服务商计费。",
"当前模型不支持画面分析;换一个多模态模型后可开启。": "当前模型不支持画面分析;换一个多模态模型后可开启。",
"只分析字幕文本,成本最低;没有字幕时会先转写。": "只分析字幕文本,成本最低;没有字幕时会先转写。",
"字幕 + 画面": "字幕 + 画面",
"「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。": "「内容切片」只按字幕分析;要用画面分析,请同时勾选高光或推广。",
"仅字幕": "仅字幕",
"当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。": "当前模型不支持画面分析,请先选「仅字幕」,或到设置换一个多模态模型。",
"按字幕找到高潮句,保留前后关键过程": "按字幕找到高潮句,保留前后关键过程",
"把字幕压进画面,整条成片统一样式。": "把字幕压进画面,整条成片统一样式。",
"字幕样式": "字幕样式",
"这里是字幕效果": "这里是字幕效果",
"简洁描边": "简洁描边",
"粗体大字": "粗体大字",
"底色字幕条": "底色字幕条",
"醒目黄字": "醒目黄字",
"主体铺满画面,自动对准说话的人。": "主体铺满画面,自动对准说话的人。",
"取景": "取景",
"首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。": "首次使用需下载人物识别组件(约 {{size}} MB),之后自动对准说话的人。",
"正在下载人物识别组件…": "正在下载人物识别组件…",
"正在识别人物位置…": "正在识别人物位置…",
"没有识别到人物,请手动调整取景位置。": "没有识别到人物,请手动调整取景位置。",
"拖动调整当前镜头的取景位置。": "拖动调整当前镜头的取景位置。",
"下载并自动取景": "下载并自动取景",
"重新自动取景": "重新自动取景",
"自动取景": "自动取景",
"片头文字(可选)": "片头文字(可选)",
"片头文字": "片头文字",
"在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。": "在第一个镜头上显示最多 4 秒的大字,适合游戏、推广类内容;访谈、讲解可以留空。",
"缩略图为设计参考,实际文字效果见左侧预览。": "缩略图为设计参考,实际文字效果见左侧预览。",
"片头": "片头",
"已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。": "已跟随说话人自动取景({{framed}}/{{total}} 个镜头,{{switches}} 次切换)。拖动滑块会改为固定取景。",
"正在跟随说话人取景;拖动滑块会改为固定取景。": "正在跟随说话人取景;拖动滑块会改为固定取景。"
}
+37 -21
View File
@@ -6,15 +6,17 @@ import {
Typography,
Select,
Spin,
Empty,
message
} from 'antd'
import { useNavigate } from 'react-router-dom'
import ProjectCard from '../components/ProjectCard'
import CreativeImport from '../features/studio/CreativeImport'
import FirstRunSetup from '../features/settings/FirstRunSetup'
import { projectApi } from '../services/api'
import { exampleProjectApi, projectApi } from '../services/api'
import { trackExampleProjectOpened } from '../analytics/events'
import { Btn } from '../ui'
import { useSimpleProgressStore } from '../stores/useSimpleProgressStore'
import { Project, useProjectStore } from '../store/useProjectStore'
import { useProjectPolling } from '../hooks/useProjectPolling'
@@ -28,6 +30,10 @@ const HomePage: React.FC = () => {
const navigate = useNavigate()
const { projects, setProjects, deleteProject, loading, setLoading } = useProjectStore()
const [statusFilter, setStatusFilter] = useState<string>('all')
// null until the model document has loaded; true blocks import until the first AI service is saved.
const [setupNeeded, setSetupNeeded] = useState<boolean | null>(null)
const [setupRequest, setSetupRequest] = useState(0)
const [exampleBusy, setExampleBusy] = useState(false)
// 使用项目轮询Hook
useProjectPolling({
@@ -104,6 +110,21 @@ const HomePage: React.FC = () => {
}
}
const openExample = async () => {
setExampleBusy(true)
try {
const { project_id } = await exampleProjectApi.create()
trackExampleProjectOpened()
void loadProjects().catch(() => undefined)
navigate(`/project/${project_id}`)
} catch (error) {
message.error(t("示例项目暂时不可用"))
console.error('Example project error:', error)
} finally {
setExampleBusy(false)
}
}
const handleProjectCardClick = (project: Project) => {
// 导入中状态的项目不能点击进入详情页
if (project.status === 'pending' && !project.settings?.smart_import && !project.processing_config?.smart_import) {
@@ -134,7 +155,8 @@ const HomePage: React.FC = () => {
}}>
<Content style={{ padding: '40px 56px 56px', position: 'relative' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto', position: 'relative' }}>
<CreativeImport onImported={loadProjects} />
<FirstRunSetup onStatus={setSetupNeeded} openRequest={setupRequest} />
<CreativeImport onImported={loadProjects} blocked={setupNeeded === true} onBlocked={() => setSetupRequest(n => n + 1)} />
{pendingImports.length>0&&<div className="studio-import-resume"><b>{t('未完成的导入')}</b>{pendingImports.map(p=><button key={p.id} className="studio-link" onClick={()=>navigate(`/import/${p.id}`)}>{p.name} · {t('继续导入确认')}</button>)}</div>}
{/* 项目管理区域 */}
@@ -197,24 +219,18 @@ const HomePage: React.FC = () => {
<div style={{ marginTop: '18px', color: 'var(--ac-muted)', fontSize: '14px' }}>{t("正在加载项目列表…")}</div>
</div>
) : filteredProjects.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '72px 0',
background: 'var(--ac-card)',
borderRadius: '16px',
border: '1px solid var(--ac-line)'
}}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div>
<Text type="secondary">
{projects.length === 0 ? t("还没有项目,请使用上方的导入区域创建第一个项目") : t("没有找到匹配的项目")}
</Text>
</div>
}
/>
</div>
projects.length === 0 ? (
/* 空态按 DESIGN.md:一句话 + 一件可做的事。示例项目不需要 Key,先看效果再导入自己的视频。 */
<div className="ac-empty">
<b>{t("还没有项目")}</b>
<span>{t("从上方导入一段视频,或者先用示例项目看看出片效果。")}</span>
<div style={{ marginTop: 18 }}>
<Btn size="sm" loading={exampleBusy} onClick={() => void openExample()}>{t("用示例项目看看效果")}</Btn>
</div>
</div>
) : (
<div className="ac-empty"><b>{t("没有找到匹配的项目")}</b></div>
)
) : (
<div style={{
display: 'grid',
+17
View File
@@ -21,6 +21,7 @@ import { useCollectionVideoDownload } from '../hooks/useCollectionVideoDownload'
import { ProjectTaskManager } from '../components/ProjectTaskManager'
import FeedbackDialog from '../components/FeedbackDialog'
import { Btn, Icon, parseTimecode, fmtDuration } from '../ui'
import { openExternalLink } from '../utils/externalLinks'
const ProjectDetailPage: React.FC = () => {
useTranslation()
@@ -275,6 +276,8 @@ const ProjectDetailPage: React.FC = () => {
const tb = b.created_at ? new Date(b.created_at).getTime() : 0
return tb - ta
})
const isExample = !!(currentProject.settings?.example || currentProject.processing_config?.example)
const sourceHost = (() => { try { return currentProject.source_url ? new URL(currentProject.source_url).hostname.replace(/^www\./, '') : '' } catch { return '' } })()
const isVisual = !!currentProject.settings?.smart_import || !!currentProject.processing_config?.smart_import || ['highlight', 'promo'].includes(currentProject.settings?.creative?.goal || currentProject.processing_config?.creative?.goal)
const isCompleted = currentProject.status === 'completed'
const isFailed = currentProject.status === 'failed' || (currentProject.status as string) === 'error'
@@ -316,6 +319,20 @@ const ProjectDetailPage: React.FC = () => {
<span>{dayjs(currentProject.created_at).fromNow()}</span>
</>
)}
{sourceHost && (
<>
<span className="dot" />
<a className="ac-link" href={currentProject.source_url} onClick={e => { e.preventDefault(); openExternalLink(currentProject.source_url!) }}>
{t("来源")} {sourceHost} ↗
</a>
</>
)}
{isExample && (
<>
<span className="dot" />
<span>{t("示例项目 · 原片只保留了三段")}</span>
</>
)}
</div>
</div>
{isCompleted && clips.length > 0 && (
+13 -5
View File
@@ -175,6 +175,14 @@ const PublishClipPage: React.FC = () => {
setSelected((cur) => cur.includes(platform) ? cur.filter((p) => p !== platform) : [...cur, platform])
}
// Covers are on by default: make one as soon as the page is ready so the user sees it instead of an empty box.
const autoCover = useRef(false)
useEffect(() => {
if (loading || error || cover || coverBusy || autoCover.current) return
autoCover.current = true
void generateCover()
}, [loading, error, cover, coverBusy])
const generateCover = async () => {
if (!projectId || !clipId || coverBusy) return
const session = ++coverJob.current
@@ -489,13 +497,13 @@ const PublishClipPage: React.FC = () => {
)}
{!loading && !studioJobId && (
<>
<Row label={t("字幕")} hint={t("从原字幕切出本段并烧进画面")}>
<Row label={t("字幕")} hint={t("把这段的字幕压进画面")}>
<Segmented size="sm" ariaLabel={t("字幕")} value={subtitles ? 'on' : 'off'} onChange={(v) => setSubtitles(v === 'on')}
options={[{ value: 'on', label: t("烧录") }, { value: 'off', label: t("不要") }]} />
options={[{ value: 'on', label: t("显示") }, { value: 'off', label: t("不显示") }]} />
</Row>
<Row label={t("标题卡")} hint={t("片头约 4 秒显示切片标题")}>
<Segmented size="sm" ariaLabel={t("标题卡")} value={titleCard ? 'on' : 'off'} onChange={(v) => setTitleCard(v === 'on')}
options={[{ value: 'on', label: t("显示") }, { value: 'off', label: t("不要") }]} />
<Row label={t("片头标题")} hint={t("片头约 4 秒显示标题文字")}>
<Segmented size="sm" ariaLabel={t("片头标题")} value={titleCard ? 'on' : 'off'} onChange={(v) => setTitleCard(v === 'on')}
options={[{ value: 'on', label: t("显示") }, { value: 'off', label: t("不显示") }]} />
</Row>
</>
)}
+11 -613
View File
@@ -1,17 +1,14 @@
import { t } from '../i18n'
import { useTranslation } from 'react-i18next'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { Form, Input, Select, Switch, message } from 'antd'
import React, { useState, useEffect, useMemo } from 'react'
import { Switch, message } from 'antd'
import { useLocation, useNavigate } from 'react-router-dom'
import { settingsApi } from '../services/api'
import SpeechRecognitionConfig from '../components/SpeechRecognitionConfig'
import AnalysisVisionSettings, { type AnalysisVisionHandle } from '../features/settings/AnalysisVisionSettings'
import AIModelSettings from '../features/settings/AIModelSettings'
import FeedbackDialog from '../components/FeedbackDialog'
import PublishSettings from '../components/PublishSettings'
import CoverModelRow, { type CoverModelHandle } from '../features/settings/CoverModelRow'
import { isDesktopMode } from '../utils/desktopMode'
import { openExternalLink } from '../utils/externalLinks'
import { trackApiKeyConfigured, trackSponsorLinkOpened } from '../analytics/events'
import { isAnalyticsEnabled, setAnalyticsEnabled } from '../analytics/posthog'
import { getRuntimeInfo } from '../analytics/lifecycle'
import { isCrashReportsEnabled, setCrashReportsEnabled } from '../desktop/sentry'
@@ -19,111 +16,11 @@ import { getAppVersion } from '../desktop/updater'
import { useAppUpdate } from '../desktop/UpdatePrompt'
import { FEEDBACK_DISCUSSIONS_URL, FEEDBACK_ISSUES_URL } from '../analytics/feedback'
import { useTheme } from '../context/ThemeContext'
import { Btn, Icon, Row, Section, Segmented, StatusDot } from '../ui'
import { Btn, Icon, Row, Section, Segmented } from '../ui'
const normalizeBaseUrl = (value: unknown): string =>
typeof value === 'string' ? value.trim().replace(/\/+$/, '') : ''
// 模型选择框是 mode="tags" 的 Select,用户手动输入后拿到的是数组;后端只接受字符串
const normalizeModelName = (value: unknown): string => {
if (Array.isArray(value)) return String(value[value.length - 1] ?? '').trim()
return typeof value === 'string' ? value.trim() : ''
}
const toNumber = (v: unknown, fallback: number): number => {
const n = typeof v === 'number' ? v : parseFloat(String(v ?? ''))
return Number.isFinite(n) ? n : fallback
}
type ProviderKey = 'dashscope' | 'openai' | 'infistar' | 'gemini' | 'deepseek' | 'seed' | 'kimi' | 'glm' | 'grok' | 'ollama' | 'lmstudio'
type LocalPreset = { baseUrl: string; defaultModel: string; docsUrl: string; app: string }
type CloudPreset = { baseUrl: string; defaultModel: string }
type Sponsor = { registerUrl: string; guideUrl: string }
// 提供商会越来越多:下拉按组展示、可搜索,新增一家只在 PROVIDERS 里加一条并标 group
type ProviderGroup = 'sponsor' | 'cloud' | 'compatible' | 'local'
const PROVIDER_GROUPS: Array<{ key: ProviderGroup; label: () => string }> = [
{ key: 'sponsor', label: () => t("赞助合作") },
{ key: 'cloud', label: () => t("云端模型") },
{ key: 'compatible', label: () => t("兼容接口") },
{ key: 'local', label: () => t("本机运行") },
]
const PROVIDERS: Record<ProviderKey, { name: string; short: string; hint: string; apiKeyField: string; placeholder: string; keyUrl: string; group: ProviderGroup; local?: LocalPreset; cloud?: CloudPreset; sponsor?: Sponsor }> = {
dashscope: { get name() { return t("阿里通义千问") }, get short() { return t("通义千问") }, get hint() { return t("阿里云 DashScope。国内直连,qwen-plus 性价比高。") }, group: 'cloud', apiKeyField: 'dashscope_api_key', placeholder: 'sk-…', keyUrl: 'https://dashscope.console.aliyun.com/apiKey' },
openai: { get name() { return t("OpenAI / 兼容接口") }, get short() { return t("OpenAI 兼容") }, get hint() { return t("OpenAI,或任何兼容接口:OpenRouter、vLLM。") }, group: 'compatible', apiKeyField: 'openai_api_key', get placeholder() { return t("sk-…(自建服务可留空)") }, keyUrl: 'https://platform.openai.com/api-keys' },
// 赞助合作伙伴(docs/INFISTAR_SETUP.md)。多模型网关,型号随账号而定:不预设默认模型,填好 key 后实时拉取
infistar: { get name() { return t("Infistar 无限星河") }, short: 'Infistar', get hint() { return t("赞助合作伙伴。一个 Key 调用 Claude、GPT、Gemini、DeepSeek 等模型,接口地址已预设。") }, group: 'sponsor', apiKeyField: 'infistar_api_key', placeholder: 'sk-…', keyUrl: 'https://www.infistar.cc/register?aff=XLK3BCM6&ref_source=link', cloud: { baseUrl: 'https://infistar.cc/v1', defaultModel: '' }, sponsor: { registerUrl: 'https://www.infistar.cc/register?aff=XLK3BCM6&ref_source=link', guideUrl: 'https://github.com/zhouxiaoka/autoclip/blob/main/docs/INFISTAR_SETUP.md' } },
gemini: { name: 'Google Gemini', short: 'Gemini', get hint() { return t("Google AI Studio 的 Gemini 系列。") }, group: 'cloud', apiKeyField: 'gemini_api_key', placeholder: 'AIza…', keyUrl: 'https://aistudio.google.com/apikey' },
deepseek: { name: 'DeepSeek', short: 'DeepSeek', get hint() { return t("DeepSeek 官方。国内直连,deepseek-flash 是当前 V4.1。") }, group: 'cloud', apiKeyField: 'deepseek_api_key', placeholder: 'sk-…', keyUrl: 'https://platform.deepseek.com/api_keys', cloud: { baseUrl: 'https://api.deepseek.com', defaultModel: 'deepseek-flash' } },
seed: { name: 'Seed', short: 'Seed', get hint() { return t("火山方舟 Seed。国内直连,豆包 Seed 2.1 系列。") }, group: 'cloud', apiKeyField: 'seed_api_key', placeholder: '…', keyUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey', cloud: { baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: 'doubao-seed-2-1-lite-260915' } },
kimi: { name: 'Kimi', short: 'Kimi', get hint() { return t("月之暗面 Kimi。国内直连,适合长字幕分析。") }, group: 'cloud', apiKeyField: 'kimi_api_key', placeholder: 'sk-…', keyUrl: 'https://platform.moonshot.cn/console/api-keys', cloud: { baseUrl: 'https://api.moonshot.cn/v1', defaultModel: 'kimi-k2.6' } },
glm: { get name() { return t("智谱 GLM") }, get short() { return 'GLM' }, get hint() { return t("智谱开放平台。国内直连,glm-5.3 是当前旗舰。") }, group: 'cloud', apiKeyField: 'glm_api_key', placeholder: '…', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys', cloud: { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-5.3' } },
grok: { name: 'Grok', short: 'Grok', get hint() { return t("xAI Grok。需要 xAI 账号。") }, group: 'cloud', apiKeyField: 'grok_api_key', placeholder: 'xai-…', keyUrl: 'https://console.x.ai', cloud: { baseUrl: 'https://api.x.ai/v1', defaultModel: 'grok-4.6' } },
// 本地预设:底层是 openai 兼容 + base_url,后端 core/local_presets.py 负责还原;无需密钥、不花钱、离线可用
ollama: { name: 'Ollama', short: 'Ollama', get hint() { return t("本机运行的 Ollama,免费、离线。推荐 ollama pull qwen2.5:7b。") }, group: 'local', apiKeyField: 'openai_api_key', placeholder: '', keyUrl: 'https://ollama.com/download', local: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'qwen2.5:7b', docsUrl: 'https://ollama.com/download', app: 'Ollama' } },
lmstudio: { name: 'LM Studio', short: 'LM Studio', get hint() { return t("本机 LM Studio 的 Local Server,免费、离线。在 LM Studio 里加载模型并启动服务。") }, group: 'local', apiKeyField: 'openai_api_key', placeholder: '', keyUrl: 'https://lmstudio.ai', local: { baseUrl: 'http://localhost:1234/v1', defaultModel: '', docsUrl: 'https://lmstudio.ai', app: 'LM Studio' } },
}
const isLocalProvider = (p: ProviderKey) => !!PROVIDERS[p]?.local
const isCloudPreset = (p: ProviderKey) => !!PROVIDERS[p]?.cloud
const providerPickerOptions = () => PROVIDER_GROUPS
.map((group) => ({
label: group.label(),
options: (Object.keys(PROVIDERS) as ProviderKey[])
.filter((key) => PROVIDERS[key].group === group.key)
.map((key) => ({
value: key,
label: PROVIDERS[key].short,
search: `${key} ${PROVIDERS[key].short} ${PROVIDERS[key].name}`.toLowerCase(),
title: PROVIDERS[key].name,
})),
}))
.filter((group) => group.options.length)
// 通义千问国际站(alibabacloud.com 开通的 key 只能打这个域名,#45);后端据 base_url 自动走兼容模式
const DASHSCOPE_INTL_BASE_URL = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1'
type DashscopeRegion = 'cn' | 'intl'
// 后端 /available-models 失败时的兜底;与 backend/core/model_catalog.py 对齐
const FALLBACK_CATALOG: Record<string, string[]> = {
dashscope: ['qwen3.8-max', 'qwen3.8-flash', 'qwen3.7-plus', 'qwen-plus', 'qwen-plus-latest', 'qwen-max', 'qwen-max-latest', 'qwen-flash', 'qwen-vl-max', 'qwen-vl-plus'],
openai: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano'],
gemini: ['gemini-3.8-flash', 'gemini-3.7-flash', 'gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.5-flash-lite', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-2.5-flash'],
deepseek: ['deepseek-flash', 'deepseek-v4-pro'],
seed: ['doubao-seed-2-1-lite-260915', 'doubao-seed-2-1-pro-260915', 'doubao-seed-2-1-turbo-260628', 'doubao-seed-evolving'],
kimi: ['kimi-k3', 'kimi-k2.6', 'kimi-k2.5', 'kimi-k2.7-code'],
glm: ['glm-5.3', 'glm-5.2', 'glm-4.7'],
grok: ['grok-4.6', 'grok-4.5', 'grok-4.3'],
infistar: [],
}
const PROVIDER_GROUP_ORDER: ProviderKey[] = ['dashscope', 'openai', 'infistar', 'gemini', 'deepseek', 'seed', 'kimi', 'glm', 'grok']
const providerGroupLabel = (key: string) => ({
dashscope: t("通义千问"), openai: 'OpenAI', gemini: 'Gemini', deepseek: 'DeepSeek',
seed: 'Seed', kimi: 'Kimi', glm: 'GLM', grok: 'Grok', infistar: 'Infistar',
} as Record<string, string>)[key] || key
const knownCloudModels = (catalog: Record<string, string[]>, extra: string[] = []) =>
new Set([...Object.values(catalog).flat(), ...extra])
const cloudModelOptions = (
provider: ProviderKey,
state: { source: 'catalog' | 'live'; models: string[]; catalog: Record<string, string[]> },
) => {
const catalog = Object.keys(state.catalog).length ? state.catalog : FALLBACK_CATALOG
const current = state.source === 'live' && state.models.length
? state.models
: (catalog[provider] || FALLBACK_CATALOG[provider] || [])
return PROVIDER_GROUP_ORDER.map((key) => ({
label: providerGroupLabel(key),
options: (key === provider ? current : (catalog[key] || [])).map((m) => ({ value: m, label: m })),
}))
}
const CLOUD_DEFAULT_MODEL: Partial<Record<ProviderKey, string>> = {
dashscope: 'qwen-plus', openai: 'gpt-5-mini', gemini: 'gemini-3.8-flash', deepseek: 'deepseek-flash',
seed: 'doubao-seed-2-1-lite-260915', kimi: 'kimi-k2.6', glm: 'glm-5.3', grok: 'grok-4.6', infistar: undefined,
}
// 分析方式 / 模型 / 视觉理解 / 转写 / 封面生图本是同一件事:「用什么让 AI 看懂视频、做出成片」,合成一页、一个保存
type SectionKey = 'ai' | 'publish' | 'app' | 'feedback'
const NAV: Array<{ key: SectionKey; label: string }> = [
{ key: 'ai', get label() { return t("AI 分析") } },
{ key: 'ai', get label() { return t("AI 模型") } },
{ key: 'publish', get label() { return t("发布") } },
{ key: 'app', get label() { return t("应用") } },
{ key: 'feedback', get label() { return t("反馈") } },
@@ -131,286 +28,26 @@ const NAV: Array<{ key: SectionKey; label: string }> = [
// 旧链接(项目卡「模型设置」「转写设置」等)仍能打开,并滚到对应小节
const SECTION_ALIASES: Record<string, { section: SectionKey; anchor?: string }> = {
model: { section: 'ai', anchor: 'ai-model' },
analysis: { section: 'ai', anchor: 'ai-analysis' },
vision: { section: 'ai', anchor: 'ai-analysis' },
analysis: { section: 'ai', anchor: 'ai-model' },
vision: { section: 'ai', anchor: 'ai-model' },
speech: { section: 'ai', anchor: 'ai-speech' },
cover: { section: 'ai', anchor: 'ai-model' },
cover: { section: 'ai', anchor: 'ai-cover' },
}
// Calm Premium settings — left nav + setting rows (see DESIGN.md → App Layer)
const SettingsPage: React.FC = () => {
useTranslation()
const [form] = Form.useForm()
const location = useLocation()
const navigate = useNavigate()
const requested = new URLSearchParams(location.search).get('section') || ''
const initialSection = useMemo<SectionKey>(() => (
SECTION_ALIASES[requested]?.section || (NAV.find((n) => n.key === requested)?.key as SectionKey) || 'ai'
), [requested])
const analysisRef = useRef<AnalysisVisionHandle>(null)
const coverRef = useRef<CoverModelHandle>(null)
const [speechOpen, setSpeechOpen] = useState(requested === 'speech')
useEffect(() => {
const anchor = SECTION_ALIASES[requested]?.anchor
if (anchor) setTimeout(() => document.getElementById(anchor)?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 120)
if (requested === 'speech') setSpeechOpen(true)
}, [requested])
const [active, setActive] = useState<SectionKey>(initialSection)
const [loading, setLoading] = useState(false)
const [testing, setTesting] = useState(false)
const [currentProvider, setCurrentProvider] = useState<any>({})
const [selectedProvider, setSelectedProvider] = useState<ProviderKey>('dashscope')
// 本地预设的模型探测:{ reachable, models } —— 让用户从下拉里选,而不是手敲 qwen2.5:7b
const [localModels, setLocalModels] = useState<{ loading: boolean; reachable: boolean | null; models: string[] }>({ loading: false, reachable: null, models: [] })
const [cloudModels, setCloudModels] = useState<{
loading: boolean
source: 'catalog' | 'live'
reachable: boolean
models: string[]
catalog: Record<string, string[]>
visionModels: string[]
imageModels: string[]
}>({ loading: false, source: 'catalog', reachable: false, models: FALLBACK_CATALOG.dashscope, catalog: FALLBACK_CATALOG, visionModels: [], imageModels: [] })
const [dashscopeRegion, setDashscopeRegion] = useState<DashscopeRegion>('cn')
const [analyticsOn, setAnalyticsOn] = useState(isAnalyticsEnabled())
const [feedbackOpen, setFeedbackOpen] = useState(false)
const runtime = getRuntimeInfo()
useEffect(() => { loadData() }, [])
useEffect(() => { setActive(initialSection) }, [initialSection])
// 桌面 / Docker / 本地脚本三种形态都走同一组 /settings 接口:settings.json 落在后端数据目录,
// API 进程与 worker 按 mtime 热重载。Docker 用户以前只能改 .env(#100)。
const loadData = async () => {
try {
const [settings, provider] = await Promise.allSettled([
settingsApi.getSettings(),
settingsApi.getCurrentProvider()
])
if (settings.status === 'rejected') console.warn('读取设置失败:', settings.reason)
const settingsData = settings.status === 'fulfilled' ? settings.value : {}
const providerData = provider.status === 'fulfilled'
? provider.value
: { available: false, provider: 'dashscope', display_name: t("阿里通义千问"), model: 'qwen-plus' }
// 以 settings.json 里保存的提供商为准;旧配置没有该字段时退回后端上报的当前提供商
const rawProvider = (settingsData.api?.api_provider || providerData.provider || 'dashscope') as string
// 旧版把 DeepSeek 走硅基流动;现在改官方渠道,打开设置页时切过去
const providerName = (rawProvider === 'siliconflow' ? 'deepseek' : rawProvider) as ProviderKey
setCurrentProvider(providerData)
const savedBaseUrl = settingsData.api?.api_base_url || ''
const localPreset = PROVIDERS[providerName]?.local
setDashscopeRegion(providerName === 'dashscope' && normalizeBaseUrl(savedBaseUrl) === DASHSCOPE_INTL_BASE_URL ? 'intl' : 'cn')
form.setFieldsValue({
llm_provider: providerName,
dashscope_api_key: settingsData.api?.api_keys?.dashscope || '',
openai_api_key: settingsData.api?.api_keys?.openai || '',
openai_base_url: localPreset || providerName === 'dashscope' ? '' : savedBaseUrl,
// 本地预设只在改过默认地址时才把地址填进表单
local_base_url: localPreset && savedBaseUrl && savedBaseUrl !== localPreset.baseUrl ? savedBaseUrl : '',
gemini_api_key: settingsData.api?.api_keys?.gemini || '',
siliconflow_api_key: settingsData.api?.api_keys?.siliconflow || '',
deepseek_api_key: settingsData.api?.api_keys?.deepseek || '',
kimi_api_key: settingsData.api?.api_keys?.kimi || '',
glm_api_key: settingsData.api?.api_keys?.glm || '',
grok_api_key: settingsData.api?.api_keys?.grok || '',
infistar_api_key: settingsData.api?.api_keys?.infistar || '',
seed_api_key: settingsData.api?.api_keys?.seed || '',
jimeng_access_key: settingsData.api?.api_keys?.jimeng_access || '',
jimeng_secret_key: settingsData.api?.api_keys?.jimeng_secret || '',
model_name: settingsData.api?.api_model || 'qwen-plus',
chunk_size: settingsData.processing?.processing_chunk_size || 5000,
min_score_threshold: settingsData.processing?.processing_min_score || 0.7,
max_clips_per_collection: settingsData.processing?.processing_max_clips || 5
})
const resolved = PROVIDERS[providerName] ? providerName : 'dashscope'
setSelectedProvider(resolved)
if (!isLocalProvider(resolved)) void loadCloudModels(resolved)
} catch (err) {
console.error('加载数据失败:', err)
}
}
const handleSave = async (values: any) => {
try {
setLoading(true)
// 先读现有配置,避免清空其它 provider 已保存的 key
let existing: any = null
try { existing = await settingsApi.getSettings() } catch (err) { console.warn('获取现有配置失败:', err) }
const keys = existing?.api?.api_keys || {}
const provider = (values.llm_provider || selectedProvider) as ProviderKey
await settingsApi.updateSettings({
basic: { app_name: 'AutoClip Desktop', app_version: runtime.version !== 'unknown' ? runtime.version : '1.0.0', debug_mode: false, auto_start: true },
service: { host: '127.0.0.1', port: 8000, max_memory_usage: 2048 },
api: {
api_keys: {
dashscope: values.dashscope_api_key || keys.dashscope || '',
openai: values.openai_api_key || keys.openai || '',
gemini: values.gemini_api_key || keys.gemini || '',
siliconflow: values.siliconflow_api_key || keys.siliconflow || '',
deepseek: values.deepseek_api_key || keys.deepseek || '',
kimi: values.kimi_api_key || keys.kimi || '',
glm: values.glm_api_key || keys.glm || '',
grok: values.grok_api_key || keys.grok || '',
infistar: values.infistar_api_key || keys.infistar || '',
seed: values.seed_api_key || keys.seed || '',
jimeng_access: values.jimeng_access_key || keys.jimeng_access || '',
jimeng_secret: values.jimeng_secret_key || keys.jimeng_secret || ''
},
api_provider: provider,
api_base_url: provider === 'openai'
? normalizeBaseUrl(values.openai_base_url)
: isLocalProvider(provider) ? normalizeBaseUrl(values.local_base_url)
: isCloudPreset(provider) ? (PROVIDERS[provider].cloud?.baseUrl || '')
: provider === 'dashscope' && dashscopeRegion === 'intl' ? DASHSCOPE_INTL_BASE_URL : '',
api_model: normalizeModelName(values.model_name) || 'qwen-plus',
api_max_tokens: 4096,
api_timeout: 30
},
processing: {
processing_chunk_size: toNumber(values.chunk_size, 5000),
processing_min_score: toNumber(values.min_score_threshold, 0.7),
processing_max_clips: toNumber(values.max_clips_per_collection, 5),
processing_max_retries: 3
},
logs: { log_level: 'INFO', log_retention_days: 7 }
// paths 由后端根据实际数据目录决定,前端不下发
})
await analysisRef.current?.save()
await coverRef.current?.save()
message.success(t("已保存"))
trackApiKeyConfigured({ provider, hasKey: isLocalProvider(provider) || !!values[PROVIDERS[provider].apiKeyField] })
await loadData()
} catch (err: any) {
message.error(t("保存失败: ") + (err.message || t("未知错误")))
} finally {
setLoading(false)
}
}
const handleTest = async () => {
const cfg = PROVIDERS[selectedProvider]
const local = isLocalProvider(selectedProvider)
const apiKey: string = local ? '' : (form.getFieldValue(cfg.apiKeyField) || '')
const baseUrl = selectedProvider === 'openai'
? normalizeBaseUrl(form.getFieldValue('openai_base_url'))
: local ? (normalizeBaseUrl(form.getFieldValue('local_base_url')) || cfg.local!.baseUrl)
: cfg.cloud ? cfg.cloud.baseUrl
: selectedProvider === 'dashscope' && dashscopeRegion === 'intl' ? DASHSCOPE_INTL_BASE_URL : ''
const modelName = normalizeModelName(form.getFieldValue('model_name'))
if (local && !modelName) {
message.error(t("请先选择一个模型"))
return
}
// 自建兼容服务(Ollama / vLLM 等)通常不需要 key,有地址就能测
if (!apiKey.trim() && !baseUrl) {
message.error(t("请先填写 API Key"))
return
}
try {
setTesting(true)
const r = await settingsApi.testApiKey(selectedProvider, apiKey, { baseUrl: baseUrl || undefined, model: modelName || undefined })
if (r.success) message.success(t("连接正常"))
else message.error(t("连接失败: ") + (r.error || t("未知错误")))
} catch (err: any) {
message.error(t("测试失败: ") + (err.message || t("未知错误")))
} finally {
setTesting(false)
}
}
const cloudBaseUrl = (p: ProviderKey = selectedProvider) => {
if (p === 'openai') return normalizeBaseUrl(form.getFieldValue('openai_base_url'))
if (PROVIDERS[p]?.cloud) return PROVIDERS[p].cloud!.baseUrl
if (p === 'dashscope' && dashscopeRegion === 'intl') return DASHSCOPE_INTL_BASE_URL
return ''
}
const loadCloudModels = async (p?: ProviderKey, opts: { refresh?: boolean } = {}) => {
const provider = p || selectedProvider
if (isLocalProvider(provider)) return
setCloudModels((s) => ({ ...s, loading: true }))
try {
const r = await settingsApi.getAvailableModels({
provider,
apiKey: form.getFieldValue(PROVIDERS[provider].apiKeyField) || '',
baseUrl: cloudBaseUrl(provider) || undefined,
refresh: opts.refresh,
})
setCloudModels({
loading: false,
source: r.source === 'live' ? 'live' : 'catalog',
reachable: !!r.reachable,
models: r.models?.length ? r.models : (r.catalog?.[provider] || FALLBACK_CATALOG[provider] || []),
catalog: r.catalog && Object.keys(r.catalog).length ? r.catalog : FALLBACK_CATALOG,
visionModels: r.vision_models || [],
imageModels: r.image_models || [],
})
} catch {
setCloudModels((s) => ({ ...s, loading: false, source: 'catalog', reachable: false }))
}
}
const detectLocalModels = async (p: ProviderKey, baseUrl?: string) => {
const preset = PROVIDERS[p]?.local
if (!preset) return
setLocalModels((s) => ({ ...s, loading: true }))
try {
const r = await settingsApi.listCompatibleModels({ provider: p, baseUrl: normalizeBaseUrl(baseUrl) || undefined })
setLocalModels({ loading: false, reachable: r.reachable, models: r.models || [] })
// 探测到模型且当前没选 / 选的不在列表里 → 帮用户选一个(优先预设默认)
const current = normalizeModelName(form.getFieldValue('model_name'))
if (r.reachable && r.models.length && (!current || !r.models.includes(current))) {
form.setFieldsValue({ model_name: r.models.includes(preset.defaultModel) ? preset.defaultModel : r.models[0] })
}
} catch {
setLocalModels({ loading: false, reachable: false, models: [] })
}
}
const handleProviderChange = (p: ProviderKey) => {
const prev = selectedProvider
setSelectedProvider(p)
form.setFieldsValue({ llm_provider: p })
const preset = PROVIDERS[p]?.local
const current = normalizeModelName(form.getFieldValue('model_name'))
if (preset) {
// 从云端切到本地时,qwen-plus 这类云端模型名对本地服务没意义
const known = knownCloudModels(cloudModels.catalog, cloudModels.models)
if (!current || known.has(current)) {
form.setFieldsValue({ model_name: preset.defaultModel || undefined })
}
void detectLocalModels(p, form.getFieldValue('local_base_url'))
} else {
// 换提供商时,别把 qwen-plus / gpt-5 这类别人的名字带过去
const known = knownCloudModels(cloudModels.catalog, cloudModels.models)
const own = new Set((cloudModels.catalog[p] || FALLBACK_CATALOG[p] || []))
if (isLocalProvider(prev) || !current || (known.has(current) && !own.has(current))) {
form.setFieldsValue({ model_name: CLOUD_DEFAULT_MODEL[p] })
}
void loadCloudModels(p)
}
}
// 打开设置页 / 切换通义站点时,顺手拉一次模型名单
useEffect(() => {
if (isLocalProvider(selectedProvider)) void detectLocalModels(selectedProvider, form.getFieldValue('local_base_url'))
else void loadCloudModels(selectedProvider)
}, [selectedProvider, dashscopeRegion])
const openaiBaseUrl = Form.useWatch('openai_base_url', form)
const usingCustomEndpoint = selectedProvider === 'openai' && !!normalizeBaseUrl(openaiBaseUrl)
const cfg = PROVIDERS[selectedProvider]
// 模型能力由后端 model_catalog 判断;本地模型认不出时按仅文字
const watchedModel = normalizeModelName(Form.useWatch('model_name', form))
const visionSet = new Set(cloudModels.visionModels)
const isMultimodal = (m: string) => visionSet.has(m)
const selectedMultimodal: boolean | null = watchedModel ? isMultimodal(watchedModel) || (!visionSet.size && null) : null
const localCfg = cfg.local
const keyUrl = selectedProvider === 'dashscope' && dashscopeRegion === 'intl'
? 'https://bailian.console.alibabacloud.com/?tab=model#/api-key'
: cfg.keyUrl
return (
<div className="ac-page">
<header>
@@ -422,12 +59,6 @@ const SettingsPage: React.FC = () => {
<span className="ac-mono">{runtime.version !== 'unknown' ? `v${runtime.version}` : 'dev'}</span>
<span className="dot" />
<span className="ac-mono">{runtime.os}/{runtime.arch}</span>
{currentProvider?.available && (
<>
<span className="dot" />
<span>{t("当前模型")}: <span className="ac-mono">{currentProvider.provider} · {currentProvider.model}</span></span>
</>
)}
</div>
</header>
@@ -439,242 +70,9 @@ const SettingsPage: React.FC = () => {
</nav>
<div className="ac-settings-body">
{/* ---------------- AI 分析:模型 → 分析方式与视觉 → 无字幕时的转写 → 封面生图,一个保存 ---------------- */}
{active === 'ai' && (
<Section title={t("AI 分析")} description={t("配置一次模型,就能分析视频、看画面、校对封面。密钥只保存在运行 AutoClip 的这台机器上,不会上传。")}>
<div id="ai-model" className="ac-subhead">{t("模型")}</div>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
requiredMark={false}
initialValues={{ llm_provider: 'dashscope', model_name: 'qwen-plus', chunk_size: 5000, min_score_threshold: 0.7, max_clips_per_collection: 5 }}
>
<Form.Item name="llm_provider" hidden><Input /></Form.Item>
<div className="ac-rows">
<Row wide label={t("提供商")} hint={cfg.hint}>
<Select
aria-label={t("提供商")}
value={selectedProvider}
onChange={(v) => handleProviderChange(v as ProviderKey)}
showSearch
optionFilterProp="search"
style={{ width: '100%' }}
popupClassName="ac-provider-popup"
options={providerPickerOptions() as any}
optionRender={(option) => {
const p = PROVIDERS[option.value as ProviderKey]
return (
<div className="ac-provider-option">
<span className="name">{p.name}{p.sponsor && <span className="ac-badge">{t("赞助")}</span>}</span>
<span className="hint">{p.hint}</span>
</div>
)
}}
/>
</Row>
{cfg.sponsor && (
<div className="ac-sponsor">
<div className="ac-sponsor-text">
<b>{cfg.name}<span className="ac-badge">{t("赞助")}</span></b>
<span>{t("通过专属链接注册可领取 $5 体验额度,注册后在控制台创建 API Key 粘贴到下方即可开始。")}</span>
<small>{t("该链接含推广分成,用于支持项目维护;领取条件以活动页面为准。")}</small>
</div>
<div className="ac-sponsor-actions">
<Btn variant="cta" size="sm" onClick={() => { trackSponsorLinkOpened({ sponsor: 'infistar', target: 'register', placement: 'settings_model' }); openExternalLink(cfg.sponsor!.registerUrl) }}>{t("注册并领取体验额度")}</Btn>
<Btn variant="text" size="sm" onClick={() => { trackSponsorLinkOpened({ sponsor: 'infistar', target: 'guide', placement: 'settings_model' }); openExternalLink(cfg.sponsor!.guideUrl) }}>{t("接入说明")}</Btn>
</div>
</div>
)}
{localCfg && (
<Row
wide
label={t("服务地址")}
hint={<>{t('本地服务提示', { url: localCfg.baseUrl })} <a href={localCfg.docsUrl} onClick={(e) => { e.preventDefault(); openExternalLink(localCfg.docsUrl) }} style={{ color: 'var(--ac-accent)' }}>{localCfg.app} · {t('官网')}</a></>}
>
<Form.Item
name="local_base_url"
style={{ width: '100%' }}
rules={[{
validator: (_, value) => {
const url = normalizeBaseUrl(value)
if (!url || /^https?:\/\/\S+$/.test(url)) return Promise.resolve()
return Promise.reject(new Error(t("请输入以 http:// 或 https:// 开头的地址")))
},
}]}
>
<Input
placeholder={localCfg.baseUrl}
allowClear
className="ac-mono"
onBlur={(e) => void detectLocalModels(selectedProvider, e.target.value)}
/>
</Form.Item>
</Row>
)}
{selectedProvider === 'dashscope' && (
<Row
label={t("站点")}
hint={dashscopeRegion === 'intl'
? <>{t("国际站(alibabacloud.com)的 Key,请求发往")} <span className="ac-mono">dashscope-intl.aliyuncs.com</span>。</>
: t("在阿里云中国站(aliyun.com)开通的 Key 选这个;海外账号选国际站。")}
>
<Segmented
size="sm"
ariaLabel={t("通义千问站点")}
value={dashscopeRegion}
onChange={setDashscopeRegion}
options={[{ value: 'cn', label: t("中国站") }, { value: 'intl', label: t("国际站") }]}
/>
</Row>
)}
{selectedProvider === 'openai' && (
<Row
wide
label={t("接口地址")}
hint={<>{t("留空用 OpenAI 官方地址。兼容服务填自己的,例如")} <span className="ac-mono">https://api.deepseek.com/v1</span>、<span className="ac-mono">http://localhost:11434/v1</span>(Ollama)。</>}
>
<Form.Item
name="openai_base_url"
style={{ width: '100%' }}
rules={[{
validator: (_, value) => {
const url = normalizeBaseUrl(value)
if (!url || /^https?:\/\/\S+$/.test(url)) return Promise.resolve()
return Promise.reject(new Error(t("请输入以 http:// 或 https:// 开头的地址")))
},
}]}
>
<Input placeholder="https://api.openai.com/v1" allowClear className="ac-mono" onBlur={() => void loadCloudModels()} />
</Form.Item>
</Row>
)}
{!localCfg && <Row
wide
label="API Key"
hint={usingCustomEndpoint
? t("自建 / 本地兼容服务不校验密钥时可留空。")
: <><a href={keyUrl} onClick={(e) => { e.preventDefault(); openExternalLink(keyUrl) }} style={{ color: 'var(--ac-accent)' }}>{cfg.name}{selectedProvider === 'dashscope' && dashscopeRegion === 'intl' ? ' · ' + t("国际站") : ''} · {t("控制台")}</a></>}
>
<Form.Item
name={cfg.apiKeyField}
style={{ width: '100%' }}
rules={usingCustomEndpoint ? [] : [
{ required: true, message: t("请输入 API Key") },
{ min: 10, message: t("API Key 长度不能少于 10 位") }
]}
>
<Input.Password placeholder={cfg.placeholder} className="ac-mono" onBlur={() => void loadCloudModels()} />
</Form.Item>
</Row>}
<Row
wide
label={t("模型")}
hint={localCfg
? (localModels.loading
? t("正在检测本地服务…")
: localModels.reachable
? <>{t("已连接模型数量", { count: localModels.models.length })} <a onClick={() => void detectLocalModels(selectedProvider, form.getFieldValue('local_base_url'))} style={{ color: 'var(--ac-accent)', cursor: 'pointer' }}>{t("刷新")}</a></>
: localModels.reachable === false
? <>{t('服务未连接', { app: localCfg.app })} <a onClick={() => void detectLocalModels(selectedProvider, form.getFieldValue('local_base_url'))} style={{ color: 'var(--ac-accent)', cursor: 'pointer' }}>{t('重新检测')}</a>{localCfg.defaultModel && <div className="ac-mono">ollama pull {localCfg.defaultModel}</div>}</>
: t("从本地服务已加载的模型中选择。"))
: cloudModels.loading
? t("正在拉取最新模型列表…")
: cloudModels.source === 'live'
? <>{t("已拉取最新模型数量", { count: cloudModels.models.length })} <a onClick={() => void loadCloudModels(selectedProvider, { refresh: true })} style={{ color: 'var(--ac-accent)', cursor: 'pointer' }}>{t("刷新")}</a></>
: usingCustomEndpoint
? <>{t("填该服务实际提供的模型名(如 glm-4-flash、deepseek-chat、qwen2.5:7b),回车确认。")} <a onClick={() => void loadCloudModels(selectedProvider, { refresh: true })} style={{ color: 'var(--ac-accent)', cursor: 'pointer' }}>{t("刷新")}</a></>
: cfg.sponsor && !cloudModels.models.length
? <>{t("填写 API Key 后会自动列出该账号可用的模型,选一个即可。")} <a onClick={() => void loadCloudModels(selectedProvider, { refresh: true })} style={{ color: 'var(--ac-accent)', cursor: 'pointer' }}>{t("刷新")}</a></>
: <>{t("可直接输入模型名,回车确认。")} {t("填写密钥后可拉取该账号可用的最新模型。")} <a onClick={() => void loadCloudModels(selectedProvider, { refresh: true })} style={{ color: 'var(--ac-accent)', cursor: 'pointer' }}>{t("刷新")}</a></>}
>
<Form.Item name="model_name" style={{ width: '100%' }} rules={[{ required: true, message: t("请输入或选择模型") }]}>
<Select
placeholder={localCfg ? (localCfg.defaultModel || t("选择或输入模型名")) : (CLOUD_DEFAULT_MODEL[selectedProvider] || t("选择或输入模型名"))}
showSearch
allowClear
mode="tags"
maxCount={1}
loading={localCfg ? localModels.loading : cloudModels.loading}
className="ac-mono"
options={(localCfg
? localModels.models.map((m) => ({ value: m, label: m }))
: cloudModelOptions(selectedProvider, cloudModels)) as any}
optionRender={(option) => (
<span className="ac-model-option">
<span className="ac-mono">{option.value as string}</span>
{!localCfg && <span className="ac-badge">{isMultimodal(option.value as string) ? t("多模态") : t("仅文字")}</span>}
</span>
)}
/>
</Form.Item>
</Row>
{!localCfg && (
<CoverModelRow ref={coverRef} provider={selectedProvider} imageModels={cloudModels.imageModels} />
)}
{selectedProvider === 'deepseek' && (
<Row
wide
label="DeepSeek"
hint={t("切片分析会关闭 DeepSeek 的思考模式,避免按长段推理输出计费。长视频仍会按大约 30 分钟一块、分几步调用;刷新页面不会重新计费,重新开始处理才会。")}
/>
)}
<Row label={t("连接测试")} hint={localCfg ? t("保存前先测一下本地服务和模型是否可用。") : t("保存前先测一下密钥和模型是否可用。")}>
<Btn size="sm" loading={testing} onClick={handleTest}>{t("测试连接")}</Btn>
</Row>
</div>
<details className="ac-disclosure">
<summary>{t("切片参数")}</summary>
<div className="ac-rows">
<Row label={t("文本分块大小")} hint={t("每次送给模型分析的字幕长度。越大越连贯、越慢,建议 5000。")}>
<Form.Item name="chunk_size">
<input className="ac-input ac-input--mono" type="number" min={1000} step={500} style={{ width: 120, textAlign: 'right' }} />
</Form.Item>
<span className="ac-unit">{t("字符")}</span>
</Row>
<Row label={t("最低评分阈值")} hint={t("低于此分的片段会被丢掉。切片为 0 时可以调低。")}>
<Form.Item name="min_score_threshold">
<input className="ac-input ac-input--mono" type="number" min={0} max={1} step={0.05} style={{ width: 120, textAlign: 'right' }} />
</Form.Item>
<span className="ac-unit" />
</Row>
<Row label={t("每个合集最多切片")} hint={t("AI 推荐合集时,一个主题最多串几条。")}>
<Form.Item name="max_clips_per_collection">
<input className="ac-input ac-input--mono" type="number" min={1} max={20} style={{ width: 120, textAlign: 'right' }} />
</Form.Item>
<span className="ac-unit">{t("条")}</span>
</Row>
</div>
</details>
<div id="ai-analysis" className="ac-subhead">{t("分析方式")}</div>
<AnalysisVisionSettings ref={analysisRef} multimodal={localCfg ? false : selectedMultimodal} />
<div id="ai-speech" className="ac-subhead">{t("无字幕时的转写")}</div>
<details className="ac-disclosure" open={speechOpen} onToggle={(e) => setSpeechOpen((e.target as HTMLDetailsElement).open)}>
<summary>{t("视频没有字幕时,用本机 Whisper 生成字幕再分析。自带字幕的视频不需要。")}</summary>
<SpeechRecognitionConfig />
</details>
<div className="ac-savebar">
{currentProvider?.available && (
<StatusDot tone="ok" label={<>{t("已配置")}: <span className="ac-mono">{PROVIDERS[currentProvider.provider as ProviderKey]?.name || currentProvider.display_name} · {currentProvider.model}</span></>} />
)}
<Btn variant="cta" loading={loading} onClick={() => form.submit()}>{t("保存")}</Btn>
</div>
</Form>
</Section>
)}
<div hidden={active !== 'ai'}>
<AIModelSettings requestedSection={requested} />
</div>
{/* ---------------- 应用 ---------------- */}
{active === 'app' && (
+1 -1
View File
@@ -12,7 +12,7 @@ export interface CoverConfigView {
allow_send_frame: boolean
configured: boolean
source: string
key_source?: 'own' | 'env' | 'text_model' | 'none'
key_source?: 'own' | 'env' | 'text_model' | 'connection' | 'none'
mode?: 'text_model' | 'custom'
note?: string | null
}
+6
View File
@@ -318,6 +318,12 @@ export const settingsApi = {
}
// 项目相关API
/** Bundled, already-finished example project (no source video, no model key needed). */
export const exampleProjectApi = {
info: () => api.get<unknown, { available: boolean; project_id: string | null }>('/example-project'),
create: () => api.post<unknown, { project_id: string; name: string }>('/example-project/create'),
}
export const projectApi = {
// 获取视频分类配置
getVideoCategories: async (): Promise<VideoCategoriesResponse> => {
+55 -19
View File
@@ -242,11 +242,11 @@
.ac-cal-item { display: flex; align-items: center; gap: 6px; margin-top: 6px; min-width: 0; font-size: 12px; color: var(--ac-ink); line-height: 1.3; }
.ac-cal-item b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- provider picker: grouped, searchable; scales as providers grow ---- */
.ac-provider-popup .ant-select-item-group { font-size: 11.5px; color: var(--ac-muted); padding-top: 10px; }
.ac-provider-option { display: flex; flex-direction: column; gap: 2px; padding: 2px 0; min-width: 0; }
.ac-provider-option .name { display: inline-flex; align-items: center; gap: 6px; font-size: 13.5px; font-weight: 500; color: var(--ac-ink); }
.ac-provider-option .hint { font-size: 12px; color: var(--ac-sub); white-space: normal; line-height: 1.45; }
/* 可换行的单选胶囊组:发丝边,选中只加深边框和底色,不上色 */
.ac-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.ac-chip { appearance: none; font: inherit; font-size: 12.5px; color: var(--ac-sub); height: 28px; padding: 0 12px; border: 1px solid var(--ac-line); border-radius: 999px; background: transparent; cursor: pointer; transition: border-color var(--ac-t-short, 200ms) ease-out, color var(--ac-t-short, 200ms) ease-out; }
.ac-chip:hover { color: var(--ac-ink); border-color: var(--ac-muted); }
.ac-chip[aria-checked="true"] { color: var(--ac-ink); border-color: var(--ac-ink); background: var(--ac-card); }
/* 中性小徽章:发丝边 + 灰字,不用彩色 chip */
.ac-badge { display: inline-flex; align-items: center; height: 18px; padding: 0 7px; border: 1px solid var(--ac-line); border-radius: 999px; font-size: 11px; font-weight: 500; color: var(--ac-sub); background: transparent; }
@@ -263,25 +263,61 @@
}
/* ---- unified AI settings page ---- */
.ac-subhead { margin: 44px 0 6px; font-size: 15px; font-weight: 600; color: var(--ac-ink); scroll-margin-top: 24px; }
.ac-subhead:first-child { margin-top: 8px; }
.ac-note { margin: 10px 0 4px; font-size: 12.5px; line-height: 1.6; color: var(--ac-sub); }
.ac-note--error { color: var(--ac-error); }
.ac-disclosure { margin-top: 18px; }
.ac-disclosure > summary { cursor: pointer; padding: 8px 0; font-size: 13px; color: var(--ac-sub); list-style: none; }
.ac-disclosure > summary::before { content: '›'; display: inline-block; width: 14px; transition: transform var(--ac-t-short, 200ms) ease-out; }
.ac-disclosure[open] > summary::before { transform: rotate(90deg); }
.ac-disclosure > summary::-webkit-details-marker { display: none; }
/* 分析方式:三张发丝边卡片,选中只加深边框,不上色 */
.ac-mode-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 12px; }
.ac-mode-card { appearance: none; text-align: left; font: inherit; cursor: pointer; display: flex; flex-direction: column; gap: 6px; padding: 16px; border: 1px solid var(--ac-line); border-radius: 16px; background: var(--ac-card); color: var(--ac-ink); transition: border-color var(--ac-t-short, 200ms) ease-out; }
.ac-mode-card:hover { border-color: var(--ac-muted); }
.ac-mode-card[aria-checked="true"] { border-color: var(--ac-ink); box-shadow: 0 0 0 1px var(--ac-ink) inset; }
.ac-mode-card b { font-size: 14px; font-weight: 500; }
.ac-mode-card span { font-size: 12.5px; color: var(--ac-sub); line-height: 1.55; }
.ac-mode-card small { font-size: 12px; color: var(--ac-muted); line-height: 1.5; }
.ac-mode-card small.cost { color: var(--ac-ink); }
.ac-savebar { position: sticky; bottom: 0; display: flex; justify-content: flex-end; align-items: center; gap: 12px; margin-top: 36px; padding: 14px 0; background: var(--ac-bg); border-top: 1px solid var(--ac-line); z-index: 2; }
@media (max-width: 860px) { .ac-mode-cards { grid-template-columns: 1fr; } }
.ac-model-option { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; }
.ac-model-option .ac-mono { overflow: hidden; text-overflow: ellipsis; }
.ac-link { color: var(--ac-accent); cursor: pointer; }
.ac-model-fields { border: 0; padding: 0; margin: 0; min-width: 0; }
.ac-model-fields:disabled { opacity: .65; pointer-events: none; }
.ac-model-section-title { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; font-size: 16px; font-weight: 600; color: var(--ac-ink); }
#ai-speech, #ai-model, #ai-cover { scroll-margin-top: 120px; }
/* ---- first-run dialog: settings rows stacked to fit the 520px dialog ---- */
.ac-setup-dialog.ac-ai-settings { padding-top: 0; }
.ac-setup-dialog.ac-ai-settings .ac-row { grid-template-columns: minmax(0, 1fr); gap: 8px; padding: 14px 0; }
.ac-setup-dialog.ac-ai-settings .ac-row-label { padding-top: 0; }
.ac-setup-dialog.ac-ai-settings .ac-rows .ac-row:first-child { padding-top: 4px; }
.ac-setup-dialog.ac-ai-settings .ac-sponsor { margin: 12px 0 4px; }
.ac-setup-status { padding-top: 14px; border-top: 1px solid var(--ac-line); }
.ac-setup-foot { display: flex; align-items: center; gap: 8px; width: 100%; }
.ac-setup-foot > .ac-btn--text { margin-right: auto; }
@media (max-width: 420px) {
.ac-header-permanent .ant-select { max-width: 100px !important; }
}
/* Model settings share one label/control grid, including nested local-model rows. */
.ac-ai-settings { min-width: 0; padding-top: 8px; }
.ac-ai-settings .ac-model-section + .ac-model-section { margin-top: 40px; }
.ac-ai-settings .ac-model-section-title { margin: 0; }
.ac-ai-settings .ac-model-section > .ac-note { margin: 8px 0 20px; }
.ac-ai-settings .ac-rows { border-top: 0; }
.ac-ai-settings .ac-row { display: grid; grid-template-columns: minmax(180px, 2fr) minmax(0, 3fr); align-items: start; gap: 24px; padding: 20px 0; }
.ac-ai-settings .ac-row-label { padding-top: 9px; }
.ac-ai-settings .ac-row-control,
.ac-ai-settings .ac-row-control--wide { width: 100%; min-width: 0; justify-content: flex-start; padding-top: 0; }
.ac-ai-settings .ac-row-control > .ant-select,
.ac-ai-settings .ac-row-control > .ant-input-affix-wrapper,
.ac-ai-settings .ac-row-control > .ant-input { width: 100%; }
.ac-ai-settings .ant-select-single { height: 40px; }
.ac-ai-settings .ant-input-affix-wrapper,
.ac-ai-settings .ac-row-control > .ant-input { min-height: 40px; }
.ac-ai-settings .ac-input { height: 40px; border-radius: 16px; }
.ac-ai-settings .ac-disclosure { margin-top: 0; border-bottom: 1px solid var(--ac-line); }
.ac-ai-settings .ac-disclosure > summary { padding: 16px 0; }
.ac-ai-settings .ac-disclosure .ac-row:last-child { border-bottom: 0; }
/* The save bar draws its own hairline; the last section must not add a second one. */
.ac-ai-settings .ac-model-fields > :last-child { border-bottom: 0; }
.ac-ai-settings .ac-model-fields > :last-child .ac-row:last-child { border-bottom: 0; }
.ac-ai-settings .ac-model-control-action { display: flex; justify-content: flex-end; gap: 4px; margin-top: 4px; }
.ac-ai-settings .ac-model-control-action .ac-btn { padding-right: 0; }
.ac-ai-settings .ac-sponsor { margin: 16px 0; }
.ac-ai-settings .ac-row-control .ac-note { display: block; margin: 8px 0 0; }
@media (max-width: 680px) {
.ac-ai-settings .ac-row { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.ac-ai-settings .ac-row-label { padding-top: 0; }
}
+4 -1
View File
@@ -109,7 +109,10 @@ export const Dialog: React.FC<{
}> = ({ open, onClose, title, description, footer, children }) => {
React.useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
const onKey = (e: KeyboardEvent) => {
// Esc with an AntD dropdown open only closes the dropdown, not the dialog behind it.
if (e.key === 'Escape' && !document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')) onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
+2 -2
View File
@@ -10,7 +10,7 @@ function view(mode,goals,available=true){
'react-i18next':{useTranslation:()=>{}},'../../i18n':{t:x=>x},
react:{useState:()=>{const n=index++;return [states[n],v=>{states[n]=v}]},useEffect:()=>{}},
'react/jsx-runtime':{jsx:element,jsxs:element,Fragment:'fragment'},
'../../ui':{Btn:'button',Dialog:'dialog',fmtDuration:String},
'../../ui':{Btn:'button',Dialog:'dialog',Row:'row',Segmented:'segmented',fmtDuration:String},
'./types':{defaultImportOptions:{},goalLabels:{content:'content',highlight:'highlight',promo:'promo'},languages:[]},
'./ImportPreferences':{default:'preferences'},
'./api':{studioApi:{confirmPlan:async(...args)=>calls.push(args)},errorText:String}
@@ -22,7 +22,7 @@ function view(mode,goals,available=true){
test('visual selection blocks content-only output without silently changing choices',()=>{
const x=view('visual',['content']);let nodes=x.render()
assert.equal(nodes.find(n=>n.props?.children==='确认并开始制作').props.disabled,true)
nodes.find(n=>n.type==='select').props.onChange({target:{value:'subtitle'}})
nodes.find(n=>n.type==='segmented').props.onChange('subtitle')
assert.deepEqual(x.states[4],['content'])
assert.equal(x.render().find(n=>n.props?.children==='确认并开始制作').props.disabled,false)
})
+66
View File
@@ -0,0 +1,66 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs'), vm = require('node:vm'), ts = require('typescript'), path = require('node:path')
const exportsForTest = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(__dirname, '../src/features/settings/modelDefaults.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { exports: exportsForTest })
const { defaultModel, applyModelDefaults, analysisModels } = exportsForTest
const connection = { id: 'main', provider: 'dashscope' }
const models = [
{ id: 'qwen3.8-max', capability: 'multimodal', analysis: true, image: false },
{ id: 'qwen3.8-flash', capability: 'multimodal', analysis: true, image: false },
{ id: 'wanx2.1-t2i-turbo', image: true, analysis: false },
]
const blank = () => ({ analysis: { connection_id: 'main', model: '', capability: 'auto' }, vision: null, cover: null, cover_enabled: false })
test('first setup automatically chooses supported analysis and cover models', () => {
const value = applyModelDefaults(blank(), connection, models, true)
assert.equal(value.analysis.model, 'qwen3.8-flash')
assert.equal(value.cover.model, 'wanx2.1-t2i-turbo')
assert.equal(value.cover.connection_id, 'main')
assert.equal(value.cover_enabled, true)
})
test('refresh never overrides explicit model or frame-only choice', () => {
const state = blank(); state.analysis.model = 'my-custom-model'
const value = applyModelDefaults(state, connection, models, false)
assert.equal(value.analysis.model, 'my-custom-model')
assert.equal(value.cover, null)
assert.equal(value.cover_enabled, false)
})
test('refresh from a different provider cannot fill the active selection', () => {
assert.equal(applyModelDefaults(blank(), { id: 'other', provider: 'dashscope' }, models, true).analysis.model, '')
})
test('independent cover selection remains unchanged', () => {
const state = blank(); state.cover = { connection_id: 'image-provider', model: 'custom-image' }; state.cover_enabled = true
const value = applyModelDefaults(state, connection, models, true)
assert.equal(value.cover.connection_id, 'image-provider')
assert.equal(value.cover.model, 'custom-image')
})
test('recommendations only select models returned by the service', () => {
assert.equal(defaultModel('dashscope', [{ id: 'new-model', analysis: true, capability: 'multimodal' }]), 'new-model')
assert.equal(defaultModel('dashscope', [{ id: 'new-image-protocol', image: true }], true), '')
assert.equal(defaultModel('infistar', [], false), '')
})
test('Infistar recommends newly discovered generation models without a static allowlist', () => {
assert.equal(defaultModel('infistar', [{ id: 'new-image', image: true }], true), 'new-image')
})
test('analysis recommendations follow the selected input mode', () => {
const mixed = [...models, { id: 'qwen-plus', capability: 'text', analysis: true }]
const text = applyModelDefaults({ ...blank(), analysis_mode: 'subtitle' }, connection, mixed, false)
assert.equal(text.analysis.model, 'qwen3.8-flash')
const visual = applyModelDefaults({ ...blank(), analysis_mode: 'auto' }, connection, mixed, false)
assert.equal(visual.analysis.model, 'qwen3.8-flash')
assert.equal(applyModelDefaults({ ...blank(), analysis_mode: 'subtitle' }, connection, models, false).analysis.model, 'qwen3.8-flash')
})
test('ASR defaults exclude unsupported timestamps and preserve explicit choices', () => {
const state = { ...blank(), transcription: { provider: 'cloud', connection_id: 'main', model: '' } }
const entries = [{ id: 'filetrans', asr: true, asr_supported: false }, { id: 'qwen-audio-3.0-asr-flash', asr: true, asr_supported: true }]
assert.equal(applyModelDefaults(state, connection, entries, false).transcription.model, 'qwen-audio-3.0-asr-flash')
state.transcription.model = 'custom'
assert.equal(applyModelDefaults(state, connection, entries, false).transcription.model, 'custom')
})
test('subtitle and smart mode accept every analysis model; only explicit visual excludes text-only and unknown', () => {
const mixed = [...models, { id: 'text-model', capability: 'text', analysis: true }, { id: 'unknown-model', capability: null, analysis: true }]
assert.deepEqual(Array.from(analysisModels(mixed, 'subtitle'), m => m.id), ['qwen3.8-max', 'qwen3.8-flash', 'text-model', 'unknown-model'])
assert.deepEqual(Array.from(analysisModels(mixed, 'auto'), m => m.id), ['qwen3.8-max', 'qwen3.8-flash', 'text-model', 'unknown-model'])
assert.deepEqual(Array.from(analysisModels(mixed, 'visual'), m => m.id), ['qwen3.8-max', 'qwen3.8-flash'])
})
@@ -0,0 +1,146 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'), ts = require('typescript')
const dir = path.join(__dirname, '../src/features/settings')
function load(file, mocks) {
const exports = {}
const code = ts.transpileModule(fs.readFileSync(path.join(dir, file), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
vm.runInNewContext(code, { exports, require: id => { assert.ok(id in mocks, id); return mocks[id] }, crypto: { randomUUID: () => 'uuid' } })
return exports
}
const providers = {
PROVIDERS: {
dashscope: { name: '阿里云百炼' }, openai: { name: 'OpenAI' }, deepseek: { name: 'DeepSeek' },
compatible: { name: '自定义' }, ollama: { name: 'Ollama', local: { baseUrl: 'http://localhost:11434/v1' } },
},
}
const defaults = load('modelDefaults.ts', {})
const logic = load('modelSettingsLogic.ts', { './providers': providers, './modelDefaults': defaults })
const plain = x => JSON.parse(JSON.stringify(x))
const conn = (id, provider, extra = {}) => ({ id, name: provider, provider, base_url: '', api_key: '', image_api: 'auto', image_base_url: '', ...extra })
const fresh = () => ({
version: 1, saved: false,
connections: [conn('legacy-analysis', 'dashscope')],
analysis: { connection_id: 'legacy-analysis', model: 'qwen-plus', capability: 'auto' },
vision: null, cover: null, transcription: { provider: 'whisper_local', model: 'base' },
cover_enabled: false, allow_send_frame: false, analysis_mode: 'auto', allow_visual_screening: true,
chunk_size: 5000, min_score_threshold: .7, max_clips_per_collection: 5,
})
const models = [
{ id: 'qwen3.8-flash', capability: 'multimodal', analysis: true, image: false },
{ id: 'qwen-plus', capability: 'text', analysis: true, image: false },
{ id: 'wanx2.1-t2i-turbo', analysis: false, image: true },
]
test('first run: nothing is pre-selected; choosing a provider fills the recommendation and AI cover', () => {
const value = logic.prepareLoaded(fresh())
assert.equal(logic.needsSetup(value), true)
assert.deepEqual([value.analysis, value.cover, value.vision], [null, null, null], 'no provider is pre-selected on first run')
assert.equal(value.analysis_mode, 'auto')
assert.deepEqual([value.cover_enabled, value.allow_send_frame], [true, true], 'AI covers with a frame reference are on by default')
assert.deepEqual(plain(logic.saveIssue(value)), { role: 'analysis', reason: 'provider' })
const chosen = logic.chooseProvider(value, 'analysis', 'dashscope', {}, true)
assert.equal(chosen.connection.id, 'legacy-analysis', 'the legacy connection of that provider is reused')
assert.equal(chosen.settings.analysis.model, '')
const filled = defaults.applyModelDefaults(chosen.settings, chosen.connection, models, true)
assert.equal(filled.analysis.model, 'qwen3.8-flash')
assert.equal(filled.cover_enabled, true)
assert.equal(filled.cover.model, 'wanx2.1-t2i-turbo')
})
test('setup is not needed once credentials exist, even before the new document is saved', () => {
const withKey = { ...fresh(), connections: [conn('legacy-analysis', 'dashscope', { has_key: true })] }
assert.equal(logic.needsSetup(withKey), false)
assert.equal(logic.prepareLoaded(withKey).analysis.model, 'qwen-plus')
const local = { ...fresh(), connections: [conn('legacy-analysis', 'ollama')] }
assert.equal(logic.needsSetup(local), false)
const custom = { ...fresh(), connections: [conn('legacy-analysis', 'compatible', { base_url: 'http://gw.local/v1' })] }
assert.equal(logic.needsSetup(custom), false)
})
test('text-only models stay eligible in smart mode; only the explicit visual route needs multimodal', () => {
assert.deepEqual(defaults.analysisModels(models, 'auto').map(m => m.id), ['qwen3.8-flash', 'qwen-plus'])
assert.deepEqual(defaults.analysisModels(models, 'subtitle').map(m => m.id), ['qwen3.8-flash', 'qwen-plus'])
assert.deepEqual(defaults.analysisModels(models, 'visual').map(m => m.id), ['qwen3.8-flash'])
const deepseek = { ...fresh(), connections: [conn('d', 'deepseek', { api_key: 'k' })], analysis: logic.cleanBinding('d') }
const filled = defaults.applyModelDefaults(deepseek, deepseek.connections[0], [{ id: 'deepseek-flash', capability: 'text', analysis: true }], false)
assert.equal(filled.analysis.model, 'deepseek-flash')
assert.equal(logic.isTextOnly(filled, { d: { models: [{ id: 'deepseek-flash', capability: 'text', analysis: true }] } }), true)
})
test('the frame-analysis switch maps to smart/subtitle mode and never keeps a stale vision binding', () => {
const on = logic.setVisual({ ...fresh(), analysis_mode: 'subtitle', allow_visual_screening: false, vision: { connection_id: 'x', model: 'm', capability: 'auto' } }, true)
assert.deepEqual([on.analysis_mode, on.allow_visual_screening, on.vision], ['auto', true, null])
const off = logic.setVisual(on, false)
assert.deepEqual([off.analysis_mode, off.allow_visual_screening], ['subtitle', false])
})
test('choosing a provider reuses its saved connection and never moves the analysis key to the cover', () => {
const state = { ...fresh(), connections: [conn('a', 'dashscope', { has_key: true }), conn('b', 'openai', { has_key: true })], analysis: { connection_id: 'a', model: 'qwen-plus', capability: 'auto' }, cover: { connection_id: 'a', model: 'wanx', capability: 'auto' }, cover_enabled: true }
const toOpenAI = logic.chooseProvider(state, 'analysis', 'openai', {}, true)
assert.equal(toOpenAI.changed, true)
assert.equal(toOpenAI.connection.id, 'b')
assert.equal(toOpenAI.settings.connections.length, 2)
assert.deepEqual(plain(toOpenAI.settings.cover), { connection_id: 'b', model: '', capability: 'auto' })
const separateCover = logic.chooseProvider(toOpenAI.settings, 'cover', 'openai', {}, false, p => conn('new', p))
assert.equal(separateCover.connection.id, 'new', 'cover must not share the analysis connection')
const same = logic.chooseProvider(toOpenAI.settings, 'analysis', 'openai', {}, false)
assert.equal(same.changed, false)
})
test('a cached live list fills the recommendation immediately when switching provider', () => {
const lists = { b: { models: [{ id: 'gpt-5-mini', capability: 'multimodal', analysis: true }, { id: 'dall-e-3', image: true }], source: 'live', preview: false } }
const result = logic.chooseProvider({ ...fresh(), connections: [conn('a', 'dashscope'), conn('b', 'openai', { has_key: true })] }, 'analysis', 'openai', lists, true)
assert.equal(result.settings.analysis.model, 'gpt-5-mini')
assert.equal(result.settings.cover_enabled, true)
})
test('cover follows or separates from the main connection without nested toggles', () => {
const lists = { 'legacy-analysis': { models, source: 'live' } }
const separate = logic.setCoverSeparate(fresh(), true, lists, p => conn('cover', p))
assert.equal(separate.cover.connection_id, 'cover')
assert.equal(separate.connections.at(-1).provider, 'dashscope')
const back = logic.setCoverSeparate(separate, false, lists)
assert.deepEqual(plain(back.cover), { connection_id: 'legacy-analysis', model: 'wanx2.1-t2i-turbo', capability: 'auto' })
const enabled = logic.setCoverEnabled(fresh(), true, lists)
assert.equal(enabled.cover_enabled, true)
assert.equal(enabled.cover.model, 'wanx2.1-t2i-turbo')
})
test('changing an address drops the stored key so it cannot leak to another endpoint', () => {
const state = { ...fresh(), connections: [conn('a', 'compatible', { has_key: true, base_url: 'https://old/v1' })] }
const edited = logic.editConnection(state, 'a', { base_url: 'https://new/v1' })
assert.deepEqual([edited.connections[0].has_key, edited.connections[0].api_key], [false, ''])
const keyOnly = logic.editConnection(state, 'a', { api_key: 'sk' })
assert.equal(keyOnly.connections[0].has_key, true)
})
test('save validation reports the first blocking field in page order and prunes blank custom connections', () => {
assert.deepEqual(plain(logic.saveIssue(fresh())), { role: 'analysis', reason: 'key' })
const noModel = { ...fresh(), connections: [conn('a', 'dashscope', { api_key: 'k' })], analysis: logic.cleanBinding('a') }
assert.deepEqual(plain(logic.saveIssue(noModel)), { role: 'analysis', reason: 'model' })
const ok = { ...noModel, analysis: { connection_id: 'a', model: 'qwen3.8-flash', capability: 'auto' } }
assert.equal(logic.saveIssue(ok), null)
const cover = { ...ok, cover_enabled: true, cover: logic.cleanBinding('a') }
assert.deepEqual(plain(logic.saveIssue(cover)), { role: 'cover', reason: 'model' })
const cloud = { ...ok, transcription: { provider: 'cloud', connection_id: 'a', model: '' } }
assert.deepEqual(plain(logic.saveIssue(cloud)), { role: 'transcription', reason: 'model' })
const doc = logic.forSave({ ...ok, connections: [...ok.connections, conn('blank', 'compatible')], cover: { connection_id: 'a', model: '', capability: 'auto' } })
assert.deepEqual(doc.connections.map(c => c.id), ['a'])
assert.equal(doc.cover, null)
})
test('AI covers fall back to frames only when the service has no image model at all', () => {
const state = { ...fresh(), cover_enabled: true, cover: null, connections: [conn('legacy-analysis', 'deepseek', { api_key: 'k' })] }
const textOnly = { 'legacy-analysis': { models: [{ id: 'deepseek-flash', capability: 'text', analysis: true }], source: 'live' } }
const fallback = logic.coverFallback(state, textOnly)
assert.deepEqual([fallback.cover_enabled, fallback.cover], [false, null])
assert.equal(logic.saveIssue({ ...fallback, analysis: { connection_id: 'legacy-analysis', model: 'deepseek-flash', capability: 'auto' } }), null)
const withImages = { 'legacy-analysis': { models, source: 'live' } }
assert.equal(logic.coverFallback(state, withImages).cover_enabled, true, 'a missing choice with images available is still reported')
assert.equal(logic.coverFallback(state, {}).cover_enabled, true, 'no list yet: keep the choice')
const filled = defaults.applyModelDefaults(state, state.connections[0], models, false)
assert.equal(filled.cover.model, 'wanx2.1-t2i-turbo', 'cover switched on before the list arrived is filled without the first-run flag')
})
+6 -3
View File
@@ -24,10 +24,13 @@ test('appending the same source twice gives independent scene identities and enf
assert.ok(draftError(next,8));assert.equal(draftError(next,10),null)
})
test('portrait recommendation updates composition and title without modifying source or language',()=>{
const original={...draft,language:'en',aspect:'landscape',layout:'fit'}
test('portrait recommendation updates composition and captions without modifying source or language',()=>{
const original={...draft,language:'en',aspect:'landscape',layout:'fit',hook:''}
const next=m.exports.portraitDesign(original)
assert.equal(next.aspect,'portrait');assert.equal(next.layout,'crop');assert.equal(next.title_style,'comic');assert.equal(next.title_template_version,6)
assert.equal(next.aspect,'portrait');assert.equal(next.layout,'crop');assert.equal(next.subtitle_style,'bold')
assert.equal(next.title_style,original.title_style,'no opening title → its style is left alone')
assert.equal(next.crop_x,.5);assert.equal(next.language,'en');assert.equal(next.scenes,original.scenes)
assert.equal(original.layout,'fit')
const titled=m.exports.portraitDesign({...original,hook:'Watch this'})
assert.equal(titled.title_style,'comic');assert.equal(titled.title_template_version,6)
})
+1 -1
View File
@@ -25,4 +25,4 @@ Issues = "https://github.com/zhouxiaoka/autoclip/issues"
include = ["backend", "backend.*"]
[tool.setuptools.package-data]
backend = ["prompt/*.txt", "prompt/**/*.txt", "assets/fonts/*.ttf", "assets/fonts/*.txt", "assets/fonts/*.json"]
backend = ["prompt/*.txt", "prompt/**/*.txt", "assets/fonts/*.ttf", "assets/fonts/*.txt", "assets/fonts/*.json", "core/infistar_models.json"]
+2 -1
View File
@@ -169,7 +169,8 @@ stdlib = set(sys.stdlib_module_names)
# Modules that are installed AT RUNTIME by the user (Whisper feature), not
# bundled. They are imported lazily inside functions and must NOT fail the
# build. Keep this list tight.
runtime_optional = {"faster_whisper", "ctranslate2", "huggingface_hub"}
# cv2: speaker framing (backend/services/studio/framing.py), installed on demand like Whisper.
runtime_optional = {"faster_whisper", "ctranslate2", "huggingface_hub", "cv2"}
mods = set()
for root, _, files in os.walk(backend_dir):
if '__pycache__' in root: