@@ -12,9 +12,11 @@ import {
1212 addAthlete ,
1313 addVideo ,
1414 analyzeProject ,
15+ cancelJob ,
1516 clipsFromMoments ,
1617 createProject ,
1718 createReel ,
19+ getJob ,
1820 isReelEelError ,
1921 listExports ,
2022 listJobLogsSince ,
@@ -25,6 +27,7 @@ import {
2527 projectDir ,
2628 removeAthlete ,
2729 removeExport ,
30+ removeJob ,
2831 removeProject ,
2932 removeVideo ,
3033 renderReel ,
@@ -209,6 +212,37 @@ const uploadFailed = (
209212 return back ( c , to , undefined , `${ described . error } ${ hint } ${ suffix } ` ) ;
210213} ;
211214
215+ /**
216+ * Analyses running in this process, by job id.
217+ *
218+ * Cancelling has to reach the work, not just relabel the row: `cancelJob`
219+ * alone would mark a job canceled while FFmpeg and the detector carried on
220+ * chewing through the video. The signal is what makes Stop mean stop.
221+ */
222+ const runningAnalyses = new Map < string , AbortController > ( ) ;
223+
224+ /** Kicks off an analysis and keeps hold of its cancel handle. */
225+ const startAnalysis = ( root : string , options : { preset : Preset ; videoId ?: string } ) : void => {
226+ const controller = new AbortController ( ) ;
227+ let jobId : string | null = null ;
228+
229+ void analyzeProject ( root , {
230+ preset : options . preset ,
231+ ...( options . videoId === undefined ? { } : { videoId : options . videoId } ) ,
232+ signal : controller . signal ,
233+ onStart : ( job ) => {
234+ jobId = job . id ;
235+ runningAnalyses . set ( job . id , controller ) ;
236+ } ,
237+ } )
238+ . catch ( ( error : unknown ) => {
239+ process . stderr . write ( `analysis failed: ${ failed ( error ) } \n` ) ;
240+ } )
241+ . finally ( ( ) => {
242+ if ( jobId !== null ) runningAnalyses . delete ( jobId ) ;
243+ } ) ;
244+ } ;
245+
212246export const registerActions = ( app : Hono ) : void => {
213247 const guard = async ( c : Context ) : Promise < Response | null > =>
214248 originAllowed ( c ) ? null : c . text ( 'Bad origin' , 403 ) ;
@@ -384,6 +418,78 @@ export const registerActions = (app: Hono): void => {
384418 return c . json ( { ok : true , upload : view ( record ) } ) ;
385419 } ) ;
386420
421+ // ── Job controls: stop, replay, discard ───────────────────────────────────
422+ //
423+ // The pipeline could already cancel and re-run; none of it was reachable from
424+ // the browser, so a run that went wrong could only be waited out.
425+
426+ /** Stop. Aborts the actual work, then records the cancellation. */
427+ app . post ( '/projects/:ref/jobs/:id/cancel' , async ( c ) => {
428+ const bad = await guard ( c ) ;
429+ if ( bad !== null ) return bad ;
430+ const ref = c . req . param ( 'ref' ) ?? '' ;
431+ const to = `/projects/${ encodeURIComponent ( ref ) } ` ;
432+
433+ try {
434+ const root = await rootOf ( c ) ;
435+ const id = c . req . param ( 'id' ) ?? '' ;
436+ // Abort first: the job row is the record, the signal is the mechanism.
437+ runningAnalyses . get ( id ) ?. abort ( ) ;
438+ runningAnalyses . delete ( id ) ;
439+ await cancelJob ( root , id ) ;
440+ return back ( c , to , 'Analysis canceled' ) ;
441+ } catch ( error ) {
442+ return back ( c , to , undefined , failed ( error ) ) ;
443+ }
444+ } ) ;
445+
446+ /**
447+ * Replay. Re-runs with the settings the original used, rather than whatever
448+ * the form happens to show now — the point of replaying a specific run.
449+ */
450+ app . post ( '/projects/:ref/jobs/:id/retry' , async ( c ) => {
451+ const bad = await guard ( c ) ;
452+ if ( bad !== null ) return bad ;
453+ const ref = c . req . param ( 'ref' ) ?? '' ;
454+ const to = `/projects/${ encodeURIComponent ( ref ) } ` ;
455+
456+ try {
457+ const root = await rootOf ( c ) ;
458+ const job = await getJob ( root , c . req . param ( 'id' ) ?? '' ) ;
459+ if ( job . status === 'running' || job . status === 'queued' ) {
460+ return back ( c , to , undefined , 'That analysis is still running.' ) ;
461+ }
462+
463+ const params = job . params as { preset ?: string ; videoIds ?: unknown } ;
464+ const preset = ( typeof params . preset === 'string' ? params . preset : 'balanced' ) as Preset ;
465+ const videoIds = Array . isArray ( params . videoIds ) ? params . videoIds : [ ] ;
466+ // One video means it was a single-video run; several means "all", and
467+ // analyzeProject reads that as "no filter".
468+ const videoId = videoIds . length === 1 && typeof videoIds [ 0 ] === 'string' ? videoIds [ 0 ] : undefined ;
469+
470+ startAnalysis ( root , { preset, ...( videoId === undefined ? { } : { videoId } ) } ) ;
471+ return back ( c , to , 'Analysis restarted — watch the live log' ) ;
472+ } catch ( error ) {
473+ return back ( c , to , undefined , failed ( error ) ) ;
474+ }
475+ } ) ;
476+
477+ /** Discard a finished run from the history. */
478+ app . post ( '/projects/:ref/jobs/:id/delete' , async ( c ) => {
479+ const bad = await guard ( c ) ;
480+ if ( bad !== null ) return bad ;
481+ const ref = c . req . param ( 'ref' ) ?? '' ;
482+ const to = `/projects/${ encodeURIComponent ( ref ) } ` ;
483+
484+ try {
485+ const root = await rootOf ( c ) ;
486+ await removeJob ( root , c . req . param ( 'id' ) ?? '' ) ;
487+ return back ( c , to , 'Removed from history' ) ;
488+ } catch ( error ) {
489+ return back ( c , to , undefined , failed ( error ) ) ;
490+ }
491+ } ) ;
492+
387493 /**
388494 * Downloads a rendered reel.
389495 *
@@ -791,11 +897,7 @@ export const registerActions = (app: Hono): void => {
791897
792898 // Analysis takes minutes; holding the request open would time out at the
793899 // proxy. It records a job, so the page can report progress instead.
794- void analyzeProject ( root , { preset, ...( videoId === undefined ? { } : { videoId } ) } ) . catch (
795- ( error : unknown ) => {
796- process . stderr . write ( `analysis failed: ${ failed ( error ) } \n` ) ;
797- } ,
798- ) ;
900+ startAnalysis ( root , { preset, ...( videoId === undefined ? { } : { videoId } ) } ) ;
799901
800902 return back ( c , to , 'Analysis started — watch the live log' ) ;
801903 } catch ( error ) {
0 commit comments