Fail a video the engine stopped and let it be cancelled

Videos whose clip step ended without a confirmed result
(video_clip_outcome_unknown) stayed "Generating" for good: the draft
still says it is at work, the card could not be opened, and nothing
offered a way out. Such a video now shows as failed and stopped, a
video card opens while it is being made, and its preview offers Cancel
video (the engine's cancel, which settles the known credits). Once
cancelled, the newest video can be made again with Retry. Run history
explains the outcome-unknown codes instead of showing them raw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-30 13:51:28 +07:00
parent 677937eb98
commit 833eb00919
5 changed files with 87 additions and 10 deletions

View File

@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { EngineError } from '../api/client'
import { correctIndex, forgetReads, keepSettled, questionsOf, remembering, settleLimited, toMarkdown, withPlanItem, type Material } from './results'
import {
correctIndex, forgetReads, isStuckVideo, keepSettled, questionsOf, remembering, settleLimited, toMarkdown, videoStatus, withPlanItem, type Material,
} from './results'
import { sentencesOf, scriptLang } from '../components/AudioPlayer'
import type { Schedule } from './schedules'
@@ -144,3 +146,17 @@ describe('remembering an archived schedule', () => {
expect(fn).toHaveBeenCalledTimes(2)
})
})
describe('videoStatus', () => {
it('fails a video the engine stopped without a confirmed result', () => {
expect(videoStatus({ status: 'clips', latest_error: 'clip_outcome_unknown' })).toBe('failed')
expect(isStuckVideo({ status: 'clips', latest_error: 'clip_outcome_unknown' })).toBe(true)
})
it('keeps a video at work while its stages run', () => {
expect(videoStatus({ status: 'clips', latest_error: null })).toBe('generating')
})
it('does not call a cancelled video stuck', () => {
expect(isStuckVideo({ status: 'cancelled', latest_error: 'user_cancelled' })).toBe(false)
expect(videoStatus({ status: 'cancelled', latest_error: 'clip_outcome_unknown' })).toBe('failed')
})
})

View File

