diff --git a/backend/api/v1/studio.py b/backend/api/v1/studio.py index d6dbe590..a9520996 100644 --- a/backend/api/v1/studio.py +++ b/backend/api/v1/studio.py @@ -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)}"}) diff --git a/backend/services/ai_model_settings.py b/backend/services/ai_model_settings.py index 728f6edd..1af1e01f 100644 --- a/backend/services/ai_model_settings.py +++ b/backend/services/ai_model_settings.py @@ -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} diff --git a/backend/services/studio/jobs.py b/backend/services/studio/jobs.py index 12bc708a..5d223454 100644 --- a/backend/services/studio/jobs.py +++ b/backend/services/studio/jobs.py @@ -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): diff --git a/backend/services/studio/packaging.py b/backend/services/studio/packaging.py index ac273046..592ef939 100644 --- a/backend/services/studio/packaging.py +++ b/backend/services/studio/packaging.py @@ -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}) diff --git a/backend/services/studio/post_copy.py b/backend/services/studio/post_copy.py index 0f395741..25219fec 100644 --- a/backend/services/studio/post_copy.py +++ b/backend/services/studio/post_copy.py @@ -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__) diff --git a/backend/services/studio/publish_kit.py b/backend/services/studio/publish_kit.py index e6e4a16e..9307401c 100644 --- a/backend/services/studio/publish_kit.py +++ b/backend/services/studio/publish_kit.py @@ -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' diff --git a/backend/services/studio/render.py b/backend/services/studio/render.py index 0b32715c..0dffad29 100644 --- a/backend/services/studio/render.py +++ b/backend/services/studio/render.py @@ -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): diff --git a/backend/services/studio/store.py b/backend/services/studio/store.py index c701a5f7..09458bf1 100644 --- a/backend/services/studio/store.py +++ b/backend/services/studio/store.py @@ -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(): diff --git a/backend/services/upload_post_publisher.py b/backend/services/upload_post_publisher.py index 221ce547..c0561a52 100644 --- a/backend/services/upload_post_publisher.py +++ b/backend/services/upload_post_publisher.py @@ -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: diff --git a/backend/tests/test_ai_model_settings.py b/backend/tests/test_ai_model_settings.py index 9375a456..a6753384 100644 --- a/backend/tests/test_ai_model_settings.py +++ b/backend/tests/test_ai_model_settings.py @@ -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(): diff --git a/backend/tests/test_english_only.py b/backend/tests/test_english_only.py new file mode 100644 index 00000000..8dedbe8c --- /dev/null +++ b/backend/tests/test_english_only.py @@ -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' diff --git a/backend/tests/test_upload_post_publisher.py b/backend/tests/test_upload_post_publisher.py index bf67de56..cc8f5743 100644 --- a/backend/tests/test_upload_post_publisher.py +++ b/backend/tests/test_upload_post_publisher.py @@ -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 diff --git a/frontend/src/features/studio/OutputVariantCard.tsx b/frontend/src/features/studio/OutputVariantCard.tsx index 1de08452..fe981d28 100644 --- a/frontend/src/features/studio/OutputVariantCard.tsx +++ b/frontend/src/features/studio/OutputVariantCard.tsx @@ -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, 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('分享文案已复制,发布视频时粘贴即可')) diff --git a/frontend/src/features/studio/outputShare.ts b/frontend/src/features/studio/outputShare.ts index b8c5848a..7bbfa12e 100644 --- a/frontend/src/features/studio/outputShare.ts +++ b/frontend/src/features/studio/outputShare.ts @@ -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 } diff --git a/frontend/tests/output-share.test.cjs b/frontend/tests/output-share.test.cjs index 67e64106..4694951a 100644 --- a/frontend/tests/output-share.test.cjs +++ b/frontend/tests/output-share.test.cjs @@ -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 \}\)/) })