fix(studio): English platforms stay English on every path

Review found several ways Chinese text still reached TikTok/Reels/Shorts/YouTube:
- packaging kept Chinese titles, captions and nameplate roles from the model;
  Chinese captions are now retried, the rest dropped or filtered
- post copy accepted Chinese titles; they are retried, never posted
- landscape YouTube burned the untranslated source captions and Chinese hook;
  landscape versions now caption in their audience's language
- appended and on-demand versions skipped the English title and post copy
- publishing fell back to the Chinese clip title; it now asks for one
- the kit's file names and the share credit followed the app language
Also: a version interrupted mid-render becomes retryable after a restart,
and a DashScope relay address from 1.4 is no longer rewritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
周小舟
2026-10-01 18:51:41 +08:00
co-authored by Claude Opus 5.5
parent 3b8088c8a4
commit 6931bc1c93
15 changed files with 248 additions and 42 deletions
+2 -1
View File
@@ -342,7 +342,8 @@ def output_variant_kit(project_id: str, variant_id: str, db: Session = Depends(g
raise HTTPException(404, '成片文件已移除')
cover, _ = publish_kit.cover_file(project_id, job['job_id'], variant['strategy_id'])
post = variant.get('post') or {'title': (job.get('result') or {}).get('title', '')}
data, name = publish_kit.kit_zip(video, cover, post, platform_strategy(variant['strategy_id']).label)
strategy = platform_strategy(variant['strategy_id'])
data, name = publish_kit.kit_zip(video, cover, post, strategy.label, english=strategy.audience_language == 'en')
return Response(data, media_type='application/zip', headers={'Content-Disposition': f"attachment; filename*=UTF-8''{quote(name)}"})
+2 -1
View File
@@ -173,9 +173,10 @@ def chat_endpoint(connection: Connection, model: str) -> dict:
**{key: preset.base_url for key, preset in LOCAL_PRESETS.items()},
}
base = connection.base_url or defaults[connection.provider]
if connection.provider == 'dashscope' and connection.base_url:
if connection.provider == 'dashscope' and connection.base_url and (urlparse(base).hostname or '').endswith('.aliyuncs.com'):
# Dedicated Bailian endpoints are pasted as a host or its native /api/v1 path; text models
# use the OpenAI-compatible path on the same host (speech recognition uses /api/v1).
# Other hosts (a relay or proxy someone set up in 1.4) are used exactly as entered.
host = base.rstrip('/').removesuffix('/compatible-mode/v1').removesuffix('/api/v1')
base = host + '/compatible-mode/v1'
return {'base_url': base, 'api_key': connection.api_key or '', 'model': model}
+18 -10
View File
@@ -368,6 +368,10 @@ def _apply_strategy(draft, strategy_id, *, burned_subtitles=False, layout=None):
title_style=strategy.title_style,
title_motion=strategy.title_motion,
)
if strategy.template == 'landscape' and strategy_id != 'original' and value.get('language', 'source') == 'source':
# No template packaging here: the legacy captions and hook are translated at render time
# (skipped when already in this language), so English YouTube never burns Chinese text.
value['language'] = strategy.audience_language
if burned_subtitles:
value['subtitles'] = False
# The template version follows the new title style: a draft derived for one platform (comic, v6)
@@ -609,9 +613,14 @@ def _audience_title(value, strategy_id, post):
from backend.services.studio.packaging import CJK
if platform_strategy(strategy_id).audience_language != 'en' or not CJK.search(value.get('title') or ''):
return value
from backend.services.studio.packaging import foreign_for
lines = (value.get('packaging') or {}).get('title_lines') or []
english = ' '.join(lines) if lines and not any(CJK.search(line) for line in lines) else (post or {}).get('title', '')
return {**value, 'title': english[:120]} if english else value
english = ' '.join(lines) if lines and not any(foreign_for('en', line) for line in lines) else (post or {}).get('title', '')
if not english or foreign_for('en', english):
# No English title came back: an English caption line beats the Chinese clip title.
cues = (value.get('packaging') or {}).get('cues') or []
english = next((cue['text'] for cue in cues if cue.get('text') and not foreign_for('en', cue['text'])), '') or 'Untitled clip'
return {**value, 'title': english[:120]}
def _content_key(value):
@@ -903,9 +912,12 @@ def append_platform_variants(project_id, platforms, branding):
value, trimmed = _fit_platform_limit(project_id, {**base, 'id': uuid.uuid4().hex, 'revision': 1}, strategy)
now = signature in automatic
framed = None
post = None
if now:
value, framed = _apply_framing(project_id, value, strategy_id, video, burned, framing_cache)
value = _apply_packaging(project_id, value, strategy_id, burned, packaging_cache)
post = _posts_for(project_id, [(strategy_id, value)], packaging_cache).get((_content_key(value), strategy_id))
value = _audience_title(value, strategy_id, post)
draft = _apply_strategy(value, strategy_id, burned_subtitles=burned, layout=value.get('layout') if framed else None)
derived.append(draft.model_dump())
variants.append({
@@ -914,6 +926,7 @@ def append_platform_variants(project_id, platforms, branding):
'status': 'queued' if now else 'on_demand', 'created_at': store.now(),
**({'trimmed_to_sec': trimmed} if trimmed else {}),
**({'framing': framed} if framed else {}),
**({'post': post} if post else {}),
})
if not variants:
raise ValueError('没有可追加的平台版本;已存在或素材不满足所选平台要求')
@@ -1007,8 +1020,9 @@ def _produce_on_demand(project_id, variant_id):
strategy_id = variant['strategy_id']
value, framed = _apply_framing(project_id, raw, strategy_id, video, burned, {})
value = _apply_packaging(project_id, value, strategy_id, burned, {})
draft = _apply_strategy(value, strategy_id, burned_subtitles=burned, layout=value.get('layout') if framed else None).model_dump()
posts = _posts_for(project_id, [(strategy_id, value)], {})
value = _audience_title(value, strategy_id, posts.get((_content_key(value), strategy_id)))
draft = _apply_strategy(value, strategy_id, burned_subtitles=burned, layout=value.get('layout') if framed else None).model_dump()
def ready(data):
for index, item in enumerate(data['drafts']):
@@ -1084,13 +1098,7 @@ def _sync_variant_status(project_id, job_id, status, error=None):
def _finish_generation(data):
"""Settle the generation once every requested (not backup) variant is completed or failed."""
variants = [item for item in data['output_variants'] if item['status'] != 'on_demand']
if all(item['status'] in ('completed', 'failed') for item in variants):
completed = [item for item in variants if item['status'] == 'completed']
outcome = 'completed' if len(completed) == len(variants) else 'partial' if completed else 'failed'
data['generation'].update(status=outcome, completed_variant_count=len(completed), finished_at=store.now())
data['analysis'] = {'status': 'completed' if completed else 'failed', 'phase': 'rendering', 'run_id': (data.get('analysis') or {}).get('run_id'), 'outcome': outcome, 'created_at': store.now()}
store.settle_generation(data)
def inspect_project(project_id, options, url=None, browser=None):
+36 -16
View File
@@ -18,6 +18,16 @@ logger = logging.getLogger(__name__)
CJK = re.compile(r'[㐀-鿿]')
KANA = re.compile(r'[぀-ヿ]')
def foreign_for(audience: str, text: str) -> bool:
"""Text an English-platform viewer cannot read: any Chinese or Japanese characters."""
return audience == 'en' and bool(CJK.search(text or '') or KANA.search(text or ''))
def _items(value) -> list[dict]:
# Model lists sometimes hold bare strings or come back as one object: keep only the objects.
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
LATIN = re.compile(r'[A-Za-z]')
TITLE_LIMIT = {'zh': 12, 'en': 36}
TAG_LIMIT = 10
@@ -143,9 +153,7 @@ def build_packaging(draft: dict[str, Any], lines: list[dict[str, Any]], strategy
translate = captions and src != audience
base = {'template': template, 'audience_language': audience, 'source_language': src, 'burned_captions': burned}
# Never mix languages: a fallback shows only what is already in the audience's language.
fallback_title = _fallback_title(draft, audience)
if audience == 'en':
fallback_title = [line for line in fallback_title if not CJK.search(line) and not KANA.search(line)]
fallback_title = [line for line in _fallback_title(draft, audience) if not foreign_for(audience, line)]
fallback = {**base, 'title_lines': fallback_title, 'fallback': True,
'cues': [] if not captions or translate else [{'start': l['start'], 'end': l['end'], 'text': l['text'][:600], 'original': ''} for l in lines]}
if not lines:
@@ -203,8 +211,9 @@ def _segments(raw, lines, translate):
raise ValueError('segments missing')
cues, expected = [], 0
for item in raw:
start, end = (item or {}).get('from'), (item or {}).get('to')
text = _clean(str((item or {}).get('text') or ''))
item = item if isinstance(item, dict) else {}
start, end = item.get('from'), item.get('to')
text = _clean(str(item.get('text') or ''))
if not (isinstance(start, int) and isinstance(end, int)) or start != expected or end < start or end >= len(lines):
raise ValueError('segments are not contiguous')
if end - start >= MAX_SEGMENT_LINES:
@@ -224,9 +233,13 @@ def _validated(result, lines, base, translate, burned, known_names, draft, avoid
if not isinstance(result, dict):
raise TypeError('packaging response is not an object')
audience = base['audience_language']
titles = [_clean(t) for t in result.get('title_lines') or [] if isinstance(t, str) and _clean(t)][:2]
raw_titles = result.get('title_lines')
raw_titles = [raw_titles] if isinstance(raw_titles, str) else raw_titles if isinstance(raw_titles, list) else []
titles = [_clean(t) for t in raw_titles if isinstance(t, str) and _clean(t)][:2]
if audience == 'zh' and any(KANA.search(t) for t in titles):
titles = [] # a Japanese title on a Chinese platform: use the draft title instead
if any(foreign_for(audience, t) for t in titles):
titles = [] # a Chinese title on an English platform: never shown; the fallback below is filtered too
limit = TITLE_LIMIT[audience]
if titles and any(len(t) > limit + 2 for t in titles):
# Models often return one long line: keep their wording when it fits two lines, breaking
@@ -239,38 +252,45 @@ def _validated(result, lines, base, translate, burned, known_names, draft, avoid
rewrapped = lines_for(joined, chars if audience == 'zh' else chars * 0.55)
titles = rewrapped if 0 < len(rewrapped) <= 2 and all(len(t) <= cap for t in rewrapped) else []
if not titles:
titles = _fallback_title(draft, audience)
titles = [line for line in _fallback_title(draft, audience) if not foreign_for(audience, line)]
accent = result.get('accent_line') if result.get('accent_line') in (0, 1) else len(titles) - 1
cues = []
if captions if captions is not None else (not burned or translate):
if translate:
cues = _segments(result.get('segments'), lines, translate) # invalid translation: whole package falls back
if any(foreign_for(audience, cue['text']) for cue in cues):
raise ValueError('English captions contain Chinese or Japanese text')
else:
# Same language: the source rows are what is actually said and carry the tightest timing.
# Merged sentence segments spread word timing over 20 s+ and drift from the audio.
cues = [{'start': l['start'], 'end': l['end'], 'text': l['text'][:600], 'original': '', 'lines': (i, i)}
for i, l in enumerate(lines)]
if burned:
if burned or audience == 'en':
for cue in cues:
cue['original'] = '' # the picture already carries a caption; never stack a third line
# Burned: the picture already carries a caption, never stack a third line.
# English: its templates show English only, so the source text is not kept either.
cue['original'] = ''
haystack = (' '.join(line['text'] for line in lines) + ' ' + known_names + ' ' + draft.get('title', '')).lower()
speakers, seen = [], set()
for item in result.get('speakers') or []:
index, name = _line(item, lines), str((item or {}).get('name') or '').strip()
for item in _items(result.get('speakers')):
index, name = _line(item, lines), str(item.get('name') or '').strip()
if index is None or not name or len(name) > 40 or name.lower() in seen or not _names_allowed(name, haystack):
continue
if foreign_for(audience, name):
continue # a nameplate the audience cannot read is worse than none
role = str(item.get('role') or '').strip()[:60]
seen.add(name.lower())
speakers.append({'at': lines[index]['start'], 'name': name, 'role': str(item.get('role') or '').strip()[:60]})
speakers.append({'at': lines[index]['start'], 'name': name, 'role': '' if foreign_for(audience, role) else role})
tags = []
if base['template'] == 'interview_zh':
for item in (result.get('tags') or [])[:MAX_TAGS]:
index, text = _line(item, lines), str((item or {}).get('text') or '').strip()
for item in _items(result.get('tags'))[:MAX_TAGS]:
index, text = _line(item, lines), str(item.get('text') or '').strip()
if index is not None and 0 < len(text) <= TAG_LIMIT:
tags.append({'at': lines[index]['start'] + .2, 'text': text})
highlights = []
if base['template'] == 'podcast_en':
for item in result.get('highlights') or []:
index, word = _line(item, lines), str((item or {}).get('word') or '').strip()
for item in _items(result.get('highlights')):
index, word = _line(item, lines), str(item.get('word') or '').strip()
cue = next((c for c in cues if index is not None and c['lines'][0] <= index <= c['lines'][1]), None)
if cue and word and len(word) <= 30 and word.lower() in cue['text'].lower():
highlights.append({'at': cue['start'], 'text': word})
+17 -3
View File
@@ -74,10 +74,13 @@ def _tags(raw: Any, rules: PostRules) -> list[str]:
return tags[:rules.tags[1]]
FOREIGN_FOR_EN = re.compile(r'[\u3040-\u30ff\u3400-\u9fff]')
def fallback(title: str, platform: str) -> dict[str, Any]:
rules = RULES.get(platform, RULES['original'])
text = _clean(title)
if rules.language == 'en' and re.search(r'[\u3040-\u30ff\u4e00-\u9fff]', text):
if rules.language == 'en' and FOREIGN_FOR_EN.search(text):
text = '' # never a Chinese title on an English platform; the user fills it in
return {'title': _fit(text, rules.title_max), 'description': '', 'tags': []}
@@ -101,13 +104,24 @@ def build_posts(title: str, lines: list[str], platforms: list[str], *, source: s
raw = (result or {}).get('posts') if isinstance(result, dict) else None
if not isinstance(raw, dict):
raise ValueError('posts missing')
mixed = []
for platform in platforms:
item = raw.get(platform) if isinstance(raw.get(platform), dict) else {}
rules = RULES[platform]
post_title = _fit(_clean(item.get('title')), rules.title_max)
description = _fit(_clean(item.get('description')), rules.description_max)
tags = _tags(item.get('tags'), rules)
if rules.language == 'en':
# English platforms are English only: a Chinese title is retried, never posted.
if FOREIGN_FOR_EN.search(post_title):
mixed.append(platform)
continue
description = '' if FOREIGN_FOR_EN.search(description) else description
tags = [tag for tag in tags if not FOREIGN_FOR_EN.search(tag)]
if post_title:
posts[platform] = {'title': post_title, 'description': _fit(_clean(item.get('description')), rules.description_max),
'tags': _tags(item.get('tags'), rules)}
posts[platform] = {'title': post_title, 'description': description, 'tags': tags}
if mixed and attempt == 0:
raise ValueError(f'{"、".join(mixed)} 的标题必须是英文,不能有中文')
return posts
except Exception as error: # noqa: BLE001 - copy falls back to the clip title
logger.warning('Post copy rejected (attempt %d): %s', attempt + 1, type(error).__name__)
+8 -4
View File
@@ -244,13 +244,17 @@ def _safe_name(text: str) -> str:
return re.sub(r'[\\/:*?"<>|\n\r\t]+', ' ', text).strip()[:60] or 'autoclip'
def kit_zip(video: Path, cover: Path | None, post: dict[str, Any], platform_label: str) -> tuple[bytes, str]:
"""(zip bytes, file name): the video, its cover and the post copy for one platform."""
def kit_zip(video: Path, cover: Path | None, post: dict[str, Any], platform_label: str, *, english: bool = False) -> tuple[bytes, str]:
"""(zip bytes, file name): the video, its cover and the post copy for one platform.
An English platform's kit is English throughout, file names included.
"""
name = _safe_name(post.get('title') or video.stem)
cover_name, copy_name, platform = ('cover', 'post', 'Platform: ') if english else ('封面', '发布文案', '平台:')
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_STORED) as archive: # mp4/jpg are already compressed
archive.write(video, f'{name}.mp4')
if cover is not None:
archive.write(cover, f'{name} 封面.jpg')
archive.writestr(f'{name} 发布文案.txt', f'平台:{platform_label}\n\n{caption_text(post)}\n')
archive.write(cover, f'{name} {cover_name}.jpg')
archive.writestr(f'{name} {copy_name}.txt', f'{platform}{platform_label}\n\n{caption_text(post)}\n')
return buffer.getvalue(), f'{name}.zip'
+13 -1
View File
@@ -17,6 +17,18 @@ from backend.utils.ffmpeg_utils import get_ffmpeg_path
PORTRAIT_LINE = 15
def _needs_translation(language, hook, entries):
"""Skip the model call when the hook and captions are already in the target language."""
from backend.services.studio.packaging import CJK, foreign_for, source_language
if language not in ('zh', 'en'):
return True
if entries and source_language([e.get('text', '') for e in entries]) != language:
return True
if hook and (foreign_for('en', hook) if language == 'en' else not CJK.search(hook)):
return True
return False
def render_draft(project_id, video, draft: Draft, job_id, progress, *, brand_outro=False):
info = _probe(video)
validate_scenes(draft.scenes, info.get('duration', 0))
@@ -35,7 +47,7 @@ def render_draft(project_id, video, draft: Draft, job_id, progress, *, brand_out
from backend.pipeline.quality import to_seconds
entries = [e.copy() for e in entries if any(to_seconds(e['start_time']) < s.end and to_seconds(e['end_time']) > s.start for s in draft.scenes)]
hook = '' if packaged else draft.hook
if draft.language != 'source' and (hook or entries):
if draft.language != 'source' and (hook or entries) and _needs_translation(draft.language, hook, entries):
translated = text_json('将 title 和 subtitles 翻译成指定语言;保持 subtitles 的数量与顺序,不添加事实。返回 {"title":"...","subtitles":["..."]}。', {'language': draft.language, 'title': hook, 'subtitles': [e.get('text', '') for e in entries]})
rows = translated.get('subtitles', [])
if len(rows) != len(entries) or not all(isinstance(t, str) for t in rows) or not isinstance(translated.get('title'), str):
+20
View File
@@ -33,17 +33,37 @@ def read(project_id: str):
data.setdefault('events', [])
data.setdefault('jobs', [])
data.setdefault('output_variants', [])
interrupted = set()
for job in data['jobs']:
if job['status'] in ('queued', 'running') and job.get('instance') != INSTANCE:
job.update(status='failed', error='服务已重启,请重新导出')
interrupted.add(job.get('job_id'))
settle = False
for variant in data['output_variants']:
if variant.get('status') == 'preparing' and variant.get('instance') != INSTANCE:
variant.update(status='failed', error='服务已重启,请重试这条', needs_prepare=True)
settle = True
elif variant.get('status') in ('queued', 'running') and variant.get('render_job_id') in interrupted:
# The app closed mid-render: the version becomes retryable instead of queued forever.
variant.update(status='failed', error='服务已重启,请重试这条')
settle = True
if settle and data.get('generation'):
settle_generation(data)
analysis = data.get('analysis')
if analysis and analysis['status'] == 'running' and analysis.get('instance') != INSTANCE:
analysis.update(status='failed', error='服务已重启,请重试分析')
return data
def settle_generation(data):
"""Settle the generation once every requested (not backup) variant is completed or failed."""
variants = [item for item in data['output_variants'] if item['status'] != 'on_demand']
if all(item['status'] in ('completed', 'failed') for item in variants):
completed = [item for item in variants if item['status'] == 'completed']
outcome = 'completed' if len(completed) == len(variants) else 'partial' if completed else 'failed'
data['generation'].update(status=outcome, completed_variant_count=len(completed), finished_at=now())
data['analysis'] = {'status': 'completed' if completed else 'failed', 'phase': 'rendering', 'run_id': (data.get('analysis') or {}).get('run_id'), 'outcome': outcome, 'created_at': now()}
def write(project_id, data):
root = directory(project_id)
if not root.is_dir():
@@ -418,6 +418,12 @@ def publish_clip(req: PublishRequest, config: UploadPostConfig | None = None,
title = (req.title or post.get("title") or clip.get("generated_title") or clip.get("title") or clip.get("outline") or f"切片 {req.clip_id}").strip()
if not title:
title = f"切片 {req.clip_id}"
if clip.get("strategy_id"):
from backend.services.platform_strategy import platform_strategy
from backend.services.studio.packaging import foreign_for
if platform_strategy(clip["strategy_id"]).audience_language == "en" and foreign_for("en", title):
# Never post a Chinese title to an English platform; the publish page asks for one.
raise UploadPostError("这个平台的标题需要是英文,请在发布页填写英文标题后再发布")
video_path = Path(export["path"])
if not video_path.exists() or video_path.stat().st_size == 0:
+2
View File
@@ -383,6 +383,8 @@ def test_a_dedicated_bailian_endpoint_serves_text_models_on_its_compatible_path(
connection = ai.Connection(id='b', name='百炼', provider='dashscope', base_url=pasted)
assert ai.chat_endpoint(connection, 'qwen-plus')['base_url'] == host + '/compatible-mode/v1'
assert ai.chat_endpoint(ai.Connection(id='d', name='d', provider='dashscope'), 'm')['base_url'] == 'https://dashscope.aliyuncs.com/compatible-mode/v1'
relay = ai.Connection(id='r', name='relay', provider='dashscope', base_url='https://proxy.example.com/v1')
assert ai.chat_endpoint(relay, 'm')['base_url'] == 'https://proxy.example.com/v1', 'a 1.4 relay address is used as entered'
def test_fal_image_connections_and_unknown_image_apis_from_newer_versions():
+100
View File
@@ -0,0 +1,100 @@
"""English platforms are English only: no Chinese or Japanese text in titles, captions, nameplates,
post copy, kit files or the share credit, whatever the model returns (no model calls)."""
import io
import json
import zipfile
from backend.services.platform_strategy import platform_strategy
from backend.services.studio import jobs, packaging, post_copy, publish_kit, render, store
ZH_LINES = [
{'start': 0.0, 'end': 3.0, 'text': '我觉得好的投资人就像飞行教练'},
{'start': 3.0, 'end': 6.0, 'text': '他会让你自己去飞,但不会让你坠机'},
]
ZH_DRAFT = {'title': '好的投资人像飞行教练', 'hook': ''}
def _response(**overrides):
body = {'title_lines': ['Good investors', 'are flight instructors'], 'accent_line': 1,
'segments': [{'from': 0, 'to': 1, 'text': 'Good investors are like flight instructors: they let you fly, never crash.'}],
'speakers': [{'line': 0, 'name': 'Sam Altman', 'role': 'OpenAI CEO'}], 'tags': [], 'highlights': []}
return {**body, **overrides}
def _has_foreign(value):
return packaging.foreign_for('en', json.dumps(value, ensure_ascii=False))
def test_chinese_model_output_never_reaches_an_english_package():
calls = []
def chinese_titles_then_good(_prompt, payload):
calls.append(payload)
if len(calls) == 1: # captions still in Chinese: rejected and retried
return _response(segments=[{'from': 0, 'to': 1, 'text': '好的投资人像飞行教练'}])
return _response(title_lines=['好的投资人', '像飞行教练'], speakers=[{'line': 0, 'name': 'Sam Altman', 'role': 'OpenAI 首席执行官'}])
result = packaging.build_packaging(ZH_DRAFT, ZH_LINES, platform_strategy('tiktok'), known_names='Sam Altman', call=chinese_titles_then_good)
assert len(calls) == 2 and 'previous_error' in calls[1]
assert not _has_foreign({k: result[k] for k in ('title_lines', 'cues', 'speakers')}), result
assert result['speakers'] == [{'at': 0.0, 'name': 'Sam Altman', 'role': ''}]
def test_malformed_lists_do_not_break_packaging():
result = packaging.build_packaging({'title': 'x', 'hook': ''}, [{'start': 0, 'end': 2, 'text': 'flight instructors teach you to fly'} for _ in range(3)],
platform_strategy('tiktok'), call=lambda *_: _response(title_lines='Fly alone', speakers=['Sam'], highlights={'a': 1}))
assert result['title_lines'] == ['Fly alone'] and result['speakers'] == []
def test_post_copy_retries_a_chinese_title_for_an_english_platform_and_never_posts_one():
answers = iter([
{'posts': {'tiktok': {'title': '好的投资人', 'description': 'x', 'tags': []}, 'douyin': {'title': '好的投资人', 'description': '简介', 'tags': ['投资']}}},
{'posts': {'tiktok': {'title': 'Good investors teach you to fly', 'description': '中文简介', 'tags': ['investing', '投资']},
'douyin': {'title': '好的投资人', 'description': '简介', 'tags': ['投资']}}},
])
posts = post_copy.build_posts('好的投资人', ['line'], ['tiktok', 'douyin'], call=lambda *_: next(answers))
assert posts['tiktok'] == {'title': 'Good investors teach you to fly', 'description': '', 'tags': ['investing']}
assert posts['douyin']['title'] == '好的投资人'
stubborn = post_copy.build_posts('好的投资人', ['line'], ['tiktok'], call=lambda *_: {'posts': {'tiktok': {'title': '中文', 'description': '', 'tags': []}}})
assert stubborn['tiktok']['title'] == ''
def test_an_english_version_without_an_english_title_uses_an_english_caption():
value = {'title': '中文标题', 'packaging': {'title_lines': [], 'cues': [{'text': '我', 'start': 0, 'end': 1}, {'text': 'Fly alone first', 'start': 1, 'end': 2}]}}
assert jobs._audience_title(value, 'tiktok', {'title': '中文'})['title'] == 'Fly alone first'
assert jobs._audience_title({'title': '中文标题', 'packaging': None}, 'youtube_long', None)['title'] == 'Untitled clip'
def test_landscape_versions_caption_in_their_audiences_language():
draft = {'id': 'd', 'title': 't', 'scenes': [{'id': 's', 'label': 'x', 'start': 0.0, 'end': 90.0}], 'language': 'source'}
assert jobs._apply_strategy(draft, 'youtube_long').language == 'en'
assert jobs._apply_strategy(draft, 'bilibili').language == 'zh'
assert jobs._apply_strategy(draft, 'original').language == 'source'
english = [{'text': 'a good investor is like a flight instructor who lets you fly'}]
assert not render._needs_translation('en', 'Fly alone', english)
assert render._needs_translation('en', '好的投资人', english), 'a Chinese hook on English YouTube is translated'
assert render._needs_translation('zh', '', english)
assert not render._needs_translation('zh', '好的投资人', [{'text': '我觉得好的投资人就像飞行教练,他会让你自己去飞'}])
def test_an_english_kit_is_english_down_to_the_file_names(tmp_path):
video, cover = tmp_path / 'v.mp4', tmp_path / 'c.jpg'
video.write_bytes(b'v')
cover.write_bytes(b'c')
data, name = publish_kit.kit_zip(video, cover, {'title': 'Fly alone', 'description': '', 'tags': []}, 'TikTok', english=True)
names = zipfile.ZipFile(io.BytesIO(data)).namelist()
assert not any(packaging.foreign_for('en', n) for n in names + [name]), names
text = zipfile.ZipFile(io.BytesIO(data)).read('Fly alone post.txt').decode()
assert not packaging.foreign_for('en', text)
def test_a_version_interrupted_mid_render_becomes_retryable_after_a_restart(tmp_path, monkeypatch):
monkeypatch.setattr(store, 'get_projects_directory', lambda: tmp_path)
meta = tmp_path / 'p1' / 'metadata'
meta.mkdir(parents=True)
(meta / 'studio.json').write_text(json.dumps({
'generation': {'status': 'rendering'}, 'analysis': {'status': 'running', 'instance': 'old'},
'jobs': [{'job_id': 'j1', 'status': 'running', 'instance': 'old'}],
'output_variants': [{'id': 'v1', 'status': 'queued', 'render_job_id': 'j1'}, {'id': 'v2', 'status': 'on_demand'}]}))
data = store.read('p1')
assert data['output_variants'][0]['status'] == 'failed'
assert data['generation']['status'] == 'failed', 'the results page does not say it is still rendering'
+16 -2
View File
@@ -539,8 +539,8 @@ def test_api_router_is_mounted():
# --------------------------------------------------------- output variants ---
def _variant_meta(path, strategy_id):
return {"id": "studio-" + "a" * 32, "title": "自动版本", "generated_title": "自动版本", "source_type": "studio",
def _variant_meta(path, strategy_id, title="Auto version"):
return {"id": "studio-" + "a" * 32, "title": title, "generated_title": title, "source_type": "studio",
"studio_job_id": "a" * 32, "revision": 1, "video_path": str(path), "warnings": [],
"output_variant_id": "v1", "strategy_id": strategy_id, "branding": {"outro_enabled": True}}
@@ -564,6 +564,20 @@ def test_publish_variant_uses_completed_file_without_reexport(data_dir, monkeypa
assert record["output_variant_id"] == "v1" and record["strategy_id"] == "tiktok"
def test_an_english_platform_version_is_never_posted_with_a_chinese_title(data_dir, monkeypatch):
from backend.services import upload_post_publisher as up
video = _fake_clip(data_dir)
monkeypatch.setattr("backend.services.studio.publishing.output_variant_meta",
lambda _p, _v: _variant_meta(video, "tiktok", title="自动版本"))
session = _Session([])
cfg = up.UploadPostConfig(api_key="k-1234567890", user="me", base_url="https://api.example.test")
with pytest.raises(up.UploadPostError, match="英文"):
up.publish_clip(up.PublishRequest("p1", "studio-" + "a" * 32, ["tiktok"], output_variant_id="v1"),
config=cfg, session=session)
assert not session.calls
def test_publish_landscape_variant_refuses_vertical_only_targets(data_dir, monkeypatch):
from backend.services import upload_post_publisher as up
@@ -15,6 +15,8 @@ import { packagingLabel } from './packagingLabel'
import PublishKit, { postCaption } from './PublishKit'
import './quick-output.css'
const ENGLISH_PLATFORMS = new Set(['tiktok', 'instagram_reels', 'youtube_shorts', 'youtube_long'])
const framingHints: Record<NonNullable<OutputVariant['framing']>, string> = {
speaker: '已按说话人重新取景',
full_frame: '画面里没有可跟随的人物,保留完整画面',
@@ -36,7 +38,8 @@ export default function OutputVariantCard({ projectId, variant, draft, job, onRe
const onDemand = variant.status === 'on_demand'
const duration = job?.result?.duration ?? (draft ? draft.scenes.reduce((sum, scene) => sum + scene.end - scene.start, 0) : 0)
const copyCaption = async () => {
const copied = await copyText(variant.post ? `${postCaption(variant.post)}\n\n${shareCaption('')}`.trim() : shareCaption(draft?.title))
const english = draft?.packaging?.audience_language === 'en' || ENGLISH_PLATFORMS.has(variant.strategy_id)
const copied = await copyText(variant.post ? `${postCaption(variant.post)}\n\n${shareCaption('', english)}`.trim() : shareCaption(draft?.title, english))
if (copied) {
trackOutputShare(projectId, { share_target: 'copy_caption', ...analytics })
message.success(t('分享文案已复制,发布视频时粘贴即可'))
+3 -2
View File
@@ -6,8 +6,9 @@ const RATING_KEY = 'autoclip.output-rating.v1'
const RATING_INTERVAL_MS = 7 * 86400000
/** Caption the user pastes next to the posted video. Nothing is uploaded by AutoClip. */
export function shareCaption(title: string | undefined): string {
const credit = `${t('用 AutoClip 剪的')} · ${REPO_URL}`
export function shareCaption(title: string | undefined, english = false): string {
// The credit is pasted with the post, so it follows the platform's language, not the app's.
const credit = `${english ? 'Made with AutoClip' : t('用 AutoClip 剪的')} · ${REPO_URL}`
return title?.trim() ? `${title.trim()}\n\n${credit}` : credit
}
+1 -1
View File
@@ -13,7 +13,7 @@ test('share caption credits AutoClip with the repository link and is copied loca
assert.match(share,/REPO_URL = 'https:\/\/github\.com\/zhouxiaoka\/autoclip'/)
assert.match(share,/t\('用 AutoClip 剪的'\)\} · \$\{REPO_URL\}/)
assert.match(share,/navigator\.clipboard\.writeText/)
assert.match(card,/copyText\(variant\.post \? `\$\{postCaption\(variant\.post\)\}\\n\\n\$\{shareCaption\(''\)\}`\.trim\(\) : shareCaption\(draft\?\.title\)\)/)
assert.match(card,/copyText\(variant\.post \? `\$\{postCaption\(variant\.post\)\}\\n\\n\$\{shareCaption\('', english\)\}`\.trim\(\) : shareCaption\(draft\?\.title, english\)\)/)
assert.match(card,/trackOutputShare\(projectId, \{ share_target: 'copy_caption', \.\.\.analytics \}\)/)
})