mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-10-02 02:34:34 +08:00
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:
@@ -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)}"})
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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'
|
||||
@@ -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('分享文案已复制,发布视频时粘贴即可'))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 \}\)/)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user