Parcourir la source

Add cancellable FFmpeg jobs and macOS accel

Extend the AGI ffmpeg API with cancellable conversions, including optional image-conversion progress files, a shared job registry, cleanup of partial outputs, and tests for cancellation/probe logic. Update FFmpeg Factory to support multi-file uploads, queued/running/completed jobs, cancellation, and the refreshed conversion UI.

Also add a macOS VideoToolbox hardware-encoder profile with probe-size support, document the new ffmpeg APIs, and surface video play() failures in the Movie player instead of leaving unhandled promise rejections.
Toby Chui il y a 1 semaine
Parent
commit
54eb12cc4a

+ 23 - 2
src/mod/agi/README.md

@@ -1485,9 +1485,12 @@ ffmpeg.convert("user:/in.mov", "user:/out.mp4", 0);
 ffmpeg.audioConvert("user:/in.wav", "user:/out.mp3", 44100, "tmp:/audio_progress.json");
 ```
 
-### `ffmpeg.imageConvert(input, output, scaleFactor, compressionRate)`
+### `ffmpeg.imageConvert(input, output, scaleFactor, compressionRate, progressFile)`
+`progressFile` is optional. Image conversions have no timeline, so the file only
+reports 0 % and, on success, 100 % — but passing it makes the job cancellable.
+
 ```javascript
-ffmpeg.imageConvert("user:/in.png", "user:/out.jpg", 0.5, 80);
+ffmpeg.imageConvert("user:/in.png", "user:/out.jpg", 0.5, 80, "tmp:/image_progress.json");
 ```
 
 ### `ffmpeg.videoConvert(input, output, resolution, compressionRate, progressFile)`
@@ -1500,6 +1503,24 @@ ffmpeg.videoConvert("user:/in.mp4", "user:/out.mp4", "720p", 55, "tmp:/video_pro
 ffmpeg.convertWithProgress("user:/in.mp4", "user:/out.gif", "tmp:/conv_progress.json");
 ```
 
+### `ffmpeg.cancel(progressFile)`
+Stops a conversion that is still running, identified by the progress file it was
+started with. Returns `true` when a running conversion was found and terminated,
+`false` when it already finished or was started without a progress file. The
+conversion call itself then returns `false` like any other failed conversion, so
+the caller decides how a cancelled job is recorded.
+
+Because the conversion runs in its own request, the cancel call is made from a
+*separate* script execution while the conversion request is still open.
+
+```javascript
+// in the request that starts the job
+ffmpeg.videoConvert("user:/in.mp4", "user:/out.mkv", "", 38, "tmp:/job42.progress.json");
+
+// in a later request, to stop it
+var stopped = ffmpeg.cancel("tmp:/job42.progress.json");
+```
+
 ## websocket API
 
 The websocket library upgrades the current HTTP connection to a WebSocket session.

+ 47 - 1
src/mod/agi/agi.ffmpeg.go

@@ -208,6 +208,8 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 		os.Remove(bufferedFilepath)
 		if err != nil {
 			g.RaiseError(err)
+			//Remove the partial output left behind by a failed or cancelled conversion
+			os.Remove(outputBufferPath)
 			return otto.FalseValue()
 		}
 		if !utils.FileExists(outputBufferPath) {
@@ -255,6 +257,10 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 		if err != nil || call.Argument(3).IsUndefined() {
 			compressionRate = 0
 		}
+		vprogressFile := ""
+		if !call.Argument(4).IsUndefined() {
+			vprogressFile, _ = call.Argument(4).ToString()
+		}
 
 		vinput = static.RelativeVpathRewrite(scriptFsh, vinput, vm, u)
 		voutput = static.RelativeVpathRewrite(scriptFsh, voutput, vm, u)
@@ -270,6 +276,14 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 			return otto.FalseValue()
 		}
 
