mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-10-02 02:34:34 +08:00
feat(analytics): observe automatic generation and template packaging
Automatic output has no confirmation step and no client export, so the screening and production watches never settled and the only terminal event depended on the results page staying open. Screening now ends as auto_started once production begins, and a persisted studio-generation watch reports studio_generation_finished once with aggregate counts (variants, templates, speaker framing, packaging fallbacks, trims, burned captions) and backend-timed duration. The backend keeps the analysis run_id across phases, records generation.finished_at and marks automatic framing as framing_source=auto. Draft save/export carry template, style, tags switch and fallback; variant downloads, shares and ratings carry the platform, template, style and framing plus flow context. All new fields are allowlisted enums, counts or booleans; no titles, captions, names or tags leave the app. Also fix two issues found while rendering demos: deriving a draft for another platform now resets the title template version (Xiaohongshu after Douyin failed validation), and English caption segments and long model titles no longer force a packaging fallback. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -252,10 +252,19 @@ def _apply_strategy(draft, strategy_id, *, burned_subtitles=False, layout=None):
|
||||
)
|
||||
if burned_subtitles:
|
||||
value['subtitles'] = False
|
||||
# The template version follows the new title style: a draft derived for one platform (comic, v6)
|
||||
# must not carry a version the next platform's style rejects (card only supports v1).
|
||||
version = value.get('title_template_version', 1)
|
||||
if value['title_style'] == 'editorial':
|
||||
value['title_template_version'] = max(2, value.get('title_template_version', 1))
|
||||
value['title_template_version'] = max(2, version if version in (2, 3, 6) else 2)
|
||||
elif value['title_style'] == 'comic':
|
||||
value['title_template_version'] = 6
|
||||
elif value['title_style'] in ('plain', 'impact', 'card'):
|
||||
value['title_template_version'] = 1
|
||||
elif value['title_style'] in ('pixel', 'frosted') and version not in (3, 6):
|
||||
value['title_template_version'] = 6
|
||||
elif version in (4, 5):
|
||||
value['title_template_version'] = 6
|
||||
return Draft.model_validate(value)
|
||||
|
||||
|
||||
@@ -349,7 +358,8 @@ def _speaker_framing(value, video, *, window=None, wait_sec=120):
|
||||
tracks = {scene['id']: scene for scene in result['scenes']}
|
||||
if not any(scene.get('faces') for scene in result['scenes']):
|
||||
return None, 'full_frame'
|
||||
scenes = [{**scene, 'crop_x': tracks[scene['id']]['crop_x'], 'crop_track': tracks[scene['id']]['crop_track']} for scene in value['scenes']]
|
||||
scenes = [{**scene, 'crop_x': tracks[scene['id']]['crop_x'], 'crop_track': tracks[scene['id']]['crop_track'],
|
||||
'framing_source': 'auto', 'framing_adjusted': False} for scene in value['scenes']]
|
||||
return scenes, 'speaker'
|
||||
|
||||
|
||||
@@ -370,7 +380,7 @@ def _apply_framing(project_id, value, strategy_id, video, burned, cache):
|
||||
interview = strategy.template == 'interview_zh'
|
||||
fallback_layout = 'window' if interview else 'blur'
|
||||
if burned:
|
||||
plain = [{**scene, 'crop_x': None, 'crop_track': None} for scene in value['scenes']]
|
||||
plain = [{**scene, 'crop_x': None, 'crop_track': None, 'framing_source': None, 'framing_adjusted': False} for scene in value['scenes']]
|
||||
return {**value, 'scenes': plain, 'layout': fallback_layout}, 'full_frame_captions'
|
||||
window = INTERVIEW_WINDOW if interview else None
|
||||
key = (tuple((scene['start'], scene['end']) for scene in value['scenes']), window)
|
||||
@@ -384,7 +394,7 @@ def _apply_framing(project_id, value, strategy_id, video, burned, cache):
|
||||
scenes, framing = cache[key]
|
||||
if scenes is None:
|
||||
# Drop tracks inherited from another platform's framing: they were computed for a different window.
|
||||
plain = [{**scene, 'crop_x': None, 'crop_track': None} for scene in value['scenes']]
|
||||
plain = [{**scene, 'crop_x': None, 'crop_track': None, 'framing_source': None, 'framing_adjusted': False} for scene in value['scenes']]
|
||||
return {**value, 'scenes': plain, 'layout': fallback_layout}, framing
|
||||
return {**value, 'scenes': scenes, 'layout': 'window' if interview else 'crop'}, framing
|
||||
|
||||
@@ -493,15 +503,15 @@ def _auto_generate(project_id, plan):
|
||||
data['events'] = events
|
||||
data['output_variants'].extend(variants)
|
||||
data['generation'].update(status='rendering', skipped=skipped, started_at=data['generation'].get('started_at') or store.now())
|
||||
data['analysis'] = {'status': 'running', 'phase': 'rendering', 'message': '正在生成可发布成片', 'instance': store.INSTANCE, 'created_at': store.now()}
|
||||
data['analysis'] = {'status': 'running', 'phase': 'rendering', 'run_id': (data.get('analysis') or {}).get('run_id'), 'message': '正在生成可发布成片', 'instance': store.INSTANCE, 'created_at': store.now()}
|
||||
store.change(project_id, persist)
|
||||
_dispatch_pending_variants(project_id)
|
||||
except Exception as error:
|
||||
capture_studio_exception(error, 'production')
|
||||
def fail(data):
|
||||
if data.get('generation'):
|
||||
data['generation'].update(status='failed', error=str(error)[:700], skipped=skipped)
|
||||
data['analysis'] = {'status': 'failed', 'phase': 'production', 'error': str(error)[:700], 'duration_ms': round((monotonic() - started) * 1000)}
|
||||
data['generation'].update(status='failed', error=str(error)[:700], skipped=skipped, finished_at=store.now())
|
||||
data['analysis'] = {'status': 'failed', 'phase': 'production', 'run_id': (data.get('analysis') or {}).get('run_id'), 'error': str(error)[:700], 'duration_ms': round((monotonic() - started) * 1000)}
|
||||
store.change(project_id, fail)
|
||||
mark_project(project_id, 'failed')
|
||||
|
||||
@@ -615,8 +625,8 @@ def _sync_variant_status(project_id, job_id, status, error=None):
|
||||
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))
|
||||
data['analysis'] = {'status': 'completed' if completed else 'failed', 'phase': 'rendering', 'outcome': outcome, 'created_at': store.now()}
|
||||
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.change(project_id, update)
|
||||
|
||||
|
||||
@@ -669,7 +679,8 @@ def _inspect(project_id, options, url, browser):
|
||||
plan['id'] = uuid.uuid4().hex
|
||||
if options.auto_start:
|
||||
def start_automatic(data):
|
||||
data.update(plan=plan, analysis={'status': 'running', 'phase': 'production', 'message': '正在制作可发布成片', 'instance': store.INSTANCE, 'created_at': store.now()})
|
||||
run_id = (data.get('analysis') or {}).get('run_id') # observers match the run across phases
|
||||
data.update(plan=plan, analysis={'status': 'running', 'phase': 'production', 'run_id': run_id, 'message': '正在制作可发布成片', 'instance': store.INSTANCE, 'created_at': store.now()})
|
||||
data['generation'].update(status='production', started_at=store.now())
|
||||
store.change(project_id, start_automatic)
|
||||
mark_project(project_id, 'processing', creative=plan['preferences'], awaiting_confirmation=False, import_staging=False)
|
||||
@@ -679,7 +690,7 @@ def _inspect(project_id, options, url, browser):
|
||||
capture_studio_exception(error, 'dispatch')
|
||||
message = '自动制作任务未能启动,请重试;原素材已保留'
|
||||
def fail_automatic(data):
|
||||
data['generation'].update(status='failed', error=message)
|
||||
data['generation'].update(status='failed', error=message, finished_at=store.now())
|
||||
data['analysis'] = {'status': 'failed', 'phase': 'production', 'error': message}
|
||||
store.change(project_id, fail_automatic)
|
||||
mark_project(project_id, 'failed')
|
||||
|
||||
@@ -46,8 +46,8 @@ class PackagingCue(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
|
||||
start: float = Field(ge=0)
|
||||
end: float = Field(gt=0)
|
||||
text: str = Field(min_length=1, max_length=200)
|
||||
original: str = Field(default='', max_length=300)
|
||||
text: str = Field(min_length=1, max_length=600)
|
||||
original: str = Field(default='', max_length=900)
|
||||
|
||||
|
||||
class PackagingSpeaker(BaseModel):
|
||||
|
||||
@@ -85,7 +85,7 @@ def build_packaging(draft: dict[str, Any], lines: list[dict[str, Any]], strategy
|
||||
translate = src != audience and not burned
|
||||
base = {'template': template, 'audience_language': audience, 'source_language': src, 'burned_captions': burned}
|
||||
fallback = {**base, 'title_lines': _fallback_title(draft, audience), 'fallback': True,
|
||||
'cues': [] if burned else [{'start': l['start'], 'end': l['end'], 'text': l['text'][:200], 'original': ''} for l in lines]}
|
||||
'cues': [] if burned else [{'start': l['start'], 'end': l['end'], 'text': l['text'][:600], 'original': ''} for l in lines]}
|
||||
if not lines:
|
||||
return Packaging.model_validate(fallback).model_dump()
|
||||
if call is None:
|
||||
@@ -120,11 +120,11 @@ def _segments(raw, lines, translate):
|
||||
text = str((item or {}).get('text') or '').strip()
|
||||
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 not text or len(text) > 200:
|
||||
if not text or len(text) > 600:
|
||||
raise ValueError('segment text invalid')
|
||||
original = ' '.join(line['text'] for line in lines[start:end + 1])
|
||||
cues.append({'start': lines[start]['start'], 'end': lines[end]['end'], 'text': text,
|
||||
'original': original[:300] if translate else '', 'lines': (start, end)})
|
||||
'original': original[:900] if translate else '', 'lines': (start, end)})
|
||||
expected = end + 1
|
||||
if expected != len(lines):
|
||||
raise ValueError('segments do not cover every line')
|
||||
@@ -136,7 +136,15 @@ def _validated(result, lines, base, translate, burned, known_names, draft):
|
||||
raise TypeError('packaging response is not an object')
|
||||
audience = base['audience_language']
|
||||
titles = [t.strip() for t in result.get('title_lines') or [] if isinstance(t, str) and t.strip()][:2]
|
||||
if not titles or any(len(t) > TITLE_LIMIT[audience] + 2 for t in titles):
|
||||
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.
|
||||
import textwrap
|
||||
joined = ' '.join(titles)
|
||||
width = min(40, max(limit, len(joined) // 2 + 2)) # balanced two lines, within the model's 40-char cap
|
||||
rewrapped = textwrap.wrap(joined, width=width, break_long_words=audience == 'zh')
|
||||
titles = rewrapped if 0 < len(rewrapped) <= 2 else []
|
||||
if not titles:
|
||||
titles = _fallback_title(draft, audience)
|
||||
accent = result.get('accent_line') if result.get('accent_line') in (0, 1) else len(titles) - 1
|
||||
cues = []
|
||||
|
||||
@@ -37,6 +37,13 @@ def test_faces_give_speaker_crop_and_one_detection_per_window_shape(monkeypatch)
|
||||
assert calls == [jobs.INTERVIEW_WINDOW, None] # 4:3 interview window once, 9:16 once
|
||||
|
||||
|
||||
@pytest.mark.parametrize('strategy_id', ['douyin', 'tiktok', 'instagram_reels', 'youtube_shorts', 'youtube_long', 'bilibili', 'xiaohongshu', 'original'])
|
||||
def test_redirecting_a_derived_draft_to_any_platform_keeps_a_valid_title_version(strategy_id):
|
||||
derived = {**_value(), 'title_style': 'comic', 'title_template_version': 6} # e.g. an earlier Douyin variant
|
||||
draft = jobs._apply_strategy(derived, strategy_id)
|
||||
assert draft.title_style and draft.title_template_version in (1, 2, 3, 4, 5, 6)
|
||||
|
||||
|
||||
def test_one_packaging_call_per_content_and_template(monkeypatch, tmp_path):
|
||||
from backend.services import publish_export
|
||||
from backend.services.studio import packaging, store
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_names_not_seen_in_subtitles_or_listing_are_dropped():
|
||||
@pytest.mark.parametrize('bad', [
|
||||
{'segments': [{'from': 0, 'to': 0, 'text': 'a'}]}, # does not cover every line
|
||||
{'segments': [{'from': 0, 'to': 0, 'text': 'a'}, {'from': 2, 'to': 2, 'text': 'c'}]}, # gap
|
||||
{'segments': [{'from': 0, 'to': 2, 'text': 'x' * 300}]}, # too long
|
||||
{'segments': [{'from': 0, 'to': 2, 'text': 'x' * 700}]}, # too long
|
||||
{'segments': None},
|
||||
])
|
||||
def test_malformed_segments_fall_back_to_source_captions(bad):
|
||||
@@ -81,6 +81,16 @@ def test_prompt_rejects_generic_praise_tags():
|
||||
assert '禁止' in packaging.PROMPT and '逻辑清晰' in packaging.PROMPT
|
||||
|
||||
|
||||
def test_long_english_sentences_and_titles_survive_validation():
|
||||
long_text = ' '.join(['token value asymmetry will redefine the economics of every model we ship'] * 5) # ~370 chars
|
||||
response = good_response(title_lines=["API pricing won't die—token value asymmetry will redefine AGI economics"],
|
||||
segments=[{'from': 0, 'to': 2, 'text': long_text}])
|
||||
result = packaging.build_packaging(DRAFT, LINES, platform_strategy('tiktok'), call=lambda *_: response)
|
||||
assert result['fallback'] is False and result['cues'][0]['text'] == long_text
|
||||
assert len(result['title_lines']) == 2 and all(len(line) <= 40 for line in result['title_lines'])
|
||||
assert ' '.join(result['title_lines']).startswith("API pricing won't die")
|
||||
|
||||
|
||||
def test_source_language_detection():
|
||||
assert packaging.source_language(['这是一个中文字幕,内容比较长一些']) == 'zh'
|
||||
assert packaging.source_language(['This is an English subtitle line with words']) == 'en'
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 快速出片与模板包装:埋点补充说明
|
||||
|
||||
2026-09-30。分支 `worktree-project-diagnosis`,已合并 main `8968916b`(含 [新用户与 Studio 数据闭环](NEW_USER_STUDIO_V2.md))。本文只说明代码,不代表已发布或线上看板已更新。
|
||||
|
||||
## 为什么要补
|
||||
|
||||
快速出片成为新的默认流程后:导入 → 自动理解 → 按平台自动派生版本 → 后端自动渲染,**没有“确认方案”这一步,也没有前端发起的导出**。沿用 main 的观察逻辑会出现:
|
||||
|
||||
| 旧观察 | 在自动出片下的表现 |
|
||||
|---|---|
|
||||
| `studio_screen_finished` | 只在 `awaiting_confirmation` 结束,自动出片永远不报;回到页面时若已渲染失败,会被误记为初筛失败 |
|
||||
| `studio_production_finished` | 挂在确认方案上,自动出片不会登记 |
|
||||
| `studio_export_*` | 版本由后端提交,前端没有导出请求 |
|
||||
| `studio_auto_generation_finished`(上一版) | 仅在结果页开着且到达终态时上报;中途离开就没有终态 |
|
||||
|
||||
另外,新能力(平台模板、包装样式、说话人取景、包装降级、原片字幕、Shorts 截断)完全没有数据。
|
||||
|
||||
## 改动
|
||||
|
||||
| 事件 / 字段 | 语义 |
|
||||
|---|---|
|
||||
| `studio_screen_finished` + `outcome=auto_started` | 自动出片进入制作或渲染阶段即算初筛结束,不论之后成败;先于“失败”判断,避免误记 |
|
||||
| `studio_generation_finished`(新,替代 `studio_auto_generation_finished`) | 导入时登记 `studio-generation` 观察,随 main 的观察持久化,生成到 `completed/partial/failed` 报一次;下次打开项目可补报 |
|
||||
| 生成汇总计数 | `variant_count`、`completed_variant_count`、`failed_variant_count`、`skipped_variant_count`、`platform_count`、`interview_count`、`podcast_count`、`landscape_count`、`speaker_framed_count`、`packaging_fallback_count`、`trimmed_count`,布尔 `burned_captions`;`duration_ms` 由后端 `generation.created_at` → `finished_at` 计算,不用观察时刻 |
|
||||
| 草稿保存/复制/改写/导出 | 增加 `template`、`packaging_style`、`tags_enabled`、`packaging_fallback`;`layout` 枚举增加 `window`;`auto_frame_retained` 同时认 `crop` 与 `window` |
|
||||
| 成片下载(requested/saved/failed) | 成片卡下载带 `strategy_id`、`template`、`packaging_style`、`framing` |
|
||||
| `studio_output_shared` / `studio_output_rated` | 带 main 的 `flow_id`、`material_origin` 上下文与上述版本枚举 |
|
||||
|
||||
后端配合:自动出片在制作/渲染/结束各阶段保留 `analysis.run_id`(main 的观察按 run 匹配);生成终态写入 `generation.finished_at`;自动取景结果写入 `framing_source=auto`,使 main 的“自动取景被保留”指标覆盖自动出片。
|
||||
|
||||
## 隐私边界
|
||||
|
||||
新增字段全部是枚举、计数或布尔,经 `safeStudioProperties` 白名单:
|
||||
|
||||
- `template`: interview_zh / podcast_en / landscape / none
|
||||
- `packaging_style`: classic / boxed / spotlight / pop / cinematic
|
||||
- `framing`: speaker / full_frame / full_frame_pending / full_frame_captions
|
||||
- `outcome` 新增 auto_started
|
||||
|
||||
不上传标题、字幕、译文、名牌人名、评论标签文字、高亮词、原视频标题或频道名。`frontend/tests/analytics-auto-generation.test.cjs` 覆盖:汇总计数、白名单丢弃标题与人名、自动出片初筛结局、生成终态只报一次。
|
||||
|
||||
## 负责人可以回答的问题
|
||||
|
||||
- 自动出片的完成、部分成功、失败比例,以及各平台模板占比。
|
||||
- 说话人取景覆盖率(`speaker_framed_count / variant_count`)与包装降级率(`packaging_fallback_count`)。降级高说明文字模型不可用或返回不合格,需要看 Sentry。
|
||||
- 哪些模板/样式的成片被下载、分享、评为“能直接用”。评分是低频抽样,只能看相对差异。
|
||||
- 用户在 Studio 里切换样式、关闭评论标签的比例(草稿保存上的 `packaging_style`、`tags_enabled`)。
|
||||
|
||||
## 仍需上线后验证
|
||||
|
||||
新事件与字段需在 validation 环境实际收数,再在 PostHog 看板增加“快速出片”分组(`studio_generation_finished` 按 `outcome`,下载按 `template`/`packaging_style`)。schema 1、上一版 `studio_auto_generation_finished` 与本版不可跨版本合并比较。
|
||||
@@ -27,33 +27,32 @@ export async function observeStudioOperation<T>(
|
||||
}
|
||||
}
|
||||
|
||||
export function trackQuickOutput(properties: Record<string, unknown>) {
|
||||
captureBusinessEvent('studio_auto_generation_finished', safeStudioProperties(properties))
|
||||
}
|
||||
|
||||
export function trackQuickOutputPlatforms(properties: Record<string, unknown>) {
|
||||
captureBusinessEvent('studio_platforms_selected', safeStudioProperties(properties))
|
||||
}
|
||||
|
||||
/** Enum/boolean summary of one output variant for delivery events; never titles, captions or names. */
|
||||
export type VariantProperties = { strategy_id?: string; template?: string; packaging_style?: string; framing?: string }
|
||||
|
||||
/** Share intent only: which enum target, never the caption, title or link text. */
|
||||
export function trackOutputShare(properties: { share_target: 'copy_caption' | 'use_case_discussion'; strategy_id?: string }) {
|
||||
captureBusinessEvent('studio_output_shared', safeStudioProperties(properties))
|
||||
export function trackOutputShare(projectId: string, properties: { share_target: 'copy_caption' | 'use_case_discussion' } & VariantProperties) {
|
||||
captureBusinessEvent('studio_output_shared', safeStudioProperties({ ...workflow.context(projectId), ...properties }))
|
||||
}
|
||||
|
||||
/** Anonymous three-level rating; free text is never collected. */
|
||||
export function trackOutputRating(properties: { output_rating: 'ready' | 'needs_edit' | 'unusable'; strategy_id?: string }) {
|
||||
captureBusinessEvent('studio_output_rated', safeStudioProperties(properties))
|
||||
export function trackOutputRating(projectId: string, properties: { output_rating: 'ready' | 'needs_edit' | 'unusable' } & VariantProperties) {
|
||||
captureBusinessEvent('studio_output_rated', safeStudioProperties({ ...workflow.context(projectId), ...properties }))
|
||||
}
|
||||
|
||||
/** Navigation intent only; no claim about successful disk writes. */
|
||||
export function studioDownloadRequested(projectId?: string, jobId?: string) {
|
||||
captureBusinessEvent('studio_download_requested', safeStudioProperties({ ...workflow.context(projectId, jobId), download_mode: 'browser' }))
|
||||
export function studioDownloadRequested(projectId?: string, jobId?: string, variant: VariantProperties = {}) {
|
||||
captureBusinessEvent('studio_download_requested', safeStudioProperties({ ...workflow.context(projectId, jobId), ...variant, download_mode: 'browser' }))
|
||||
}
|
||||
|
||||
export async function observeStudioDownload<T>(action: () => Promise<T>, projectId?: string, jobId?: string): Promise<T> {
|
||||
export async function observeStudioDownload<T>(action: () => Promise<T>, projectId?: string, jobId?: string, variant: VariantProperties = {}): Promise<T> {
|
||||
const generation = workflow.generation(), started = Date.now()
|
||||
const enabled = workflow.active(generation)
|
||||
const props = safeStudioProperties({ ...workflow.context(projectId, jobId), operation_id: telemetryId(), download_mode: 'native' })
|
||||
const props = safeStudioProperties({ ...workflow.context(projectId, jobId), ...variant, operation_id: telemetryId(), download_mode: 'native' })
|
||||
const emit = (name: string, result: Properties = {}) => {
|
||||
if (enabled && workflow.active(generation)) captureBusinessEvent(name, { ...props, ...result })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Versioned business telemetry. Only explicit UI operations enroll projects. */
|
||||
export type Properties = Record<string, string | number | boolean | null | undefined>
|
||||
export type Watch = { kind: 'project' | 'export' | 'bilibili' | 'youtube' | 'studio-screen' | 'studio-production' | 'studio-export' | 'framing-runtime'; id: string; projectId?: string; since: number; seen: string[]; settled?: boolean; properties?: Properties; token?: string; runId?: string }
|
||||
export type Watch = { kind: 'project' | 'export' | 'bilibili' | 'youtube' | 'studio-screen' | 'studio-production' | 'studio-export' | 'studio-generation' | 'framing-runtime'; id: string; projectId?: string; since: number; seen: string[]; settled?: boolean; properties?: Properties; token?: string; runId?: string }
|
||||
export const WORKFLOW_KEY = 'autoclip.analytics.workflow.v2'
|
||||
const TTL = 7 * 86400000
|
||||
const LIMIT = 50
|
||||
@@ -29,19 +29,22 @@ export function safeStudioProperties(value: Record<string, unknown> | null = {})
|
||||
aspect: ['original', 'portrait', 'landscape', 'auto'],
|
||||
recommendation_mode: ['ai', 'local', 'manual', 'fallback'],
|
||||
subtitle_status: ['available', 'missing', 'invalid', 'unreadable', 'too_large'],
|
||||
outcome: ['completed', 'failed', 'partial', 'recommended', 'manual', 'fallback', 'scheduled', 'inbox', 'unknown'],
|
||||
outcome: ['completed', 'failed', 'partial', 'recommended', 'manual', 'fallback', 'scheduled', 'inbox', 'unknown', 'auto_started'],
|
||||
download_mode: ['native', 'browser'], gateway: ['bilibili', 'upload-post'],
|
||||
title_style: ['plain', 'impact', 'card', 'comic', 'neon', 'arena', 'editorial', 'pixel', 'frosted'],
|
||||
share_target: ['copy_caption', 'use_case_discussion'],
|
||||
output_rating: ['ready', 'needs_edit', 'unusable'],
|
||||
template: ['interview_zh', 'podcast_en', 'landscape', 'none'],
|
||||
packaging_style: ['classic', 'boxed', 'spotlight', 'pop', 'cinematic'],
|
||||
framing: ['speaker', 'full_frame', 'full_frame_pending', 'full_frame_captions'],
|
||||
}
|
||||
for (const [key, allowed] of Object.entries(enums)) {
|
||||
if (typeof input[key] === 'string' && allowed.includes(input[key] as string)) out[key] = input[key] as string
|
||||
}
|
||||
for (const key of ['brand_outro_enabled', 'reused_content_profile', 'has_crop_track', 'has_manual_adjustment', 'auto_frame_retained', 'subtitle_enabled', 'has_subtitle', 'goal_content', 'goal_highlight', 'goal_promo', 'allow_visual_screening', 'scheduled', ...['requested', 'succeeded', 'failed'].flatMap(p => ['content', 'highlight', 'promo'].map(g => `${p}_${g}`))]) {
|
||||
for (const key of ['brand_outro_enabled', 'reused_content_profile', 'has_crop_track', 'has_manual_adjustment', 'auto_frame_retained', 'tags_enabled', 'packaging_fallback', 'burned_captions', 'subtitle_enabled', 'has_subtitle', 'goal_content', 'goal_highlight', 'goal_promo', 'allow_visual_screening', 'scheduled', ...['requested', 'succeeded', 'failed'].flatMap(p => ['content', 'highlight', 'promo'].map(g => `${p}_${g}`))]) {
|
||||
if (typeof input[key] === 'boolean') out[key] = input[key] as boolean
|
||||
}
|
||||
for (const key of ['example_version', 'framed_count', 'scene_count', 'fit_count', 'duration_ms', 'request_duration_ms', 'variant_count', 'completed_variant_count', 'platform_count', 'result_count', 'requested_count', 'succeeded_count', 'failed_count']) {
|
||||
for (const key of ['example_version', 'framed_count', 'scene_count', 'fit_count', 'duration_ms', 'request_duration_ms', 'variant_count', 'completed_variant_count', 'failed_variant_count', 'skipped_variant_count', 'interview_count', 'podcast_count', 'landscape_count', 'speaker_framed_count', 'packaging_fallback_count', 'trimmed_count', 'platform_count', 'result_count', 'requested_count', 'succeeded_count', 'failed_count']) {
|
||||
if (typeof input[key] === 'number' && Number.isFinite(input[key]) && (input[key] as number) >= 0) out[key] = input[key] as number
|
||||
}
|
||||
for (const prefix of ['requested', 'succeeded', 'failed']) {
|
||||
@@ -95,6 +98,33 @@ export interface StudioSnapshot {
|
||||
plan?: { id: string; mode?: string; confirmed_analysis?: string; recommended_analysis?: string; local_evidence?: { subtitle_status?: string } }
|
||||
analysis?: { run_id?: string; phase?: string; status: string; outcome?: string; duration_ms?: number; error_code?: string; requested_goals?: string[]; succeeded_goals?: string[]; failed_goals?: string[]; result_count?: number } | null
|
||||
jobs?: { job_id: string; status: string; duration_ms?: number; error_code?: string }[]
|
||||
generation?: { auto_start?: boolean; status?: string; requested_platforms?: string[]; completed_variant_count?: number; skipped?: unknown[]; source_has_burned_subtitles?: boolean; created_at?: string; finished_at?: string } | null
|
||||
output_variants?: { draft_id: string; strategy_id: string; status: string; framing?: string; trimmed_to_sec?: number }[]
|
||||
drafts?: { id: string; packaging?: { template?: string; fallback?: boolean } | null }[]
|
||||
}
|
||||
|
||||
/** Aggregate counts for one automatic generation; no titles, captions, names or IDs. */
|
||||
export function generationSummary(snapshot: StudioSnapshot): Record<string, unknown> {
|
||||
const variants = snapshot.output_variants || []
|
||||
const templates = new Map((snapshot.drafts || []).map(d => [d.id, d.packaging] as const))
|
||||
const packaged = variants.map(v => templates.get(v.draft_id)).filter(Boolean)
|
||||
const count = (test: (v: (typeof variants)[number]) => boolean) => variants.filter(test).length
|
||||
const created = utcMillis(snapshot.generation?.created_at), finished = utcMillis(snapshot.generation?.finished_at)
|
||||
return {
|
||||
variant_count: variants.length,
|
||||
completed_variant_count: count(v => v.status === 'completed'),
|
||||
failed_variant_count: count(v => v.status === 'failed'),
|
||||
skipped_variant_count: Array.isArray(snapshot.generation?.skipped) ? snapshot.generation!.skipped!.length : 0,
|
||||
platform_count: snapshot.generation?.requested_platforms?.length || 0,
|
||||
interview_count: packaged.filter(p => p?.template === 'interview_zh').length,
|
||||
podcast_count: packaged.filter(p => p?.template === 'podcast_en').length,
|
||||
landscape_count: variants.length - packaged.length,
|
||||
speaker_framed_count: count(v => v.framing === 'speaker'),
|
||||
packaging_fallback_count: packaged.filter(p => p?.fallback).length,
|
||||
trimmed_count: count(v => !!v.trimmed_to_sec),
|
||||
burned_captions: !!snapshot.generation?.source_has_burned_subtitles,
|
||||
duration_ms: created !== undefined && finished !== undefined ? Math.max(0, finished - created) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Storage and capture are injected so offline/privacy/replay behavior is testable. */
|
||||
@@ -111,7 +141,7 @@ export class WorkflowTracker {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(storage.getItem(WORKFLOW_KEY) || '[]')
|
||||
if (Array.isArray(raw)) this.watches = raw.filter((w): w is Watch =>
|
||||
w && ['project', 'export', 'bilibili', 'youtube', 'studio-screen', 'studio-production', 'studio-export', 'framing-runtime'].includes(w.kind) &&
|
||||
w && ['project', 'export', 'bilibili', 'youtube', 'studio-screen', 'studio-production', 'studio-export', 'studio-generation', 'framing-runtime'].includes(w.kind) &&
|
||||
typeof w.id === 'string' && Number.isFinite(w.since) && Array.isArray(w.seen) &&
|
||||
w.seen.every((v: unknown) => typeof v === 'string') && w.seen.length <= 2000 &&
|
||||
(w.projectId === undefined || typeof w.projectId === 'string') &&
|
||||
@@ -210,12 +240,20 @@ export class WorkflowTracker {
|
||||
}
|
||||
} else if (w.kind === 'studio-screen') {
|
||||
details = { duration_ms: snapshot.analysis?.duration_ms, error_code: snapshot.analysis?.error_code }
|
||||
if (snapshot.analysis?.status === 'failed' && snapshot.analysis?.phase !== 'production') { event = 'studio_screen_finished'; outcome = 'failed' }
|
||||
const automatic = !!snapshot.generation?.auto_start && ['production', 'rendering'].includes(snapshot.analysis?.phase || '')
|
||||
if (automatic) {
|
||||
// Automatic output skips confirmation: screening ended once production started, whatever came after.
|
||||
event = 'studio_screen_finished'; outcome = 'auto_started'
|
||||
details = { ...details, duration_ms: undefined, error_code: undefined, recommendation_mode: snapshot.plan?.mode, analysis_mode: snapshot.plan?.recommended_analysis, subtitle_status: snapshot.plan?.local_evidence?.subtitle_status }
|
||||
} else if (snapshot.analysis?.status === 'failed' && snapshot.analysis?.phase !== 'production') { event = 'studio_screen_finished'; outcome = 'failed' }
|
||||
else if (snapshot.analysis?.status === 'awaiting_confirmation' && snapshot.plan?.id) {
|
||||
event = 'studio_screen_finished'
|
||||
outcome = ['ai', 'local'].includes(snapshot.plan.mode || '') ? 'recommended' : snapshot.plan.mode
|
||||
details = { ...details, recommendation_mode: snapshot.plan.mode, analysis_mode: snapshot.plan.recommended_analysis, subtitle_status: snapshot.plan.local_evidence?.subtitle_status }
|
||||
}
|
||||
} else if (w.kind === 'studio-generation' && ['completed', 'partial', 'failed'].includes(snapshot.generation?.status || '')) {
|
||||
event = 'studio_generation_finished'; outcome = snapshot.generation!.status
|
||||
details = generationSummary(snapshot)
|
||||
} else if (w.kind === 'studio-production' && snapshot.plan?.id === w.id &&
|
||||
['completed', 'failed'].includes(snapshot.analysis?.status || '')) {
|
||||
event = 'studio_production_finished'; outcome = snapshot.analysis!.outcome || snapshot.analysis!.status
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { t } from '../../i18n'
|
||||
import { Btn } from '../../ui'
|
||||
import { trackOutputRating, trackOutputShare } from '../../analytics/studio'
|
||||
import { trackOutputRating, trackOutputShare, type VariantProperties } from '../../analytics/studio'
|
||||
import { openExternalLink } from '../../utils/externalLinks'
|
||||
import { USE_CASE_URL } from './outputShare'
|
||||
|
||||
type Rating = 'ready' | 'needs_edit' | 'unusable'
|
||||
|
||||
/** Shown once after a successful download: three-level rating, then an optional use-case invite. */
|
||||
export default function OutputFeedback({ strategyId, onDone }: { strategyId: string; onDone: () => void }) {
|
||||
export default function OutputFeedback({ projectId, variant, onDone }: { projectId: string; variant: VariantProperties; onDone: () => void }) {
|
||||
const [rated, setRated] = useState<Rating>()
|
||||
const rate = (value: Rating) => {
|
||||
setRated(value)
|
||||
trackOutputRating({ output_rating: value, strategy_id: strategyId })
|
||||
trackOutputRating(projectId, { output_rating: value, ...variant })
|
||||
}
|
||||
if (!rated) return <div className="studio-output-feedback" role="group" aria-label={t('这条成片能直接用吗?')}>
|
||||
<span className="studio-output-hint">{t('这条成片能直接用吗?')}</span>
|
||||
@@ -26,7 +26,7 @@ export default function OutputFeedback({ strategyId, onDone }: { strategyId: str
|
||||
return <div className="studio-output-feedback" role="status">
|
||||
<span className="studio-output-hint">{rated === 'unusable' ? t('收到,我们会继续改进挑片和剪辑。') : t('谢谢反馈。愿意把这条成片分享为使用案例吗?')}</span>
|
||||
<div className="studio-output-feedback-actions">
|
||||
{rated !== 'unusable' && <Btn size="sm" variant="text" onClick={() => { trackOutputShare({ share_target: 'use_case_discussion', strategy_id: strategyId }); void openExternalLink(USE_CASE_URL); onDone() }}>{t('分享使用案例')}</Btn>}
|
||||
{rated !== 'unusable' && <Btn size="sm" variant="text" onClick={() => { trackOutputShare(projectId, { share_target: 'use_case_discussion', ...variant }); void openExternalLink(USE_CASE_URL); onDone() }}>{t('分享使用案例')}</Btn>}
|
||||
<Btn size="sm" variant="text" onClick={onDone}>{t('关闭')}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { message } from 'antd'
|
||||
import { t } from '../../i18n'
|
||||
import { Btn, fmtDuration } from '../../ui'
|
||||
import { trackOutputShare } from '../../analytics/studio'
|
||||
import { trackOutputShare, type VariantProperties } from '../../analytics/studio'
|
||||
import StudioDownloadLink from './StudioDownloadLink'
|
||||
import OutputFeedback from './OutputFeedback'
|
||||
import { Draft, OutputVariant, RenderJob } from './types'
|
||||
@@ -24,13 +24,18 @@ const framingHints: Record<NonNullable<OutputVariant['framing']>, string> = {
|
||||
export default function OutputVariantCard({ projectId, variant, draft, job, onRetry }: { projectId: string; variant: OutputVariant; draft?: Draft; job?: RenderJob; onRetry: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const [asking, setAsking] = useState(false)
|
||||
const packaging = draft?.packaging
|
||||
const analytics: VariantProperties = {
|
||||
strategy_id: variant.strategy_id, framing: variant.framing, template: packaging?.template,
|
||||
packaging_style: packaging ? packaging.style || (packaging.template === 'podcast_en' ? 'pop' : 'classic') : undefined,
|
||||
}
|
||||
const completed = variant.status === 'completed' && job?.status === 'completed'
|
||||
const failed = variant.status === 'failed'
|
||||
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(shareCaption(draft?.title))
|
||||
if (copied) {
|
||||
trackOutputShare({ share_target: 'copy_caption', strategy_id: variant.strategy_id })
|
||||
trackOutputShare(projectId, { share_target: 'copy_caption', ...analytics })
|
||||
message.success(t('分享文案已复制,发布视频时粘贴即可'))
|
||||
} else message.error(t('复制失败,请稍后重试'))
|
||||
}
|
||||
@@ -56,9 +61,9 @@ export default function OutputVariantCard({ projectId, variant, draft, job, onRe
|
||||
{failed && <Btn variant="text" onClick={onRetry}>{t('重试这条')}</Btn>}
|
||||
{completed && variant.render_job_id && <Btn variant="text" onClick={() => navigate(outputVariantPublishPath(projectId, variant))}>{t('发布')}</Btn>}
|
||||
{completed && <Btn variant="text" onClick={() => void copyCaption()}>{t('复制分享文案')}</Btn>}
|
||||
{completed && variant.render_job_id && <StudioDownloadLink className="studio-link" projectId={projectId} jobId={variant.render_job_id} onSaved={downloaded}/>}
|
||||
{completed && variant.render_job_id && <StudioDownloadLink className="studio-link" projectId={projectId} jobId={variant.render_job_id} onSaved={downloaded} variant={analytics}/>}
|
||||
</div></div>
|
||||
{asking && <OutputFeedback strategyId={variant.strategy_id} onDone={() => setAsking(false)}/>}
|
||||
{asking && <OutputFeedback projectId={projectId} variant={analytics} onDone={() => setAsking(false)}/>}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { message } from 'antd'
|
||||
import { studioDownloadRequested, observeStudioDownload } from '../../analytics/studio'
|
||||
import { studioDownloadRequested, observeStudioDownload, type VariantProperties } from '../../analytics/studio'
|
||||
import { captureStudioException } from '../../desktop/sentry'
|
||||
import { studioApi } from './api'
|
||||
import { isDesktopDownload, saveStudioExport } from './nativeDownload'
|
||||
|
||||
export default function StudioDownloadLink({ projectId, jobId, className = 'studio-link', onSaved }: {
|
||||
export default function StudioDownloadLink({ projectId, jobId, className = 'studio-link', onSaved, variant }: {
|
||||
projectId: string; jobId: string; className?: string; onSaved?: () => void
|
||||
/** Enum summary of the output variant being downloaded, for delivery analytics. */
|
||||
variant?: VariantProperties
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const pending = useRef(false)
|
||||
@@ -15,13 +17,13 @@ export default function StudioDownloadLink({ projectId, jobId, className = 'stud
|
||||
return <a className={className} href={studioApi.video(projectId, jobId, true)} download
|
||||
aria-busy={busy} aria-disabled={busy}
|
||||
onClick={async event => {
|
||||
if (!isDesktopDownload()) { studioDownloadRequested(projectId, jobId); onSaved?.(); return }
|
||||
if (!isDesktopDownload()) { studioDownloadRequested(projectId, jobId, variant); onSaved?.(); return }
|
||||
event.preventDefault()
|
||||
if (pending.current) return
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
try {
|
||||
await observeStudioDownload(() => saveStudioExport(projectId, jobId), projectId, jobId)
|
||||
await observeStudioDownload(() => saveStudioExport(projectId, jobId), projectId, jobId, variant)
|
||||
message.success(t('已保存到下载文件夹'))
|
||||
onSaved?.()
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { t } from '../../i18n'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Project, Clip } from '../../store/useProjectStore'
|
||||
import { Btn, Section, Dialog, fmtDuration } from '../../ui'
|
||||
@@ -12,7 +12,6 @@ import DraftResultCard from './DraftResultCard'
|
||||
import OutputVariantCard from './OutputVariantCard'
|
||||
import PlatformPicker from './PlatformPicker'
|
||||
import { platformLabel } from './platformLabel'
|
||||
import { trackQuickOutput } from '../../analytics/studio'
|
||||
|
||||
import './studio.css'
|
||||
|
||||
@@ -27,7 +26,6 @@ export default function StudioResults({ project, children, onCreateCollection, o
|
||||
const [exporting,setExporting] = useState<Draft|null>(null)
|
||||
const [addingPlatforms, setAddingPlatforms] = useState(false)
|
||||
const [platforms, setPlatforms] = useState<string[]>([])
|
||||
const trackedGeneration = useRef('')
|
||||
const goal = project.settings?.creative?.goal || project.processing_config?.creative?.goal || 'content'
|
||||
const visual = goal !== 'content'
|
||||
const sourceDuration=workspace.plan?.source_duration ?? workspace.analysis?.coverage?.duration
|
||||
@@ -36,14 +34,8 @@ export default function StudioResults({ project, children, onCreateCollection, o
|
||||
const variantDraft = (draftId: string) => workspace.drafts.find(draft => draft.id === draftId)
|
||||
const variantJob = (jobId?: string) => workspace.jobs.find(job => job.job_id === jobId)
|
||||
useEffect(()=>{if(workspace.analysis && workspace.analysis.status!=='running' && project.status!=='completed') onReload()},[workspace.analysis?.status])
|
||||
useEffect(() => {
|
||||
const generation = workspace.generation
|
||||
if (!generation || !['completed', 'partial', 'failed'].includes(generation.status)) return
|
||||
const token = `${generation.status}:${workspace.output_variants?.length || 0}:${generation.completed_variant_count || 0}`
|
||||
if (trackedGeneration.current === token) return
|
||||
trackedGeneration.current = token
|
||||
trackQuickOutput({ outcome: generation.status, variant_count: workspace.output_variants?.length || 0, completed_variant_count: generation.completed_variant_count || 0, platform_count: generation.requested_platforms.length, brand_outro_enabled: generation.branding.outro_enabled })
|
||||
}, [workspace.generation, workspace.output_variants])
|
||||
// The generation terminal event comes from the persisted `studio-generation` watch registered at
|
||||
// import, so it is reported once even when the user leaves this page before rendering ends.
|
||||
const act = async (key: string, fn:()=>Promise<void>) => {setBusy(key);setActionError('');try{await fn();refresh()}catch(e){setActionError(t(errorText(e)))}finally{setBusy('')}}
|
||||
const appendPlatforms = () => act('append-platforms', async () => { await studioApi.appendPlatforms(project.id, platforms, workspace.generation?.branding.outro_enabled ?? true); setAddingPlatforms(false) })
|
||||
const createLegacy = (clip: Clip) => act(clip.id, async()=>{const draft=await studioApi.create(project.id,[clip.id],clip.generated_title||clip.title||t("新成片"));navigate(`/project/${project.id}/studio/${draft.id}`)})
|
||||
|
||||
@@ -6,10 +6,14 @@ import api from '../../services/api'
|
||||
import { Draft, Workspace, RenderJob, Language, CandidateList, ImportOptions, Goal, AnalysisMode, AnalysisPreferences, SubtitleCue, FramingStatus, AutoFrameResult, PlatformStrategySummary, OutputVariant } from './types'
|
||||
export type SourcePreview = { status: 'idle' | 'queued' | 'running' | 'completed' | 'failed'; version?: string; error?: string }
|
||||
export function draftProperties(draft?: Partial<Draft>): Properties {
|
||||
const packaging = draft?.packaging
|
||||
return safeStudioProperties({ aspect: draft?.aspect, subtitle_enabled: draft?.subtitles, title_style: draft?.title_style,
|
||||
layout: draft?.layout, has_crop_track: !!draft?.scenes?.some(s => s.crop_track?.length),
|
||||
has_manual_adjustment: !!draft?.scenes?.some(s => s.framing_adjusted),
|
||||
auto_frame_retained: draft?.layout === 'crop' && !!draft?.scenes?.some(s => s.framing_source === 'auto' && !s.framing_adjusted && s.crop_track?.length),
|
||||
auto_frame_retained: (draft?.layout === 'crop' || draft?.layout === 'window') && !!draft?.scenes?.some(s => s.framing_source === 'auto' && !s.framing_adjusted && s.crop_track?.length),
|
||||
// Template packaging: enums and booleans only, never the title, captions, names or tags.
|
||||
template: packaging?.template, packaging_style: packaging ? packaging.style || (packaging.template === 'podcast_en' ? 'pop' : 'classic') : undefined,
|
||||
tags_enabled: packaging ? packaging.tags_enabled : undefined, packaging_fallback: packaging ? packaging.fallback : undefined,
|
||||
})
|
||||
}
|
||||
export const studioApi = {
|
||||
@@ -39,6 +43,8 @@ export const studioApi = {
|
||||
return observeStudioOperation('studio_import', () => api.post('/studio/import', body, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }), (result: { project_id: string; analysis_run_id?: string }, observed) => {
|
||||
workflow.rememberProject(result.project_id, props)
|
||||
workflow.watch('studio-screen', result.project_id, undefined, undefined, observed, false, result.analysis_run_id)
|
||||
// Automatic output has no confirmation step: watch the generation itself to its terminal state.
|
||||
if (body.get?.('auto_start') === 'true') workflow.watch('studio-generation', result.project_id, undefined, undefined, observed)
|
||||
}, props)
|
||||
},
|
||||
confirmPlan: (pid: string, planId: string, goals: Goal[], options: ImportOptions, analysisMode: AnalysisMode) => observeStudioOperation('studio_confirm', () => api.post<unknown, { analysis_run_id?: string }>(`/studio/${pid}/start`, {plan_id:planId, goals, analysis_mode:analysisMode, language:options.language, aspect:options.aspect, duration:options.duration}), (_result, props) => workflow.watch('studio-production', planId, pid, undefined, { ...props, ...workflow.context(pid), analysis_mode: analysisMode, ...studioGoals(goals) }, false, _result.analysis_run_id), { ...workflow.context(pid), analysis_mode: analysisMode, ...studioGoals(goals), aspect: options.aspect || 'auto' }),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const {test}=require('node:test')
|
||||
const assert=require('node:assert/strict')
|
||||
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),ts=require('typescript')
|
||||
|
||||
function loadWorkflow(){
|
||||
const source=fs.readFileSync(path.join(__dirname,'../src/analytics/workflow.ts'),'utf8')
|
||||
const exports={}
|
||||
vm.runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,{exports,Date,Math,JSON,Number,Array,Object,Map,Set,String,URL})
|
||||
return exports
|
||||
}
|
||||
const workflow=loadWorkflow()
|
||||
|
||||
function tracker(){
|
||||
const events=[],store=new Map()
|
||||
const storage={getItem:k=>store.get(k)??null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)}
|
||||
const t=new workflow.WorkflowTracker(storage,()=>true,(name,props)=>{events.push({name,props});return true})
|
||||
return {t,events}
|
||||
}
|
||||
|
||||
const SNAPSHOT={
|
||||
generation:{auto_start:true,status:'partial',requested_platforms:['douyin','tiktok'],skipped:[{strategy_id:'youtube_long',reason:'x'}],
|
||||
source_has_burned_subtitles:false,created_at:'2026-09-30T10:00:00Z',finished_at:'2026-09-30T10:04:00Z'},
|
||||
analysis:{status:'completed',phase:'rendering',run_id:'run-1'},
|
||||
plan:{id:'plan-1',mode:'local',recommended_analysis:'subtitle'},
|
||||
output_variants:[
|
||||
{draft_id:'a',strategy_id:'douyin',status:'completed',framing:'speaker'},
|
||||
{draft_id:'b',strategy_id:'tiktok',status:'failed',framing:'full_frame',trimmed_to_sec:180},
|
||||
],
|
||||
drafts:[{id:'a',title:'Private title',packaging:{template:'interview_zh',fallback:false}},{id:'b',packaging:{template:'podcast_en',fallback:true}}],
|
||||
}
|
||||
|
||||
test('generation summary counts templates, framing and fallbacks without any content',()=>{
|
||||
const summary=workflow.generationSummary(SNAPSHOT)
|
||||
assert.deepEqual({...summary},{variant_count:2,completed_variant_count:1,failed_variant_count:1,skipped_variant_count:1,platform_count:2,
|
||||
interview_count:1,podcast_count:1,landscape_count:0,speaker_framed_count:1,packaging_fallback_count:1,trimmed_count:1,burned_captions:false,duration_ms:240000})
|
||||
const props=workflow.safeStudioProperties({...summary,title:'Private title',name:'Sam Altman',template:'interview_zh',packaging_style:'boxed',framing:'speaker'})
|
||||
assert.equal(JSON.stringify(props).includes('Private'),false)
|
||||
assert.equal(JSON.stringify(props).includes('Sam'),false)
|
||||
assert.equal(props.template,'interview_zh'); assert.equal(props.packaging_style,'boxed'); assert.equal(props.framing,'speaker')
|
||||
})
|
||||
|
||||
test('automatic output ends screening as auto_started, even if rendering later failed',()=>{
|
||||
const {t,events}=tracker()
|
||||
t.watch('studio-screen','p1',undefined,undefined,{},false,'run-1')
|
||||
t.observeStudio(t.list().find(w=>w.kind==='studio-screen'),{...SNAPSHOT,analysis:{status:'failed',phase:'rendering',run_id:'run-1'}})
|
||||
const screen=events.find(e=>e.name==='studio_screen_finished')
|
||||
assert.ok(screen); assert.equal(screen.props.outcome,'auto_started')
|
||||
})
|
||||
|
||||
test('generation watch reports one terminal event with the aggregate summary',()=>{
|
||||
const {t,events}=tracker()
|
||||
t.watch('studio-generation','p1')
|
||||
const watch=t.list().find(w=>w.kind==='studio-generation')
|
||||
t.observeStudio(watch,{...SNAPSHOT,generation:{...SNAPSHOT.generation,status:'rendering'}})
|
||||
assert.equal(events.filter(e=>e.name==='studio_generation_finished').length,0)
|
||||
t.observeStudio(watch,SNAPSHOT)
|
||||
t.observeStudio(watch,SNAPSHOT)
|
||||
const finished=events.filter(e=>e.name==='studio_generation_finished')
|
||||
assert.equal(finished.length,1)
|
||||
assert.equal(finished[0].props.outcome,'partial'); assert.equal(finished[0].props.speaker_framed_count,1)
|
||||
})
|
||||
@@ -14,7 +14,7 @@ test('share caption credits AutoClip with the repository link and is copied loca
|
||||
assert.match(share,/t\('用 AutoClip 剪的'\)\} · \$\{REPO_URL\}/)
|
||||
assert.match(share,/navigator\.clipboard\.writeText/)
|
||||
assert.match(card,/copyText\(shareCaption\(draft\?\.title\)\)/)
|
||||
assert.match(card,/trackOutputShare\(\{ share_target: 'copy_caption'/)
|
||||
assert.match(card,/trackOutputShare\(projectId, \{ share_target: 'copy_caption', \.\.\.analytics \}\)/)
|
||||
})
|
||||
|
||||
test('rating is asked after a successful download at most once per project and once a week',()=>{
|
||||
@@ -34,6 +34,8 @@ test('use-case invite goes to GitHub Discussions and nothing is uploaded',()=>{
|
||||
test('share and rating analytics only carry allowlisted enums',()=>{
|
||||
assert.match(workflow,/share_target: \['copy_caption', 'use_case_discussion'\]/)
|
||||
assert.match(workflow,/output_rating: \['ready', 'needs_edit', 'unusable'\]/)
|
||||
assert.match(studio,/captureBusinessEvent\('studio_output_shared', safeStudioProperties\(properties\)\)/)
|
||||
assert.match(studio,/captureBusinessEvent\('studio_output_rated', safeStudioProperties\(properties\)\)/)
|
||||
assert.match(studio,/captureBusinessEvent\('studio_output_shared', safeStudioProperties\(\{ \.\.\.workflow\.context\(projectId\), \.\.\.properties \}\)\)/)
|
||||
assert.match(studio,/captureBusinessEvent\('studio_output_rated', safeStudioProperties\(\{ \.\.\.workflow\.context\(projectId\), \.\.\.properties \}\)\)/)
|
||||
assert.match(workflow,/template: \['interview_zh', 'podcast_en', 'landscape', 'none'\]/)
|
||||
assert.match(workflow,/packaging_style: \['classic', 'boxed', 'spotlight', 'pop', 'cinematic'\]/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user