@@ -41,7 +41,8 @@ export interface Material {
rule: Rule
/** How to produce it again, when the engine allows another attempt: the plan item, or (video) a new run. */
retry?: { planId: number; itemId: number } | { run: true }
video?: { url: string | null; progress: number; credits: number | null }
/** stuck: the engine stopped it without knowing whether a paid step finished; only cancelling settles it. */
video?: { url: string | null; progress: number; credits: number | null; stuck: boolean }
}
// Labels are resolved at render time so they follow the UI language.
@@ -136,6 +137,7 @@ interface RawVideo {
status: string
title: string | null
progress_percentage: number
latest_error: string | null
video_url: string | null
sources: { document_id: number; title: string }[]
actual_credits: number | null
@@ -143,9 +145,15 @@ interface RawVideo {
}
// Video drafts move through script, storyboard, clips and render before they are ready to review.
function videoStatus(s: string): MaterialStatus {
// A stage whose outcome the engine could not confirm (e.g. clip_outcome_unknown) stops the run for
// good, though the draft still says it is at work: that is a failure until it is cancelled.
export const isStuckVideo = (d: Pick<RawVideo, 'status' | 'latest_error'>) =>
!['ready', 'accepted', 'rejected', 'failed', 'cancelled'].includes(d.status) && !!d.latest_error?.endsWith('_outcome_unknown')
export function videoStatus(d: Pick<RawVideo, 'status' | 'latest_error'>): MaterialStatus {
const s = d.status
if (s === 'ready' || s === 'accepted' || s === 'rejected') return s
if (s === 'failed' || s === 'cancelled') return 'failed'
if (s === 'failed' || s === 'cancelled' || isStuckVideo(d)) return 'failed'
return 'generating'
}
@@ -155,13 +163,15 @@ async function videosOf({ agent, rule, active }: Schedule): Promise<Material[]>
// newest draft only, so older failures don't each start another (paid) run.
const newest = Math.max(...drafts.map(d => d.id))
return drafts.map(d => {
const status = videoStatus(d.status)
const status = videoStatus(d)
const stuck = isStuckVideo(d)
return {
retry: status === 'failed' && active && d.id === newest ? { run: true } as const : undefined,
// A stuck video is cancelled first; a new run beside it would leave it unsettled.
retry: status === 'failed' && !stuck && active && d.id === newest ? { run: true } as const : undefined,
id: d.id, agentId: agent.id, kind: 'video', status, revision: 0,
parts: [{ id: d.id, revision: 0, kind: 'video', status }],
planTitle: d.title, composeSetId: null, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
video: { url: d.video_url, progress: d.progress_percentage, credits: d.actual_credits },
video: { url: d.video_url, progress: d.progress_percentage, credits: d.actual_credits, stuck },
}
})
}
@@ -280,6 +290,10 @@ export async function materialContent(m: Material): Promise<{ content: MaterialC
// The engine renders slide decks to PPTX itself; other kinds have no file export there.
export const exportPptx = (deckId: number) => request<Blob>(`/slide-deck/${deckId}/export`, { method: 'POST', as: 'blob' })
/** Stops a video still being made, or settles a stuck one; the engine keeps every charge it knows of. */
export const canCancelVideo = (m: Material) => m.kind === 'video' && (isWorking(m) || !!m.video?.stuck)
export const cancelVideo = (m: Material) => request(`${AGENTS}/${m.agentId}/video-drafts/${m.id}/cancel`, { method: 'POST' })
export const retryMaterial = ({ agentId, retry }: Material) =>
retry && 'run' in retry ? runNow(agentId) : retryItem(agentId, retry!.planId, retry!.itemId)

View File

@@ -270,6 +270,11 @@ const en = {
'runErr.agent_not_active': 'The schedule is paused or deleted.',
'runErr.run_access_revoked': 'You no longer have permission to run this schedule.',
'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.',
'runErr.user_cancelled': 'Cancelled.',
'runErr.video_script_outcome_unknown': 'The video stopped without a confirmed result. Cancel it on Results to make it again.',
'runErr.video_storyboard_outcome_unknown': 'The video stopped without a confirmed result. Cancel it on Results to make it again.',
'runErr.video_style_outcome_unknown': 'The video stopped without a confirmed result. Cancel it on Results to make it again.',
'runErr.video_clip_outcome_unknown': 'The video stopped without a confirmed result. Cancel it on Results to make it again.',
'sch.noNewSources': 'No new sources found. A more specific topic may help.',
'sch.blockedBy': 'Blocking material:',
'sch.blockedNone': 'All material is ready now. Try again.',
@@ -430,6 +435,13 @@ const en = {
'res.videoNotReady': 'The video is not ready yet',
'res.videoFailed': 'The video could not be made',
'res.videoFailedCopy': 'Rendering stopped before the video was finished.',
'res.videoStuck': 'The video stopped',
'res.videoStuckCopy': 'The engine could not confirm whether a step finished, so it will not continue. Cancel it to settle its credits; the schedule can then make it again.',
'res.videoStuckShort': 'Stopped: cancel to retry',
'res.cancelVideo': 'Cancel video',
'res.cancellingVideo': 'Cancelling…',
'res.cancelVideoConfirm': 'Cancel this video? Credits already spent are not refunded.',
'res.videoCancelled': 'Video cancelled',
'res.downloadVideo': 'Download video (MP4)',
'res.creditsUsed': 'Credits used',
'res.accepted': 'Approved.',
@@ -777,6 +789,11 @@ const id: Record<Key, string> = {
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.',
'runErr.user_cancelled': 'Dibatalkan.',
'runErr.video_script_outcome_unknown': 'Video berhenti tanpa hasil yang pasti. Batalkan di Hasil agar bisa dibuat ulang.',
'runErr.video_storyboard_outcome_unknown': 'Video berhenti tanpa hasil yang pasti. Batalkan di Hasil agar bisa dibuat ulang.',
'runErr.video_style_outcome_unknown': 'Video berhenti tanpa hasil yang pasti. Batalkan di Hasil agar bisa dibuat ulang.',
'runErr.video_clip_outcome_unknown': 'Video berhenti tanpa hasil yang pasti. Batalkan di Hasil agar bisa dibuat ulang.',
'sch.noNewSources': 'Tidak ada source baru yang ditemukan. Topik yang lebih spesifik bisa membantu.',
'sch.blockedBy': 'Materi yang menahan:',
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
@@ -936,6 +953,13 @@ const id: Record<Key, string> = {
'res.videoNotReady': 'Video belum siap',
'res.videoFailed': 'Video gagal dibuat',
'res.videoFailedCopy': 'Proses render berhenti sebelum video selesai.',
'res.videoStuck': 'Video berhenti',
'res.videoStuckCopy': 'Engine tidak bisa memastikan apakah satu tahap selesai, jadi proses tidak dilanjutkan. Batalkan video ini untuk menyelesaikan kreditnya; setelah itu jadwal bisa membuatnya lagi.',
'res.videoStuckShort': 'Berhenti: batalkan untuk coba lagi',
'res.cancelVideo': 'Batalkan video',
'res.cancellingVideo': 'Membatalkan…',
'res.cancelVideoConfirm': 'Batalkan video ini? Kredit yang sudah terpakai tidak dikembalikan.',
'res.videoCancelled': 'Video dibatalkan',
'res.downloadVideo': 'Unduh video (MP4)',
'res.creditsUsed': 'Kredit terpakai',
'res.accepted': 'Disetujui.',

View File

@@ -8,7 +8,7 @@ import { Empty, PageHead, SkeletonCards, SkeletonLines, SkeletonRows } from '../
import { toast } from '../components/Toast'
import { describeRule, kindLabel, type ContentKind } from '../express/parse'
import {
decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, RESULTS_POLL, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf,
canCancelVideo, cancelVideo, decideMaterial, download, exportPptx, fileName, isWorking, printPdf, listOf, listResults, materialContent, RESULTS_POLL, materialKey, questionsOf, retryMaterial, reviewSource, slidesOf, textOf,
titleOf, toMarkdown, type FoundSource, type Material, type MaterialContent, type MaterialStatus,
} from '../express/results'
import { MASTERY, scormPackage } from '../express/scorm'
@@ -132,12 +132,14 @@ export function ContentCard({ material: m, onOpen }: { material: Material; onOpe
</div>
<div className="content-card-body">
<div className="content-card-top"><span className="type-badge"><Icon name={KIND_ICON[m.kind]} />{kindLabel(m.kind)}</span><StatusBadge status={m.status} /></div>
<button className="content-title" disabled={working} onClick={onOpen}>{titleOf(m)}</button>
{/* A video opens while it is made too: its progress and Cancel are there. */}
<button className="content-title" disabled={working && !m.video} onClick={onOpen}>{titleOf(m)}</button>
<p>{t(m.sources.length === 1 ? 'res.sourceOne' : 'res.sourceCount', { n: m.sources.length })} · {fmt(m.createdAt)}</p>
<div className="content-card-foot">
<span title={describeRule(m.rule)}>{describeRule(m.rule)}</span>
{working
? <span>{m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')}</span>
: m.video?.stuck ? <span className="error">{t('res.videoStuckShort')}</span>
: m.retry && <div className="content-card-actions"><RetryButton material={m} className="text-btn" /></div>}
</div>
</div>
@@ -145,6 +147,24 @@ export function ContentCard({ material: m, onOpen }: { material: Material; onOpe
)
}
function CancelButton({ material: m, className }: { material: Material; className: string }) {
const queryClient = useQueryClient()
const cancel = useMutation({
mutationFn: () => cancelVideo(m),
onSuccess: () => {
toast(t('res.videoCancelled'))
queryClient.invalidateQueries({ queryKey: ['results'] })
},
onError: e => toast(e.message, { error: true }),
})
return (
<button className={className} disabled={cancel.isPending}
onClick={() => confirm(t('res.cancelVideoConfirm')) && cancel.mutate()}>
{cancel.isPending ? t('res.cancellingVideo') : t('res.cancelVideo')}
</button>
)
}
// A failed plan item is produced again with a fresh quote; the result then shows as preparing.
function RetryButton({ material: m, className }: { material: Material; className: string }) {
const queryClient = useQueryClient()
@@ -214,6 +234,7 @@ export function Preview({ material: m, onClose }: { material: Material; onClose:
<div className="modal-foot">
{decide.error ? <small className="error">{decide.error.message}</small> : <small>{t('res.footNote')}</small>}
{m.composeSetId && <a className="btn" href={`${STUDIO_URL}/compose/${m.composeSetId}`} target="_blank" rel="noreferrer">{t('res.openStudio')}</a>}
{canCancelVideo(m) && <CancelButton material={m} className="btn danger" />}
{m.retry && <RetryButton material={m} className="btn primary" />}
{content.data?.content && <DownloadMenu material={m} content={content.data.content} outputId={content.data.outputId} />}
{m.status === 'ready' && (
@@ -349,6 +370,7 @@ function Body({ material: m, content }: { material: Material; content: MaterialC
const title = textOf(content.title) || titleOf(m)
if (m.kind === 'video') {
if (m.video?.url) return <article className="prose"><h1>{title}</h1><video className="source-player" controls preload="metadata" src={m.video.url} /></article>
if (m.video?.stuck) return <Empty icon="video" title={t('res.videoStuck')}>{t('res.videoStuckCopy')}</Empty>
if (m.status === 'failed') return <Empty icon="video" title={t('res.videoFailed')}>{t('res.videoFailedCopy')}</Empty>
return <Empty icon="video" title={t('res.videoNotReady')}>{t('res.videoProgress', { n: m.video?.progress ?? 0 })}</Empty>
}

View File

@@ -22,7 +22,8 @@ const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s
const RUN_ERRORS = new Set([
'knowledge_not_ready', 'video_sources_unavailable', 'material_sources_unavailable', 'knowledge_search_failed',
'organization_wallet', 'run_cap', 'monthly_cap', 'reservation_cap', 'price_changed', 'video_price_changed',
'agent_not_active', 'run_access_revoked', 'required_tool_unavailable',
'agent_not_active', 'run_access_revoked', 'required_tool_unavailable', 'user_cancelled',
'video_script_outcome_unknown', 'video_storyboard_outcome_unknown', 'video_style_outcome_unknown', 'video_clip_outcome_unknown',
])
const ISSUE_REASONS = new Set([
'document_processing', 'document_failed', 'graph_building', 'graph_not_ready', 'legacy_set_needs_conversion',