Files
autoclip/frontend/src/features/studio/OutputVariantCard.tsx
T
周小舟andClaude Opus 5.5 506beb7291 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>
2026-09-30 23:49:46 +08:00

70 lines
4.9 KiB
TypeScript

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { message } from 'antd'
import { t } from '../../i18n'
import { Btn, fmtDuration } from '../../ui'
import { trackOutputShare, type VariantProperties } from '../../analytics/studio'
import StudioDownloadLink from './StudioDownloadLink'
import OutputFeedback from './OutputFeedback'
import { Draft, OutputVariant, RenderJob } from './types'
import { studioApi } from './api'
import { outputVariantPublishPath } from './outputVariantPublish'
import { copyText, markRatingAsked, shareCaption, shouldAskRating } from './outputShare'
import { platformLabel } from './platformLabel'
import { packagingLabel } from './packagingLabel'
import './quick-output.css'
const framingHints: Record<NonNullable<OutputVariant['framing']>, string> = {
speaker: '已按说话人重新取景',
full_frame: '画面里没有可跟随的人物,保留完整画面',
full_frame_pending: '人物识别组件准备中,这条先保留完整画面',
full_frame_captions: '原片自带字幕,保留完整画面以免裁掉字幕',
}
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(projectId, { share_target: 'copy_caption', ...analytics })
message.success(t('分享文案已复制,发布视频时粘贴即可'))
} else message.error(t('复制失败,请稍后重试'))
}
const downloaded = () => {
if (!shouldAskRating(projectId)) return
markRatingAsked(projectId)
setAsking(true)
}
return <article className="ac-card">
<div className="studio-variant-thumb">{completed && variant.render_job_id ? <video className="studio-variant-video" controls preload="metadata" src={studioApi.video(projectId, variant.render_job_id)}/> : <span className="play">▷</span>}<span className="ac-tag ac-tag--tl">{platformLabel(variant.strategy_id)}</span>{duration > 0 && <span className="ac-tag ac-tag--br">{fmtDuration(duration)}</span>}</div>
<div className="ac-card-body">
<h2 className="ac-card-title">{draft?.title || t('正在准备成片')}</h2>
<div className={`studio-output-state studio-output-state--${failed ? 'failed' : completed ? 'ready' : 'rendering'}`} role="status">
{completed ? t('已生成 · 可下载') : failed ? t('生成失败') : job?.status === 'running' ? t('正在生成') : t('排队生成中')}
</div>
<p className="studio-output-hint">{variant.branding.outro_enabled ? t('包含 Made with AutoClip 片尾') : t('不含品牌片尾')}</p>
{variant.trimmed_to_sec && <p className="studio-output-hint">{t('平台上限 {{seconds}} 秒,已在句子结束处截断', { seconds: variant.trimmed_to_sec })}</p>}
{variant.framing && framingHints[variant.framing] && <p className="studio-output-hint">{t(framingHints[variant.framing])}</p>}
{draft?.packaging && <p className="studio-output-hint">{packagingLabel(draft.packaging)}{draft.packaging.fallback ? ` · ${t('包装未能完整生成,已使用原字幕')}` : ''}</p>}
{failed && <p className="studio-output-hint studio-error">{variant.error || job?.error || t('这条版本未完成,其他成片不受影响。')}</p>}
<div className="ac-card-foot"><span className="meta">{platformLabel(variant.strategy_id)}</span><div className="ac-card-actions">
{draft && <Btn variant="text" onClick={() => navigate(`/project/${projectId}/studio/${draft.id}`)}>{t('预览与修改')}</Btn>}
{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} variant={analytics}/>}
</div></div>
{asking && <OutputFeedback projectId={projectId} variant={analytics} onDone={() => setAsking(false)}/>}
</div>
</article>
}