From 833eb009191055a975fdb2f0149fbc7027c357b3 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Fri, 30 Oct 2026 13:51:28 +0700 Subject: [PATCH] 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 --- src/express/results.test.ts | 18 +++++++++++++++++- src/express/results.ts | 26 ++++++++++++++++++++------ src/i18n.ts | 24 ++++++++++++++++++++++++ src/pages/Results.tsx | 26 ++++++++++++++++++++++++-- src/pages/Schedule.tsx | 3 ++- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/express/results.test.ts b/src/express/results.test.ts index 919654f..998b545 100644 --- a/src/express/results.test.ts +++ b/src/express/results.test.ts @@ -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') + }) +}) diff --git a/src/express/results.ts b/src/express/results.ts index 06a9358..28c5f6b 100644 --- a/src/express/results.ts +++ b/src/express/results.ts @@ -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) => + !['ready', 'accepted', 'rejected', 'failed', 'cancelled'].includes(d.status) && !!d.latest_error?.endsWith('_outcome_unknown') + +export function videoStatus(d: Pick): 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 // 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(`/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) diff --git a/src/i18n.ts b/src/i18n.ts index f7fe336..e432ab1 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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 = { '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 = { '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.', diff --git a/src/pages/Results.tsx b/src/pages/Results.tsx index f72c025..f3be1f1 100644 --- a/src/pages/Results.tsx +++ b/src/pages/Results.tsx @@ -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
{kindLabel(m.kind)}
- + {/* A video opens while it is made too: its progress and Cancel are there. */} +

{t(m.sources.length === 1 ? 'res.sourceOne' : 'res.sourceCount', { n: m.sources.length })} · {fmt(m.createdAt)}

{describeRule(m.rule)} {working ? {m.video ? t('res.videoProgress', { n: m.video.progress }) : t('res.working')} + : m.video?.stuck ? {t('res.videoStuckShort')} : m.retry &&
}
@@ -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 ( + + ) +} + // 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:
{decide.error ? {decide.error.message} : {t('res.footNote')}} {m.composeSetId && {t('res.openStudio')}} + {canCancelVideo(m) && } {m.retry && } {content.data?.content && } {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

{title}

+ if (m.video?.stuck) return {t('res.videoStuckCopy')} if (m.status === 'failed') return {t('res.videoFailedCopy')} return {t('res.videoProgress', { n: m.video?.progress ?? 0 })} } diff --git a/src/pages/Schedule.tsx b/src/pages/Schedule.tsx index a8384b2..12e55cb 100644 --- a/src/pages/Schedule.tsx +++ b/src/pages/Schedule.tsx @@ -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',