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',