+		rprogressFile := ""
+		if vprogressFile != "" && vprogressFile != "undefined" {
+			vprogressFile = static.RelativeVpathRewrite(scriptFsh, vprogressFile, vm, u)
+			if _, rp, e := static.VirtualPathToRealPath(vprogressFile, u); e == nil {
+				rprogressFile = rp
+			}
+		}
+
 		bufferedFilepath, err := fsh.BufferRemoteToLocal(rinput)
 		if err != nil {
 			g.RaiseError(err)
@@ -279,10 +293,12 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 		outputTmpFilename := uuid.NewV4().String() + filepath.Ext(routput)
 		outputBufferPath := filepath.Join(filepath.Dir(bufferedFilepath), outputTmpFilename)
 
-		err = ffmpegutil.FFmpeg_image_conv(bufferedFilepath, outputBufferPath, scaleFactor, int(compressionRate))
+		err = ffmpegutil.FFmpeg_image_conv(bufferedFilepath, outputBufferPath, scaleFactor, int(compressionRate), rprogressFile)
 		os.Remove(bufferedFilepath)
 		if err != nil {
 			g.RaiseError(err)
+			//Remove the partial output left behind by a failed or cancelled conversion
+			os.Remove(outputBufferPath)
 			return otto.FalseValue()
 		}
 		if !utils.FileExists(outputBufferPath) {
@@ -374,6 +390,8 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 		os.Remove(bufferedFilepath)
 		if err != nil {
 			g.RaiseError(err)
+			//Remove the partial output left behind by a failed or cancelled conversion
+			os.Remove(outputBufferPath)
 			return otto.FalseValue()
 		}
 		if !utils.FileExists(outputBufferPath) {
@@ -453,6 +471,8 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 		os.Remove(bufferedFilepath)
 		if err != nil {
 			g.RaiseError(err)
+			//Remove the partial output left behind by a failed or cancelled conversion
+			os.Remove(outputBufferPath)
 			return otto.FalseValue()
 		}
 		if !utils.FileExists(outputBufferPath) {
@@ -477,6 +497,31 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 		return otto.TrueValue()
 	})
 
+	// _ffmpeg_cancel(progressFile)
+	// Stops the conversion that was started with the given progress file, no matter
+	// which request or user script started it. Returns true if a running conversion
+	// was found and terminated, false if it already finished or never had a progress
+	// file. The cancelled conversion reports itself as a failed conversion.
+	vm.Set("_ffmpeg_cancel", func(call otto.FunctionCall) otto.Value {
+		vprogressFile, err := call.Argument(0).ToString()
+		if err != nil || vprogressFile == "" || vprogressFile == "undefined" {
+			g.RaiseError(errors.New("progress file not provided"))
+			return otto.FalseValue()
+		}
+
+		vprogressFile = static.RelativeVpathRewrite(scriptFsh, vprogressFile, vm, u)
+		_, rprogressFile, err := static.VirtualPathToRealPath(vprogressFile, u)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+
+		if !ffmpegutil.CancelConversion(rprogressFile) {
+			return otto.FalseValue()
+		}
+		return otto.TrueValue()
+	})
+
 	vm.Run(`
 		var ffmpeg = {};
 		ffmpeg.convert = _ffmpeg_conv;
@@ -484,5 +529,6 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 		ffmpeg.imageConvert = _ffmpeg_image_conv;
 		ffmpeg.videoConvert = _ffmpeg_video_conv;
 		ffmpeg.convertWithProgress = _ffmpeg_conv_with_progress;
+		ffmpeg.cancel = _ffmpeg_cancel;
 	`)
 }

+ 104 - 17
src/mod/agi/static/ffmpegutil/ffmpegutil.go

@@ -32,6 +32,12 @@ type ConversionProgress struct {
 	Completed      bool    `json:"completed"`
 }
 
+// runningConversions maps a conversion job key to the ffmpeg process currently
+// handling that job. The key is the progress file path supplied by the caller,
+// which is unique per conversion task, so another request can look up and stop
+// a running conversion without needing any extra bookkeeping.
+var runningConversions sync.Map // string -> *exec.Cmd
+
 // resolutionHeightMap maps common resolution names to their vertical pixel count.
 var resolutionHeightMap = map[string]int{
 	"144p":  144,
@@ -134,6 +140,86 @@ func isLossyImage(filename string) bool {
 	return utils.StringInArray(lossyFormats, strings.ToLower(filepath.Ext(filename)))
 }
 
+// --- Conversion job registry (used for cancelling a running conversion) ---
+
+// conversionJobKey normalises a progress file path so that the path used when a
+// conversion starts and the one used when it is cancelled always match.
+func conversionJobKey(progressFile string) string {
+	if progressFile == "" {
+		return ""
+	}
+	return filepath.ToSlash(filepath.Clean(progressFile))
+}
+
+// registerConversion records the ffmpeg process running the job identified by
+// progressFile. A empty progressFile means the job is not cancellable.
+func registerConversion(progressFile string, cmd *exec.Cmd) {
+	key := conversionJobKey(progressFile)
+	if key == "" || cmd == nil {
+		return
+	}
+	runningConversions.Store(key, cmd)
+}
+
+// unregisterConversion removes a finished job from the registry.
+func unregisterConversion(progressFile string) {
+	key := conversionJobKey(progressFile)
+	if key == "" {
+		return
+	}
+	runningConversions.Delete(key)
+}
+
+// ConversionIsRunning reports whether a cancellable conversion is currently
+// registered under the given progress file path.
+func ConversionIsRunning(progressFile string) bool {
+	key := conversionJobKey(progressFile)
+	if key == "" {
+		return false
+	}
+	_, ok := runningConversions.Load(key)
+	return ok
+}
+
+// CancelConversion terminates the ffmpeg process of the conversion registered
+// under the given progress file path. It returns false when no such conversion
+// is running (already finished, never started, or started without a progress
+// file). The conversion function itself reports the killed process as a normal
+// conversion failure, so the caller decides how a cancelled job is presented.
+func CancelConversion(progressFile string) bool {
+	key := conversionJobKey(progressFile)
+	if key == "" {
+		return false
+	}
+	value, ok := runningConversions.Load(key)
+	if !ok {
+		return false
+	}
+	cmd, ok := value.(*exec.Cmd)
+	if !ok || cmd == nil || cmd.Process == nil {
+		return false
+	}
+	if err := cmd.Process.Kill(); err != nil {
+		return false
+	}
+	return true
+}
+
+// runFFmpeg runs ffmpeg with the given arguments, registering the process under
+// cancelKey (when not empty) so that CancelConversion can terminate it while it
+// is still running.
+func runFFmpeg(args []string, cancelKey string) error {
+	cmd := exec.Command("ffmpeg", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if err := cmd.Start(); err != nil {
+		return err
+	}
+	registerConversion(cancelKey, cmd)
+	defer unregisterConversion(cancelKey)
+	return cmd.Wait()
+}
+
 // --- Progress helpers ---
 
 // fileSize returns the byte size of path, or 0 if the file is not accessible.
@@ -277,10 +363,7 @@ func FFmpeg_audio_conv(input, output string, sampleRate int, progressFile string
 	}
 
 	args = append(args, output)
-	cmd := exec.Command("ffmpeg", args...)
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	err := cmd.Run()
+	err := runFFmpeg(args, progressFile)
 
 	if progressFile != "" {
 		stopProgressMonitor(doneCh, wg, ffmpegPipeFile, progressFile, output, inputSize, startTime, err)
@@ -297,7 +380,13 @@ func FFmpeg_audio_conv(input, output string, sampleRate int, progressFile string
 //     0 or 1.0 leaves the size unchanged.
 //   - compressionRate – 0-100 quality-loss percentage; only applied to lossy formats
 //     (JPEG, WebP); silently ignored for lossless formats (PNG, BMP, GIF, TIFF).
-func FFmpeg_image_conv(input, output string, scaleFactor float64, compressionRate int) error {
+//   - progressFile   – real filesystem path used as the cancellation key and to write
+//     the start/finish JSON progress entries; "" disables both. Image conversions have
+//     no timeline, so the progress file only reports 0 % and, on success, 100 %.
+func FFmpeg_image_conv(input, output string, scaleFactor float64, compressionRate int, progressFile string) error {
+	startTime := time.Now()
+	inputSize := fileSize(input)
+
 	args := []string{"-i", input, "-y"}
 
 	if scaleFactor > 0 && scaleFactor != 1.0 {
@@ -322,12 +411,16 @@ func FFmpeg_image_conv(input, output string, scaleFactor float64, compressionRat
 	}
 
 	args = append(args, output)
-	cmd := exec.Command("ffmpeg", args...)
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	if err := cmd.Run(); err != nil {
+	if progressFile != "" {
+		writeProgressJSON(progressFile, inputSize, output, startTime, 0.0, false)
+	}
+	err := runFFmpeg(args, progressFile)
+	if err != nil {
 		return fmt.Errorf("ffmpeg image conversion failed: %v", err)
 	}
+	if progressFile != "" {
+		writeProgressJSON(progressFile, inputSize, output, startTime, 100.0, true)
+	}
 	return nil
 }
 
@@ -373,10 +466,7 @@ func FFmpeg_video_conv(input, output, resolution string, compressionRate int, pr
 	}
 
 	args = append(args, output)
-	cmd := exec.Command("ffmpeg", args...)
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	err := cmd.Run()
+	err := runFFmpeg(args, progressFile)
 
 	if progressFile != "" {
 		stopProgressMonitor(doneCh, wg, ffmpegPipeFile, progressFile, output, inputSize, startTime, err)
@@ -412,10 +502,7 @@ func FFmpeg_conv_with_progress(input, output, progressFile string) error {
 	}
 
 	args = append(args, output)
-	cmd := exec.Command("ffmpeg", args...)
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	err := cmd.Run()
+	err := runFFmpeg(args, progressFile)
 
 	if progressFile != "" {
 		stopProgressMonitor(doneCh, wg, ffmpegPipeFile, progressFile, output, inputSize, startTime, err)

+ 168 - 0
src/mod/agi/static/ffmpegutil/ffmpegutil_test.go

@@ -0,0 +1,168 @@
+package ffmpegutil
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+/*
+	Tests for the conversion job registry that backs CancelConversion.
+
+	These tests never invoke ffmpeg itself; they spawn the test binary as a
+	helper child process so the registry can be exercised on every platform
+	the project builds for.
+*/
+
+// TestMain lets the test binary double as the long-running helper process.
+func TestMain(m *testing.M) {
+	if os.Getenv("FFMPEGUTIL_HELPER_SLEEP") == "1" {
+		time.Sleep(30 * time.Second)
+		os.Exit(0)
+	}
+	os.Exit(m.Run())
+}
+
+// startHelperProcess launches a child process that stays alive until killed.
+func startHelperProcess(t *testing.T) *exec.Cmd {
+	t.Helper()
+	cmd := exec.Command(os.Args[0], "-test.run=TestNothingToRun")
+	cmd.Env = append(os.Environ(), "FFMPEGUTIL_HELPER_SLEEP=1")
+	if err := cmd.Start(); err != nil {
+		t.Fatalf("unable to start helper process: %v", err)
+	}
+	return cmd
+}
+
+func TestConversionJobKey(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  string
+	}{
+		{"empty stays empty", "", ""},
+		{"already clean", "/tmp/aroz/task.progress.json", "/tmp/aroz/task.progress.json"},
+		{"redundant separators", "/tmp//aroz/./task.progress.json", "/tmp/aroz/task.progress.json"},
+		{"parent traversal resolved", "/tmp/aroz/sub/../task.progress.json", "/tmp/aroz/task.progress.json"},
+		{"relative path", "aroz/task.progress.json", "aroz/task.progress.json"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := conversionJobKey(tt.input); got != tt.want {
+				t.Errorf("conversionJobKey(%q) = %q, want %q", tt.input, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestConversionJobKeyMatchesNativeSeparator(t *testing.T) {
+	// A path built with the platform separator must produce the same key as the
+	// slash-separated form, so a job started on Windows can still be cancelled.
+	native := filepath.Join("tmp", "ffmpeg_factory", "abc.progress.json")
+	want := "tmp/ffmpeg_factory/abc.progress.json"
+	if got := conversionJobKey(native); got != want {
+		t.Errorf("conversionJobKey(%q) = %q, want %q", native, got, want)
+	}
+}
+
+func TestCancelConversionUnknownJob(t *testing.T) {
+	tests := []struct {
+		name        string
+		progressFil string
+	}{
+		{"empty key", ""},
+		{"never registered", "/tmp/ffmpeg_factory/not-a-real-task.progress.json"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if CancelConversion(tt.progressFil) {
+				t.Errorf("CancelConversion(%q) = true, want false", tt.progressFil)
+			}
+			if ConversionIsRunning(tt.progressFil) {
+				t.Errorf("ConversionIsRunning(%q) = true, want false", tt.progressFil)
+			}
+		})
+	}
+}
+
+func TestRegisterAndUnregisterConversion(t *testing.T) {
+	key := filepath.Join(t.TempDir(), "task.progress.json")
+	cmd := startHelperProcess(t)
+	defer func() {
+		cmd.Process.Kill() //nolint:errcheck
+		cmd.Wait()         //nolint:errcheck
+	}()
+
+	if ConversionIsRunning(key) {
+		t.Fatalf("job reported as running before it was registered")
+	}
+
+	registerConversion(key, cmd)
+	if !ConversionIsRunning(key) {
+		t.Errorf("ConversionIsRunning(%q) = false after registering, want true", key)
+	}
+
+	// The same path in a non-normalised form must resolve to the same job
+	messy := filepath.Join(filepath.Dir(key), "sub", "..", "task.progress.json")
+	if !ConversionIsRunning(messy) {
+		t.Errorf("ConversionIsRunning(%q) = false, want true (key normalisation failed)", messy)
+	}
+
+	unregisterConversion(key)
+	if ConversionIsRunning(key) {
+		t.Errorf("ConversionIsRunning(%q) = true after unregistering, want false", key)
+	}
+}
+
+func TestRegisterConversionIgnoresIncompleteJobs(t *testing.T) {
+	tests := []struct {
+		name string
+		key  string
+		cmd  *exec.Cmd
+	}{
+		{"empty key", "", &exec.Cmd{}},
+		{"nil command", filepath.Join(t.TempDir(), "nil.progress.json"), nil},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			registerConversion(tt.key, tt.cmd)
+			if ConversionIsRunning(tt.key) {
+				t.Errorf("ConversionIsRunning(%q) = true, want false", tt.key)
+			}
+		})
+	}
+}
+
+func TestCancelConversionKillsRunningJob(t *testing.T) {
+	key := filepath.Join(t.TempDir(), "running.progress.json")
+	cmd := startHelperProcess(t)
+
+	waitErr := make(chan error, 1)
+	registerConversion(key, cmd)
+	go func() { waitErr <- cmd.Wait() }()
+
+	if !CancelConversion(key) {
+		t.Fatalf("CancelConversion(%q) = false, want true", key)
+	}
+
+	select {
+	case err := <-waitErr:
+		if err == nil {
+			t.Errorf("cancelled process exited without error, want a kill error")
+		}
+	case <-time.After(5 * time.Second):
+		t.Fatalf("cancelled process did not exit within 5s")
+	}
+
+	unregisterConversion(key)
+
+	// A second cancel of the same job must report that nothing was running
+	if CancelConversion(key) {
+		t.Errorf("CancelConversion(%q) = true on an already cancelled job, want false", key)
+	}
+}

+ 27 - 13
src/mod/media/transcoder/hwaccel.go

@@ -3,11 +3,11 @@ package transcoder
 /*
 	hwaccel.go
 
-	Detects and caches a working Intel/AMD hardware H.264 encoder so
-	TranscodeAndStream can offload encoding from the CPU when the host
-	supports it, falling back to the existing libx264 software path
-	otherwise. Platform-specific profiles (which encoder, which ffmpeg
-	args) live in hwaccel_linux.go / hwaccel_windows.go / hwaccel_other.go.
+	Detects and caches a working hardware H.264 encoder so TranscodeAndStream
+	can offload encoding from the CPU when the host supports it, falling back
+	to the existing libx264 software path otherwise. Platform-specific profiles
+	(which encoder, which ffmpeg args) live in hwaccel_linux.go /
+	hwaccel_windows.go / hwaccel_darwin.go / hwaccel_other.go.
 */
 
 import (
@@ -27,8 +27,15 @@ type hwEncoderProfile struct {
 	PreInput    []string                   // extra global args inserted before -i (device init, etc.)
 	ScaleFilter func(height string) string // -vf value for the given target height ("" height = no scaling)
 	EncodeArgs  []string                   // encoder-specific args replacing "-preset superfast"
+	ProbeSize   string                     // WxH test frame size for the probe encode; "" uses defaultProbeSize
 }
 
+// defaultProbeSize is the test frame size used by testHWEncoder when a profile
+// does not ask for a larger one. 320x240 rather than something tiny because
+// some encoders (observed with h264_nvenc) reject smaller frames as below their
+// minimum encode dimensions.
+const defaultProbeSize = "320x240"
+
 var (
 	hwProfileOnce sync.Once
 	hwProfile     *hwEncoderProfile // nil if no usable hardware encoder was found
@@ -73,25 +80,32 @@ func probeHWEncoders() *hwEncoderProfile {
 	return nil
 }
 
-// testHWEncoder runs a tiny, throwaway encode against the given profile to
-// confirm ffmpeg has a working hardware path on this host.
-func testHWEncoder(profile *hwEncoderProfile) bool {
+// probeArgs builds the ffmpeg argument list for a profile's throwaway probe
+// encode: a single synthetic frame pushed through the real encoder settings
+// and discarded.
+func probeArgs(profile *hwEncoderProfile) []string {
+	size := profile.ProbeSize
+	if size == "" {
+		size = defaultProbeSize
+	}
 	args := append([]string{}, profile.PreInput...)
 	args = append(args,
 		"-hide_banner", "-loglevel", "error",
-		// 320x240: some encoders (observed with h264_nvenc) reject much
-		// smaller test frames as below their minimum encode dimensions.
-		"-f", "lavfi", "-i", "color=c=black:s=320x240:d=0.1",
+		"-f", "lavfi", "-i", "color=c=black:s="+size+":d=0.1",
 	)
 	if vf := profile.ScaleFilter(""); vf != "" {
 		args = append(args, "-vf", vf)
 	}
 	args = append(args, "-frames:v", "1", "-vcodec", profile.Codec)
 	args = append(args, profile.EncodeArgs...)
-	args = append(args, "-f", "null", "-")
+	return append(args, "-f", "null", "-")
+}
 
+// testHWEncoder runs a tiny, throwaway encode against the given profile to
+// confirm ffmpeg has a working hardware path on this host.
+func testHWEncoder(profile *hwEncoderProfile) bool {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
-	cmd := exec.CommandContext(ctx, "ffmpeg", args...)
+	cmd := exec.CommandContext(ctx, "ffmpeg", probeArgs(profile)...)
 	return cmd.Run() == nil
 }

+ 38 - 0
src/mod/media/transcoder/hwaccel_darwin.go

@@ -0,0 +1,38 @@
+//go:build darwin
+// +build darwin
+
+package transcoder
+
+/*
+	hwaccel_darwin.go
+
+	On macOS, hardware H.264 encoding is provided by the OS-level VideoToolbox
+	framework (h264_videotoolbox), which is backed by the Apple Silicon media
+	engine or the Intel/AMD GPU on older Macs alike - so a single profile covers
+	every supported Mac. Like NVENC/QSV/AMF it accepts plain system-memory
+	frames, so no explicit hardware device setup is needed (unlike VAAPI on
+	Linux).
+
+	-allow_sw defaults to false in ffmpeg, meaning the encoder refuses to open
+	when no hardware encode path exists; that keeps testHWEncoder (hwaccel.go)
+	honest and lets such hosts fall back to software (libx264) transcoding.
+*/
+
+func candidateHWProfiles() []*hwEncoderProfile {
+	return []*hwEncoderProfile{
+		{
+			Name:        "Apple VideoToolbox",
+			Codec:       "h264_videotoolbox",
+			ScaleFilter: nv12ScaleFilter,
+			// VideoToolbox has no -preset; -realtime hints the encoder to
+			// prioritise keeping up with playback over compression efficiency,
+			// which is what this live-streaming transcode path wants.
+			EncodeArgs: []string{"-realtime", "1"},
+			// VideoToolbox refuses to open a compression session for very small
+			// frames (measured: 512x384 fails, 640x360 succeeds), so the shared
+			// 320x240 probe frame would report a false negative on a Mac that
+			// does support hardware encoding.
+			ProbeSize: "640x480",
+		},
+	}
+}

+ 5 - 5
src/mod/media/transcoder/hwaccel_other.go

@@ -1,14 +1,14 @@
-//go:build !linux && !windows
-// +build !linux,!windows
+//go:build !linux && !windows && !darwin
+// +build !linux,!windows,!darwin
 
 package transcoder
 
 /*
 	hwaccel_other.go
 
-	No Intel/AMD hardware encode path is implemented yet for platforms other
-	than Linux and Windows (e.g. macOS, BSD). These hosts always fall back to
-	software (libx264) transcoding.
+	No hardware encode path is implemented yet for platforms other than Linux,
+	Windows and macOS (e.g. BSD). These hosts always fall back to software
+	(libx264) transcoding.
 */
 
 func candidateHWProfiles() []*hwEncoderProfile {

+ 102 - 1
src/mod/media/transcoder/hwaccel_test.go

@@ -1,6 +1,10 @@
 package transcoder
 
-import "testing"
+import (
+	"regexp"
+	"strings"
+	"testing"
+)
 
 // TestNV12ScaleFilter verifies the shared scale/format filter chain used by
 // the hardware encoders that accept plain system-memory frames.
@@ -44,6 +48,103 @@ func TestCandidateHWProfiles(t *testing.T) {
 				t.Errorf("profile %q ScaleFilter(%q) returned empty string", profile.Name, height)
 			}
 		}
+		if profile.ProbeSize != "" && !regexp.MustCompile(`^\d+x\d+$`).MatchString(profile.ProbeSize) {
+			t.Errorf("profile %q has malformed ProbeSize %q, want WxH", profile.Name, profile.ProbeSize)
+		}
+	}
+}
+
+// TestProbeArgs verifies the probe command line: profile args are placed in the
+// order ffmpeg expects, and ProbeSize overrides the default test frame size
+// (VideoToolbox needs a larger frame than the 320x240 default to open at all).
+func TestProbeArgs(t *testing.T) {
+	cases := []struct {
+		name        string
+		profile     *hwEncoderProfile
+		wantSize    string
+		wantOrdered []string // args that must appear in this relative order
+		wantAbsent  []string
+	}{
+		{
+			name: "default probe size and no pre-input",
+			profile: &hwEncoderProfile{
+				Name:        "system memory encoder",
+				Codec:       "h264_fake",
+				ScaleFilter: nv12ScaleFilter,
+				EncodeArgs:  []string{"-preset", "fast"},
+			},
+			wantSize:    defaultProbeSize,
+			wantOrdered: []string{"-i", "-vf", "format=nv12", "-vcodec", "h264_fake", "-preset", "fast", "-f", "null", "-"},
+		},
+		{
+			name: "explicit probe size overrides the default",
+			profile: &hwEncoderProfile{
+				Name:        "videotoolbox-like",
+				Codec:       "h264_videotoolbox",
+				ScaleFilter: nv12ScaleFilter,
+				EncodeArgs:  []string{"-realtime", "1"},
+				ProbeSize:   "640x480",
+			},
+			wantSize:    "640x480",
+			wantOrdered: []string{"-i", "-vcodec", "h264_videotoolbox", "-realtime", "1"},
+			wantAbsent:  []string{"color=c=black:s=" + defaultProbeSize + ":d=0.1"},
+		},
+		{
+			name: "pre-input args come before the input",
+			profile: &hwEncoderProfile{
+				Name:        "vaapi-like",
+				Codec:       "h264_vaapi",
+				PreInput:    []string{"-vaapi_device", "/dev/dri/renderD128"},
+				ScaleFilter: func(string) string { return "format=nv12,hwupload" },
+			},
+			wantSize:    defaultProbeSize,
+			wantOrdered: []string{"-vaapi_device", "/dev/dri/renderD128", "-f", "lavfi", "-i"},
+		},
+		{
+			name: "empty scale filter emits no -vf",
+			profile: &hwEncoderProfile{
+				Name:        "no filter",
+				Codec:       "h264_fake",
+				ScaleFilter: func(string) string { return "" },
+			},
+			wantSize:   defaultProbeSize,
+			wantAbsent: []string{"-vf"},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			args := probeArgs(tc.profile)
+			joined := strings.Join(args, " ")
+
+			wantInput := "color=c=black:s=" + tc.wantSize + ":d=0.1"
+			if !strings.Contains(joined, wantInput) {
+				t.Errorf("probeArgs() = %v, want it to contain input %q", args, wantInput)
+			}
+
+			at := 0
+			for _, want := range tc.wantOrdered {
+				found := -1
+				for i := at; i < len(args); i++ {
+					if args[i] == want {
+						found = i
+						break
+					}
+				}
+				if found == -1 {
+					t.Fatalf("probeArgs() = %v, missing %q at or after index %d", args, want, at)
+				}
+				at = found + 1
+			}
+
+			for _, absent := range tc.wantAbsent {
+				for _, arg := range args {
+					if arg == absent {
+						t.Errorf("probeArgs() = %v, did not expect %q", args, absent)
+					}
+				}
+			}
+		})
 	}
 }
 

+ 4 - 3
src/mod/media/transcoder/transcoder.go

@@ -30,9 +30,10 @@ const (
 
 // Transcode and stream the given file. Make sure ffmpeg is installed before calling to transcoder.
 // startTime is a seek offset in seconds; pass 0 to start from the beginning.
-// When the host has a usable Intel or AMD hardware encoder (probed once and
-// cached by getHWEncoderProfile), it is used in place of libx264 to keep CPU
-// load down; otherwise this falls back to the original software path.
+// When the host has a usable hardware H.264 encoder - NVENC / VAAPI / Quick
+// Sync / AMF on Linux and Windows, VideoToolbox on macOS - it is used in place
+// of libx264 to keep CPU load down (probed once and cached by
+// getHWEncoderProfile); otherwise this falls back to the original software path.
 func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string, resolution TranscodeOutputResolution, startTime float64) {
 	// Build the FFmpeg command based on the resolution parameter
 	var cmd *exec.Cmd

+ 145 - 71
src/web/FFmpeg Factory/agi/convert.agi

@@ -1,8 +1,12 @@
 /*
     FFmpeg Factory - Convert
     Runs an ffmpeg conversion and writes progress to a JSON file.
+    The same endpoint also cancels a running conversion (action=cancel), so the
+    frontend only needs the task ID it already knows to stop a job.
 
     Expected POST parameters:
+      action     - optional; "cancel" stops the running conversion of taskId.
+                   Any other value (or omitted) starts a new conversion.
       src        - virtual path of the input file
       outputExt  - desired output file extension (e.g. "mp3", "mp4")
       convType   - "audio" | "video" | "image" | "generic"
@@ -16,84 +20,154 @@
 requirelib("filelib");
 requirelib("ffmpeg");
 
-// Validate required parameters
-if (typeof src === "undefined" || typeof outputExt === "undefined" ||
-    typeof convType === "undefined" || typeof taskId === "undefined") {
-    sendJSONResp(JSON.stringify({ error: "Missing required parameters" }));
-}
-
-// Parse options
-var opts = {};
-if (typeof options !== "undefined" && options !== "" && options !== "undefined") {
-    try { opts = JSON.parse(options); } catch (e) {}
-}
-
 // Ensure task folder exists
 var taskDir = "tmp:/ffmpeg_factory";
 if (!filelib.fileExists(taskDir)) {
     filelib.mkdir(taskDir);
 }
 
-// Determine output virtual path (same directory as the source, new extension)
-var lastSlash = src.lastIndexOf("/");
-var inputDir   = src.substring(0, lastSlash);
-var inputFile  = src.substring(lastSlash + 1);
-var lastDot    = inputFile.lastIndexOf(".");
-var baseName   = (lastDot > 0) ? inputFile.substring(0, lastDot) : inputFile;
-var outputVpath = inputDir + "/" + baseName + "." + outputExt;
-
-// Progress file virtual path (written by the Go ffmpeg functions every ~500 ms)
-var progressVpath = taskDir + "/" + taskId + ".progress.json";
-
-// Write initial task file so the frontend can resume the session if the tab is closed
-var taskObj = {
-    id:           taskId,
-    input_vpath:  src,
-    output_vpath: outputVpath,
-    conv_type:    convType,
-    options:      opts,
-    status:       "running",
-    error:        "",
-    created_at:   Date.now()
-};
-filelib.writeFile(taskDir + "/" + taskId + ".task.json", JSON.stringify(taskObj));
-
-// --- Run the conversion ---
-var success = false;
-var errMsg  = "";
-
-try {
-    if (convType === "audio") {
-        var sr = (opts.sample_rate && parseInt(opts.sample_rate) > 0) ? parseInt(opts.sample_rate) : 0;
-        success = ffmpeg.audioConvert(src, outputVpath, sr, progressVpath);
-
-    } else if (convType === "video") {
-        var res  = (opts.resolution && opts.resolution !== "undefined") ? opts.resolution : "";
-        var crf  = (opts.compression) ? parseInt(opts.compression) : 0;
-        success = ffmpeg.videoConvert(src, outputVpath, res, crf, progressVpath);
-
-    } else if (convType === "image") {
-        var scale = (opts.scale) ? parseFloat(opts.scale) : 1.0;
-        var qual  = (opts.compression) ? parseInt(opts.compression) : 0;
-        success = ffmpeg.imageConvert(src, outputVpath, scale, qual);
+function taskFileOf(id)     { return taskDir + "/" + id + ".task.json"; }
+function progressFileOf(id) { return taskDir + "/" + id + ".progress.json"; }
 
-    } else {
-        // generic / cross-media (e.g. mp4 → gif)
-        success = ffmpeg.convertWithProgress(src, outputVpath, progressVpath);
+// Read a task file back into an object, or null when it is missing / unreadable
+function readTaskObject(id) {
+    var taskFile = taskFileOf(id);
+    if (!filelib.fileExists(taskFile)) {
+        return null;
+    }
+    var content = filelib.readFile(taskFile);
+    if (content === false || content === "") {
+        return null;
+    }
+    try {
+        return JSON.parse(content);
+    } catch (e) {
+        return null;
     }
-} catch (e) {
-    errMsg  = e.toString();
-    success = false;
 }
 
-// Update task file with final status
-taskObj.status = success ? "completed" : "failed";
-taskObj.error  = errMsg;
-filelib.writeFile(taskDir + "/" + taskId + ".task.json", JSON.stringify(taskObj));
-
-sendJSONResp(JSON.stringify({
-    success: success,
-    taskId:  taskId,
-    output:  outputVpath,
-    error:   errMsg
-}));
+var isCancelRequest = (typeof action !== "undefined" && action === "cancel");
+var haveTaskId      = (typeof taskId !== "undefined" && taskId !== "" && taskId !== "undefined");
+
+if (isCancelRequest) {
+    // ── Cancel an already running conversion ──
+    if (!haveTaskId) {
+        sendJSONResp(JSON.stringify({ error: "Missing taskId" }));
+    } else {
+        // Stop the ffmpeg process; false means it already finished on its own
+        var stopped = ffmpeg.cancel(progressFileOf(taskId));
+
+        // Mark the task as cancelled. The still-running convert request re-reads
+        // this file before writing its own result, so the cancelled state wins.
+        var pendingTask = readTaskObject(taskId);
+        if (pendingTask !== null) {
+            pendingTask.status = "cancelled";
+            pendingTask.error  = "Cancelled by user";
+            filelib.writeFile(taskFileOf(taskId), JSON.stringify(pendingTask));
+        }
+
+        sendJSONResp(JSON.stringify({
+            success: true,
+            taskId:  taskId,
+            stopped: stopped
+        }));
+    }
+
+} else if (typeof src === "undefined" || typeof outputExt === "undefined" ||
+           typeof convType === "undefined" || !haveTaskId) {
+    // ── Validate required parameters ──
+    sendJSONResp(JSON.stringify({ error: "Missing required parameters" }));
+
+} else {
+    // ── Start a new conversion ──
+
+    // Parse options
+    var opts = {};
+    if (typeof options !== "undefined" && options !== "" && options !== "undefined") {
+        try { opts = JSON.parse(options); } catch (e) {}
+    }
+
+    // Determine output virtual path (same directory as the source, new extension)
+    var lastSlash = src.lastIndexOf("/");
+    var inputDir   = src.substring(0, lastSlash);
+    var inputFile  = src.substring(lastSlash + 1);
+    var lastDot    = inputFile.lastIndexOf(".");
+    var baseName   = (lastDot > 0) ? inputFile.substring(0, lastDot) : inputFile;
+    var outputVpath = inputDir + "/" + baseName + "." + outputExt;
+
+    // Progress file virtual path (written by the Go ffmpeg functions every ~500 ms).
+    // It doubles as the cancellation key of this conversion.
+    var progressVpath = progressFileOf(taskId);
+
+    // Write initial task file so the frontend can resume the session if the tab is closed
+    var taskObj = {
+        id:           taskId,
+        input_vpath:  src,
+        output_vpath: outputVpath,
+        conv_type:    convType,
+        options:      opts,
+        status:       "running",
+        error:        "",
+        created_at:   Date.now()
+    };
+    filelib.writeFile(taskFileOf(taskId), JSON.stringify(taskObj));
+
+    // --- Run the conversion ---
+    var success = false;
+    var errMsg  = "";
+
+    try {
+        if (convType === "audio") {
+            var sr = (opts.sample_rate && parseInt(opts.sample_rate) > 0) ? parseInt(opts.sample_rate) : 0;
+            success = ffmpeg.audioConvert(src, outputVpath, sr, progressVpath);
+
+        } else if (convType === "video") {
+            var res  = (opts.resolution && opts.resolution !== "undefined") ? opts.resolution : "";
+            var crf  = (opts.compression) ? parseInt(opts.compression) : 0;
+            success = ffmpeg.videoConvert(src, outputVpath, res, crf, progressVpath);
+
+        } else if (convType === "image") {
+            var scale = (opts.scale) ? parseFloat(opts.scale) : 1.0;
+            var qual  = (opts.compression) ? parseInt(opts.compression) : 0;
+            success = ffmpeg.imageConvert(src, outputVpath, scale, qual, progressVpath);
+
+        } else {
+            // generic / cross-media (e.g. mp4 → gif)
+            success = ffmpeg.convertWithProgress(src, outputVpath, progressVpath);
+        }
+    } catch (e) {
+        errMsg  = e.toString();
+        success = false;
+    }
+
+    // A cancel request may have come in while ffmpeg was running. The killed
+    // process looks like a normal failure here, so check the task file first.
+    var finalStatus  = success ? "completed" : "failed";
+    var latestTask   = readTaskObject(taskId);
+    var wasCancelled = (latestTask !== null && latestTask.status === "cancelled");
+    if (wasCancelled) {
+        finalStatus = "cancelled";
+        errMsg      = "Cancelled by user";
+    }
+
+    if (filelib.fileExists(taskFileOf(taskId))) {
+        // Update task file with final status
+        taskObj.status = finalStatus;
+        taskObj.error  = errMsg;
+        filelib.writeFile(taskFileOf(taskId), JSON.stringify(taskObj));
+    } else {
+        // The task was dismissed while ffmpeg was still running: do not recreate
+        // its files, and drop the progress file the conversion just rewrote.
+        if (filelib.fileExists(progressVpath)) {
+            filelib.deleteFile(progressVpath);
+        }
+    }
+
+    sendJSONResp(JSON.stringify({
+        success:   success && !wasCancelled,
+        cancelled: wasCancelled,
+        taskId:    taskId,
+        output:    outputVpath,
+        error:     errMsg
+    }));
+}

Fichier diff supprimé car celui-ci est trop grand
+ 747 - 490
src/web/FFmpeg Factory/index.html


+ 30 - 6
src/web/Movie/embedded.html

@@ -729,6 +729,30 @@ function showToast(msg) {
     toastTimer = setTimeout(function () { $('#toast').removeClass('show'); }, 2800);
 }
 
+// play() returns a promise that rejects when the browser will not start the
+// media — most commonly NotSupportedError on Safari, which refuses a transcoded
+// stream because the endpoint cannot answer byte-range requests. Left
+// unhandled it only shows up as an unhandled rejection in the console, so the
+// player just sits there with no explanation. Surface it instead.
+function playVideo(v) {
+    var p = v.play();
+    if (p && typeof p.catch === 'function') {
+        p.catch(function (err) {
+            if (err && err.name === 'AbortError') {
+                // Fired whenever a load or a new src interrupts a pending
+                // play() — routine during seek-by-reload, not a failure.
+                return;
+            }
+            if (err && err.name === 'NotSupportedError') {
+                showToast('This browser cannot play this transcoded format. Try Chrome or Firefox.');
+            } else {
+                showToast('Playback failed: ' + ((err && err.message) || 'unknown error'));
+            }
+        });
+    }
+    return p;
+}
+
 // ── File loading ──────────────────────────────────────────────────────────────
 var files = ao_module_loadInputFiles();
 if (files && files.length > 0) {
@@ -918,7 +942,7 @@ function transcodeSeekTo(seconds) {
     vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
             + '&start=' + pos.toFixed(3);
     vid.load();
-    vid.play();
+    playVideo(vid);
 }
 
 // Current playback position in whole-file terms (transcode streams restart at 0)
@@ -1012,7 +1036,7 @@ function initVideoControls() {
             transcodeSeekTo(0);
         } else {
             vid.currentTime = 0;
-            vid.play();
+            playVideo(vid);
         }
     });
 
@@ -1049,7 +1073,7 @@ function updateMuteIcon() {
 function togglePlay() {
     $('#resume-popup').removeClass('active');
     var willPlay = vid.paused;
-    if (willPlay) { vid.play(); } else { vid.pause(); }
+    if (willPlay) { playVideo(vid); } else { vid.pause(); }
     flashPlayState(willPlay);
 }
 
@@ -1107,12 +1131,12 @@ function showResumePopup(savedPos, duration) {
             transcodeSeekTo(pendingResumePos);
         } else {
             vid.currentTime = pendingResumePos;
-            vid.play();
+            playVideo(vid);
         }
         $('#resume-popup').removeClass('active');
     });
     $('#resume-btn-restart').off('click').on('click', function () {
-        vid.play();
+        playVideo(vid);
         $('#resume-popup').removeClass('active');
     });
 }
@@ -1149,7 +1173,7 @@ function initContextMenu() {
         if (!$(e.target).closest('#player-ctx').length) { $ctx.hide(); }
     });
 
-    $('#ctx-play').on('click',   function () { vid.play();  flashPlayState(true);  $ctx.hide(); });
+    $('#ctx-play').on('click',   function () { playVideo(vid);  flashPlayState(true);  $ctx.hide(); });
     $('#ctx-pause').on('click',  function () { vid.pause(); flashPlayState(false); $ctx.hide(); });
     $('#ctx-repeat').on('click', function () { setRepeatSingle(!repeatSingle); $ctx.hide(); });
     $('#ctx-subtitle-settings').on('click', function () { $ctx.hide(); openSubtitleSettings(); });

+ 31 - 7
src/web/Movie/index.html

@@ -2535,7 +2535,7 @@ function transcodeSeekTo(seconds) {
             + encodeURIComponent(currentEpisodes[playingIndex].filepath)
             + '&start=' + pos.toFixed(3);
     vid.load();
-    vid.play();
+    playVideo(vid);
 }
 
 // Current playback position in whole-file terms (transcode streams restart at 0)
@@ -2582,12 +2582,12 @@ function showResumePopup(savedPos, duration) {
             transcodeSeekTo(pendingResumePos);
         } else {
             vid.currentTime = pendingResumePos;
-            vid.play();
+            playVideo(vid);
         }
         $('#resume-popup').removeClass('active');
     });
     $('#resume-btn-restart').off('click').on('click', function () {
-        vid.play();
+        playVideo(vid);
         $('#resume-popup').removeClass('active');
     });
 }
@@ -3388,7 +3388,7 @@ function startPlayback(index) {
         _castSend('media.play', {});
     } else {
         vid.src = src;
-        vid.play();
+        playVideo(vid);
     }
 
     $('#now-playing-title, #topbar-title').text(ep.name);
@@ -3642,7 +3642,7 @@ function initVideoControls() {
         cancelCountdown();
         if (repeatSingle) {
             vid.currentTime = 0;
-            vid.play();
+            playVideo(vid);
         } else if (playingIndex < currentEpisodes.length - 1 && autoplayEnabled) {
             startNextCountdown();
         }
@@ -3711,7 +3711,7 @@ function togglePlay() {
     }
     var vid = document.getElementById('main-video');
     var willPlay = vid.paused;
-    if (willPlay) { vid.play(); } else { vid.pause(); }
+    if (willPlay) { playVideo(vid); } else { vid.pause(); }
     flashPlayState(willPlay);
 }
 
@@ -3835,7 +3835,7 @@ function initContextMenu() {
         if (castMode && _castConnected()) {
             _castSend('media.play', {}); castIsPlaying = true;
             $('#play-icon').attr('src', 'img/icons/pause_white.svg');
-        } else { vid.play(); }
+        } else { playVideo(vid); }
         flashPlayState(true);
         $ctx.hide();
     });
@@ -4848,6 +4848,30 @@ function showToast(msg) {
     $('#toast').text(msg).addClass('show');
     toastTimer = setTimeout(function () { $('#toast').removeClass('show'); }, 2800);
 }
+
+// play() returns a promise that rejects when the browser will not start the
+// media — most commonly NotSupportedError on Safari, which refuses a transcoded
+// stream because the endpoint cannot answer byte-range requests. Left
+// unhandled it only shows up as an unhandled rejection in the console, so the
+// player just sits there with no explanation. Surface it instead.
+function playVideo(v) {
+    var p = v.play();
+    if (p && typeof p.catch === 'function') {
+        p.catch(function (err) {
+            if (err && err.name === 'AbortError') {
+                // Fired whenever a load or a new src interrupts a pending
+                // play() — routine during seek-by-reload, not a failure.
+                return;
+            }
+            if (err && err.name === 'NotSupportedError') {
+                showToast('This browser cannot play this transcoded format. Try Chrome or Firefox.');
+            } else {
+                showToast('Playback failed: ' + ((err && err.message) || 'unknown error'));
+            }
+        });
+    }
+    return p;
+}
 </script>
 </body>
 </html>

+ 17 - 3
src/web/Terminal/docs/api.json

@@ -1122,10 +1122,24 @@
     },
     {
      "name": "ffmpeg.imageConvert",
-     "sig": "ffmpeg.imageConvert(input, output, scaleFactor, compressionRate)",
-     "desc": "Convert/resize an image. scaleFactor 0.5 = 50% size.",
+     "sig": "ffmpeg.imageConvert(input, output, scaleFactor, compressionRate, progressFile)",
+     "desc": "Convert/resize an image. scaleFactor 0.5 = 50% size. progressFile is optional; it only reports 0% and 100% but makes the job cancellable.",
      "ret": "bool",
-     "example": "requirelib(\"ffmpeg\");\nffmpeg.imageConvert(\"user:/in.png\", \"user:/out.jpg\", 0.5, 80);"
+     "example": "requirelib(\"ffmpeg\");\nffmpeg.imageConvert(\"user:/in.png\", \"user:/out.jpg\", 0.5, 80, \"tmp:/image_progress.json\");"
+    },
+    {
+     "name": "ffmpeg.convertWithProgress",
+     "sig": "ffmpeg.convertWithProgress(input, output, progressFile)",
+     "desc": "Convert between media types without format detection (e.g. mp4 to gif), writing JSON progress updates to progressFile.",
+     "ret": "bool",
+     "example": "requirelib(\"ffmpeg\");\nffmpeg.convertWithProgress(\"user:/in.mp4\", \"user:/out.gif\", \"tmp:/conv_progress.json\");"
+    },
+    {
+     "name": "ffmpeg.cancel",
+     "sig": "ffmpeg.cancel(progressFile)",
+     "desc": "Stop a running conversion, identified by the progress file it was started with. Returns true if a running conversion was terminated, false if it already finished. Call it from a separate request while the conversion request is still open.",
+     "ret": "bool",
+     "example": "requirelib(\"ffmpeg\");\nvar stopped = ffmpeg.cancel(\"tmp:/job42.progress.json\");"
     }
    ]
   },

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff