fix(studio): show the whole cover in the kit; drop dead raw-clip cards

The output card's 16:9 thumbnail crops a 9:16 cover to the face, so the kit
now shows the cover at its own ratio. Automatic projects no longer list raw
clip cards after their outputs: fast output never renders raw clip files, so
their previews 404'd and Download/Export did nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
周小舟
2026-10-01 18:25:25 +08:00
co-authored by Claude Opus 5.5
parent 0fc14a61fb
commit a0108d889c
14 changed files with 78 additions and 18 deletions
@@ -58,7 +58,7 @@ export default function OutputVariantCard({ projectId, variant, draft, job, onRe
{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>}
<PublishKit projectId={projectId} variant={variant} onCoverChanged={() => setCoverStamp(Date.now())}/>
<PublishKit projectId={projectId} variant={variant} coverStamp={coverStamp} onCoverChanged={() => setCoverStamp(Date.now())}/>
{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>}
+12 -4
View File
@@ -16,7 +16,7 @@ export function postCaption(post: PostCopy) {
}
/** Ready-to-publish copy and cover of one output: read, edit, copy, export as a bundle, redo the cover with AI. */
export default function PublishKit({ projectId, variant, onCoverChanged }: { projectId: string; variant: OutputVariant; onCoverChanged: () => void }) {
export default function PublishKit({ projectId, variant, coverStamp, onCoverChanged }: { projectId: string; variant: OutputVariant; coverStamp: number; onCoverChanged: () => void }) {
const post = variant.post
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<PostCopy | null>(post || null)
@@ -39,6 +39,7 @@ export default function PublishKit({ projectId, variant, onCoverChanged }: { pro
else message.error(t('复制失败,请稍后重试'))
}
const kitPath = `/studio/${projectId}/output-variants/${variant.id}/kit`
const coverUrl = studioApi.variantCover(projectId, variant.id, coverStamp)
const exportKit = async (event: React.MouseEvent) => {
if (!isDesktopDownload()) return
event.preventDefault()
@@ -72,9 +73,16 @@ export default function PublishKit({ projectId, variant, onCoverChanged }: { pro
onChange={event => setDraft({ ...draft, tags: event.target.value.split(/[\s,,#]+/).filter(Boolean) })}/>
<div className="studio-post-actions"><Btn variant="text" onClick={() => { setDraft(post); setEditing(false) }}>{t('取消')}</Btn><Btn size="sm" loading={saving} onClick={() => void save()}>{t('保存')}</Btn></div>
</div> : <>
<p className="studio-post-title">{post.title}</p>
{post.description && <p className="studio-post-desc">{post.description}</p>}
{post.tags.length > 0 && <p className="studio-post-tags">{post.tags.map(tag => `#${tag}`).join(' ')}</p>}
<div className="studio-post-body">
{variant.cover && <a className={`studio-post-cover${LANDSCAPE.has(variant.strategy_id) ? ' studio-post-cover--wide' : ''}`} href={coverUrl} target="_blank" rel="noreferrer" title={t('查看封面')}>
<img src={coverUrl} alt={t('封面')}/>
</a>}
<div className="studio-post-text">
<p className="studio-post-title">{post.title}</p>
{post.description && <p className="studio-post-desc">{post.description}</p>}
{post.tags.length > 0 && <p className="studio-post-tags">{post.tags.map(tag => `#${tag}`).join(' ')}</p>}
</div>
</div>
<div className="studio-post-actions">
<Btn variant="text" onClick={() => void copy()}>{t('复制发布文案')}</Btn>
<Btn variant="text" onClick={() => setEditing(true)}>{t('编辑文案')}</Btn>
@@ -18,7 +18,7 @@ import './studio.css'
export default function StudioResults({ project, children, onCreateCollection, onReload }: { project: Project; children: ReactNode; onCreateCollection: () => void; onReload: () => void }) {
useTranslation()
const navigate = useNavigate()
const {workspace,error,loading,refresh} = useWorkspace(project.id)
const {workspace,error,loading,loaded,refresh} = useWorkspace(project.id)
const [history, setHistory] = useState(false)
const [busy,setBusy] = useState('')
const [actionError,setActionError] = useState('')
@@ -42,12 +42,12 @@ export default function StudioResults({ project, children, onCreateCollection, o
return <>
{managed && !automatic && <div className="studio-row"><span className="studio-muted">{sourceDuration!=null?`${t('原素材 {{duration}}', { duration: fmtDuration(sourceDuration) })} · `:''}{workspace.analysis?.status==='awaiting_confirmation'?t("新制作方案待确认,已有结果保留。"):t("制作结果与编辑")}</span><Btn size="sm" disabled={workspace.analysis?.status==='running'} onClick={()=>navigate(`/import/${project.id}`)}>{workspace.analysis?.status==='awaiting_confirmation'?t("继续确认方案"):t("调整制作方案")}</Btn></div>}
{automatic && <><div className="studio-row"><span className="studio-muted">{workspace.generation?.status==='completed'?t('已按所选平台生成可发布成片。'):workspace.generation?.status==='partial'?t('部分成片已完成,未完成版本可稍后重试。'):t('AI 正在按所选平台制作成片。')}</span><Btn size="sm" disabled={!!busy} onClick={()=>{setPlatforms([]);setAddingPlatforms(value=>!value)}}>{t('追加平台版本')}</Btn></div>{workspace.generation?.skipped?.map(item=><p key={item.strategy_id} className="studio-output-hint">{t('未生成 {{platform}}:{{reason}}', { platform: platformLabel(item.strategy_id), reason: t(item.reason) })}</p>)}{addingPlatforms && <div className="studio-import-box"><PlatformPicker value={platforms} onChange={setPlatforms} disabled={!!busy}/><div className="studio-actions"><Btn size="sm" onClick={()=>setAddingPlatforms(false)}>{t('关闭')}</Btn><Btn variant="cta" size="sm" disabled={!platforms.length} loading={busy==='append-platforms'} onClick={appendPlatforms}>{t('生成追加版本')}</Btn></div></div>}</>}
{!(workspace.analysis?.status==='awaiting_confirmation' && !workspace.drafts.length && !project.clips?.length && !project.collections?.length) && <Section title={t("剪辑与成片")} count={workspace.drafts.length+(project.clips?.length||0)+(project.collections?.length||0)} description={visual?t("候选已整理为可编辑草稿;导出完成后,才能下载带包装的视频。"):t("切片与合集都在这里,可以直接下载,也可以另存为成片草稿。")} right={<div className="studio-actions"><Btn size="sm" onClick={()=>setHistory(true)}>{t("导出记录")}</Btn>{!managed&&<Btn size="sm" onClick={onCreateCollection}>{t("新建合集")}</Btn>}</div>}>
{!(workspace.analysis?.status==='awaiting_confirmation' && !workspace.drafts.length && !project.clips?.length && !project.collections?.length) && <Section title={t("剪辑与成片")} count={automatic ? (workspace.output_variants?.length || 0) : workspace.drafts.length+(project.clips?.length||0)+(project.collections?.length||0)} description={visual?t("候选已整理为可编辑草稿;导出完成后,才能下载带包装的视频。"):t("切片与合集都在这里,可以直接下载,也可以另存为成片草稿。")} right={<div className="studio-actions"><Btn size="sm" onClick={()=>setHistory(true)}>{t("导出记录")}</Btn>{!managed&&<Btn size="sm" onClick={onCreateCollection}>{t("新建合集")}</Btn>}</div>}>
{workspace.analysis?.status==='running' && <div className="ac-empty studio-processing"><b><span className="spin"/> {t(workspace.analysis.message||"分析画面中")}{typeof workspace.analysis.percent==='number' && ` · ${workspace.analysis.percent}%`}</b>{workspace.analysis.phase==='screening'?t("识别完成后,请确认要制作的类型;现在还不会开始剪辑。"):t("完成后草稿会出现在这里,可以先处理其他项目。")}</div>}
{workspace.analysis?.status==='failed' && <div className="ac-empty"><b>{t("这次制作未完成")}</b><span className="studio-error">{t(workspace.analysis.error || '')}</span><Btn onClick={()=>navigate('/settings')}>{t("模型设置")}</Btn></div>}
{error && <div className="ac-empty"><b>{t("成片草稿加载失败")}</b>{t(error)}<Btn onClick={refresh}>{t("重试")}</Btn></div>}
<div className="ac-grid-3 studio-result-grid">{automatic ? [...(workspace.output_variants || [])].sort((a, b) => Number(a.status === 'on_demand') - Number(b.status === 'on_demand')).map(variant => <OutputVariantCard key={variant.id} projectId={project.id} variant={variant} draft={variantDraft(variant.draft_id)} job={variantJob(variant.render_job_id)} onRetry={()=>act(`retry-${variant.id}`, async()=>{await studioApi.retryOutputVariant(project.id, variant.id)})} onProduce={()=>act(`produce-${variant.id}`, async()=>{await studioApi.produceOutputVariant(project.id, variant.id, variant.strategy_id)})}/>) : workspace.drafts.map(d => <DraftResultCard key={d.id} projectId={project.id} draft={d} jobs={workspace.jobs}
onEdit={()=>navigate(`/project/${project.id}/studio/${d.id}`)} onExport={()=>setExporting(d)} onHistory={()=>setHistory(true)} />)}{children}</div>
onEdit={()=>navigate(`/project/${project.id}/studio/${d.id}`)} onExport={()=>setExporting(d)} onHistory={()=>setHistory(true)} />)}{loaded && !automatic && children}</div>
{!loading&&!error&&!workspace.drafts.length&&!project.clips?.length&&!project.collections?.length&&workspace.analysis?.status!=='running'&&<div className="ac-empty"><b>{t("暂时没有可用成片")}</b>{managed?t("可以重试,或修改制作方案。"):t("可调整最低评分后重试内容处理。")}</div>}
</Section>}
{actionError&&<p className="studio-error" role="alert">{actionError}</p>}
@@ -119,6 +119,36 @@
padding-top: 12px;
}
.studio-post-body {
align-items: flex-start;
display: flex;
gap: 12px;
}
.studio-post-text {
flex: 1;
min-width: 0;
}
/* The cover at its real ratio: the video thumbnail above crops it to 16:9. */
.studio-post-cover {
border: 1px solid var(--ac-line);
border-radius: 10px;
flex: none;
overflow: hidden;
width: 64px;
}
.studio-post-cover--wide {
width: 104px;
}
.studio-post-cover img {
display: block;
height: auto;
width: 100%;
}
.studio-post-title {
color: var(--ac-ink);
font-size: 13.5px;
+4 -2
View File
@@ -7,6 +7,8 @@ export function useWorkspace(projectId: string | undefined) {
const [state, setState] = useState<Workspace>(emptyWorkspace)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
// True once the first response arrived; `loading` turns true again on every refresh.
const [loaded, setLoaded] = useState(false)
const [version, setVersion] = useState(0)
const refresh = useCallback(() => setVersion(v => v + 1), [])
useEffect(() => {
@@ -15,7 +17,7 @@ export function useWorkspace(projectId: string | undefined) {
setLoading(true)
void pollWorkspace(signal => studioApi.get(projectId, signal), {
signal: controller.signal,
onData: data => { setState(data); setError('') },
onData: data => { setState(data); setError(''); setLoaded(true) },
onError: error => setError(errorText(error)),
onSettled: () => setLoading(false),
})
@@ -30,5 +32,5 @@ export function useWorkspace(projectId: string | undefined) {
document.removeEventListener('visibilitychange', resume)
}
}, [refresh])
return { workspace: state, error, loading, refresh }
return { workspace: state, error, loading, loaded, refresh }
}
+2 -1
View File
@@ -1510,5 +1510,6 @@
"AI 重新设计封面": "Redesign cover with AI",
"使用百炼专属接入地址时填写,留空用默认地址。": "Only for a dedicated Bailian endpoint; leave empty for the default address.",
"自动设计(免费)": "Auto design (free)",
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "Covers are designed automatically from the guest's sharpest frame: free and instant. For more design, switch to AI generation; you choose the image service and model, billed by that service."
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "Covers are designed automatically from the guest's sharpest frame: free and instant. For more design, switch to AI generation; you choose the image service and model, billed by that service.",
"查看封面": "View cover"
}
+2 -1
View File
@@ -1510,5 +1510,6 @@
"AI 重新设计封面": "Rediseñar portada con IA",
"使用百炼专属接入地址时填写,留空用默认地址。": "Solo para un endpoint dedicado de Bailian; déjalo vacío para usar la dirección predeterminada.",
"自动设计(免费)": "Diseño automático (gratis)",
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "Las portadas se diseñan automáticamente con el fotograma más nítido del invitado: gratis e inmediato. Para más diseño, cambia a generación con IA; tú eliges el servicio y el modelo de imágenes, que cobra ese servicio."
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "Las portadas se diseñan automáticamente con el fotograma más nítido del invitado: gratis e inmediato. Para más diseño, cambia a generación con IA; tú eliges el servicio y el modelo de imágenes, que cobra ese servicio.",
"查看封面": "Ver portada"
}
+2 -1
View File
@@ -1510,5 +1510,6 @@
"AI 重新设计封面": "Redessiner la couverture avec l’IA",
"使用百炼专属接入地址时填写,留空用默认地址。": "Uniquement pour un point d’accès Bailian dédié ; laissez vide pour l’adresse par défaut.",
"自动设计(免费)": "Design automatique (gratuit)",
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "Les couvertures sont conçues automatiquement à partir de l'image la plus nette de l'invité : gratuit et immédiat. Pour plus de design, passez à la génération par IA ; vous choisissez le service et le modèle d'images, facturés par ce service."
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "Les couvertures sont conçues automatiquement à partir de l'image la plus nette de l'invité : gratuit et immédiat. Pour plus de design, passez à la génération par IA ; vous choisissez le service et le modèle d'images, facturés par ce service.",
"查看封面": "Voir la couverture"
}
+2 -1
View File
@@ -1510,5 +1510,6 @@
"AI 重新设计封面": "AI でカバーを作り直す",
"使用百炼专属接入地址时填写,留空用默认地址。": "Bailian の専用エンドポイントを使う場合のみ入力。空欄なら既定のアドレスを使います。",
"自动设计(免费)": "自動デザイン(無料)",
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "ゲストが最も鮮明に映ったフレームから自動でカバーをデザインします(無料・即時)。よりデザイン性を高めるには AI 生成に切り替えてください。画像サービスとモデルは自由に選べ、料金は選んだサービスに支払います。"
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "ゲストが最も鮮明に映ったフレームから自動でカバーをデザインします(無料・即時)。よりデザイン性を高めるには AI 生成に切り替えてください。画像サービスとモデルは自由に選べ、料金は選んだサービスに支払います。",
"查看封面": "カバーを見る"
}
+2 -1
View File
@@ -1510,5 +1510,6 @@
"AI 重新设计封面": "AI로 표지 다시 만들기",
"使用百炼专属接入地址时填写,留空用默认地址。": "Bailian 전용 엔드포인트를 쓸 때만 입력하세요. 비우면 기본 주소를 사용합니다.",
"自动设计(免费)": "자동 디자인(무료)",
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "게스트가 가장 선명한 프레임으로 표지를 자동 디자인합니다(무료·즉시). 더 디자인감 있는 표지를 원하면 AI 생성으로 바꾸세요. 이미지 서비스와 모델은 직접 선택하며 해당 서비스 요금이 부과됩니다."
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "게스트가 가장 선명한 프레임으로 표지를 자동 디자인합니다(무료·즉시). 더 디자인감 있는 표지를 원하면 AI 생성으로 바꾸세요. 이미지 서비스와 모델은 직접 선택하며 해당 서비스 요금이 부과됩니다.",
"查看封面": "표지 보기"
}
+2 -1
View File
@@ -1510,5 +1510,6 @@
"AI 重新设计封面": "Refazer capa com IA",
"使用百炼专属接入地址时填写,留空用默认地址。": "Só para um endpoint dedicado do Bailian; deixe vazio para o endereço padrão.",
"自动设计(免费)": "Design automático (grátis)",
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "As capas são desenhadas automaticamente a partir do quadro mais nítido do convidado: grátis e na hora. Para mais design, mude para geração por IA; você escolhe o serviço e o modelo de imagens, cobrados por esse serviço."
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "As capas são desenhadas automaticamente a partir do quadro mais nítido do convidado: grátis e na hora. Para mais design, mude para geração por IA; você escolhe o serviço e o modelo de imagens, cobrados por esse serviço.",
"查看封面": "Ver capa"
}
+2 -1
View File
@@ -1510,5 +1510,6 @@
"AI 重新设计封面": "Переделать обложку с ИИ",
"使用百炼专属接入地址时填写,留空用默认地址。": "Только для выделенного адреса Bailian; оставьте пустым, чтобы использовать адрес по умолчанию.",
"自动设计(免费)": "Автодизайн (бесплатно)",
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "Обложки создаются автоматически из самого чёткого кадра гостя — бесплатно и сразу. Для большего дизайна переключитесь на генерацию ИИ; сервис и модель изображений выбираете вы, оплата — по тарифам выбранного сервиса."
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "Обложки создаются автоматически из самого чёткого кадра гостя — бесплатно и сразу. Для большего дизайна переключитесь на генерацию ИИ; сервис и модель изображений выбираете вы, оплата — по тарифам выбранного сервиса.",
"查看封面": "Открыть обложку"
}
+2 -1
View File
@@ -1510,5 +1510,6 @@
"AI 重新设计封面": "AI 重新设计封面",
"使用百炼专属接入地址时填写,留空用默认地址。": "使用百炼专属接入地址时填写,留空用默认地址。",
"自动设计(免费)": "自动设计(免费)",
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。"
"默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。": "默认用成片里嘉宾最清晰的画面自动设计封面,免费、秒出。想要更有设计感可以改用 AI 生成,生图服务和模型由你自己选择,按所选服务计费。",
"查看封面": "查看封面"
}
+12
View File
@@ -25,3 +25,15 @@ test('the publish page starts from the output kit: copy, hashtags and the matchi
assert.match(publish,/setDescription\(\[variant\.post\.description, variant\.post\.tags\.map/)
assert.match(publish,/coverApi\.get\(projectId, clipId, coverSlot\)/)
})
test('the kit shows the whole cover, which the 16:9 video thumbnail crops',()=>{
assert.match(kit,/className=\{`studio-post-cover/)
assert.match(kit,/<img src=\{coverUrl\}/)
assert.match(card,/coverStamp=\{coverStamp\}/)
})
test('automatic projects list their outputs only: raw clips have no video file to preview or download',()=>{
const results=read('src/features/studio/StudioResults.tsx')
assert.match(results,/\{loaded && !automatic && children\}/)
assert.match(read('src/features/studio/useWorkspace.ts'),/setLoaded\(true\)/)
})