Quellcode durchsuchen

Add FFmpeg Factory UI and conversion backend

Introduce a complete FFmpeg-based conversion feature: add frontend UI (index.html) and AGI endpoints (convert, progress, tasks, dismiss, ensuredirs) under src/web/FFmpeg Factory for task management and progress. Extend the Go AGI module to register ffmpeg (with exec.LookPath check) and inject new VM helpers for audio, image, video and generic conversions that buffer remote files, run ffmpeg, and write results back. Implement ffmpegutil helpers with progress monitoring, ffprobe duration detection, conversion functions (audio/image/video/conv_with_progress) and JSON progress writes. Update .gitignore to add temporary paths and ignore editor backup files.
Toby Chui vor 3 Tagen
Ursprung
Commit
6f6928982a

+ 2 - 1
.gitignore

@@ -30,4 +30,5 @@ src/system/auth/authlog.db
 src/system/bridge.json
 src/system/cron.json
 src/system/smtp_conf.json
-/src/web/FFmpeg Factory
+*.exe~
+/src/tmp

+ 339 - 2
src/mod/agi/agi.ffmpeg.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"log"
 	"os"
+	"os/exec"
 	"path/filepath"
 
 	"github.com/robertkrimen/otto"
@@ -24,7 +25,11 @@ import (
 */
 
 func (g *Gateway) FFmpegLibRegister() {
-	err := g.RegisterLib("ffmpeg", g.injectFFmpegFunctions)
+	_, err := exec.LookPath("ffmpeg")
+	if err != nil {
+		log.Fatal("ffmpeg not found in PATH")
+	}
+	err = g.RegisterLib("ffmpeg", g.injectFFmpegFunctions)
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -53,7 +58,7 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 
 		if voutput == "" {
 			//Output filename not provided. Not sure what format to convert
-			g.RaiseError(err)
+			g.RaiseError(errors.New("output filename not provided"))
 			return otto.FalseValue()
 		}
 
@@ -141,8 +146,340 @@ func (g *Gateway) injectFFmpegFunctions(payload *static.AgiLibInjectionPayload)
 		return otto.TrueValue()
 	})
 
+	// _ffmpeg_audio_conv(input, output, sampleRate, progressFile)
+	// Converts audio (or strips audio from video).
+	// sampleRate: target Hz, e.g. 44100; 0 keeps original.
+	// progressFile: virtual path for the JSON progress file; omit or pass "" to disable.
+	vm.Set("_ffmpeg_audio_conv", func(call otto.FunctionCall) otto.Value {
+		vinput, err := call.Argument(0).ToString()
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		voutput, err := call.Argument(1).ToString()
+		if err != nil || voutput == "" || voutput == "undefined" {
+			g.RaiseError(errors.New("output filename not provided"))
+			return otto.FalseValue()
+		}
+		sampleRate, err := call.Argument(2).ToInteger()
+		if err != nil || call.Argument(2).IsUndefined() {
+			sampleRate = 0
+		}
+		vprogressFile := ""
+		if !call.Argument(3).IsUndefined() {
+			vprogressFile, _ = call.Argument(3).ToString()
+		}
+
+		vinput = static.RelativeVpathRewrite(scriptFsh, vinput, vm, u)
+		voutput = static.RelativeVpathRewrite(scriptFsh, voutput, vm, u)
+
+		fsh, rinput, err := static.VirtualPathToRealPath(vinput, u)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		fsh, routput, err := static.VirtualPathToRealPath(voutput, u)
+		if err != nil {
+			g.RaiseError(err)
+			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)
+			return otto.FalseValue()
+		}
+
+		outputTmpFilename := uuid.NewV4().String() + filepath.Ext(routput)
+		outputBufferPath := filepath.Join(filepath.Dir(bufferedFilepath), outputTmpFilename)
+
+		err = ffmpegutil.FFmpeg_audio_conv(bufferedFilepath, outputBufferPath, int(sampleRate), rprogressFile)
+		os.Remove(bufferedFilepath)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		if !utils.FileExists(outputBufferPath) {
+			g.RaiseError(errors.New("output file not found after audio conversion"))
+			return otto.FalseValue()
+		}
+
+		src, err := os.OpenFile(outputBufferPath, os.O_RDONLY, 0755)
+		if err != nil {
+			g.RaiseError(err)
+			os.Remove(outputBufferPath)
+			return otto.FalseValue()
+		}
+		defer src.Close()
+		err = fsh.FileSystemAbstraction.WriteStream(routput, src, 0775)
+		if err != nil {
+			g.RaiseError(err)
+			os.Remove(outputBufferPath)
+			return otto.FalseValue()
+		}
+		os.Remove(outputBufferPath)
+		return otto.TrueValue()
+	})
+
+	// _ffmpeg_image_conv(input, output, scaleFactor, compressionRate)
+	// Converts an image file with optional uniform scaling and lossy compression.
+	// scaleFactor: float multiplier for both dimensions (0.5 = half size); 0 or 1.0 = no change.
+	// compressionRate: 0-100; only applied to lossy formats (JPEG, WebP); ignored for PNG/BMP/GIF.
+	vm.Set("_ffmpeg_image_conv", func(call otto.FunctionCall) otto.Value {
+		vinput, err := call.Argument(0).ToString()
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		voutput, err := call.Argument(1).ToString()
+		if err != nil || voutput == "" || voutput == "undefined" {
+			g.RaiseError(errors.New("output filename not provided"))
+			return otto.FalseValue()
+		}
+		scaleFactor, err := call.Argument(2).ToFloat()
+		if err != nil || call.Argument(2).IsUndefined() {
+			scaleFactor = 0
+		}
+		compressionRate, err := call.Argument(3).ToInteger()
+		if err != nil || call.Argument(3).IsUndefined() {
+			compressionRate = 0
+		}
+
+		vinput = static.RelativeVpathRewrite(scriptFsh, vinput, vm, u)
+		voutput = static.RelativeVpathRewrite(scriptFsh, voutput, vm, u)
+
+		fsh, rinput, err := static.VirtualPathToRealPath(vinput, u)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		fsh, routput, err := static.VirtualPathToRealPath(voutput, u)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+
+		bufferedFilepath, err := fsh.BufferRemoteToLocal(rinput)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+
+		outputTmpFilename := uuid.NewV4().String() + filepath.Ext(routput)
+		outputBufferPath := filepath.Join(filepath.Dir(bufferedFilepath), outputTmpFilename)
+
+		err = ffmpegutil.FFmpeg_image_conv(bufferedFilepath, outputBufferPath, scaleFactor, int(compressionRate))
+		os.Remove(bufferedFilepath)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		if !utils.FileExists(outputBufferPath) {
+			g.RaiseError(errors.New("output file not found after image conversion"))
+			return otto.FalseValue()
+		}
+
+		src, err := os.OpenFile(outputBufferPath, os.O_RDONLY, 0755)
+		if err != nil {
+			g.RaiseError(err)
+			os.Remove(outputBufferPath)
+			return otto.FalseValue()
+		}
+		defer src.Close()
+		err = fsh.FileSystemAbstraction.WriteStream(routput, src, 0775)
+		if err != nil {
+			g.RaiseError(err)
+			os.Remove(outputBufferPath)
+			return otto.FalseValue()
+		}
+		os.Remove(outputBufferPath)
+		return otto.TrueValue()
+	})
+
+	// _ffmpeg_video_conv(input, output, resolution, compressionRate, progressFile)
+	// Converts a video file with optional resolution scaling and CRF compression.
+	// resolution: "144p", "240p", "360p", "480p", "576p", "720p", "1080p", "1440p", "2160p", "4k", "8k"; "" keeps original.
+	// compressionRate: 0-100; mapped to CRF 1-51 (0 = encoder default, 100 = most compressed).
+	// progressFile: virtual path for the JSON progress file; omit or pass "" to disable.
+	vm.Set("_ffmpeg_video_conv", func(call otto.FunctionCall) otto.Value {
+		vinput, err := call.Argument(0).ToString()
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		voutput, err := call.Argument(1).ToString()
+		if err != nil || voutput == "" || voutput == "undefined" {
+			g.RaiseError(errors.New("output filename not provided"))
+			return otto.FalseValue()
+		}
+		resolution := ""
+		if !call.Argument(2).IsUndefined() {
+			resolution, _ = call.Argument(2).ToString()
+			if resolution == "undefined" {
+				resolution = ""
+			}
+		}
+		compressionRate, err := call.Argument(3).ToInteger()
+		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)
+
+		fsh, rinput, err := static.VirtualPathToRealPath(vinput, u)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		fsh, routput, err := static.VirtualPathToRealPath(voutput, u)
+		if err != nil {
+			g.RaiseError(err)
+			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)
+			return otto.FalseValue()
+		}
+
+		outputTmpFilename := uuid.NewV4().String() + filepath.Ext(routput)
+		outputBufferPath := filepath.Join(filepath.Dir(bufferedFilepath), outputTmpFilename)
+
+		err = ffmpegutil.FFmpeg_video_conv(bufferedFilepath, outputBufferPath, resolution, int(compressionRate), rprogressFile)
+		os.Remove(bufferedFilepath)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		if !utils.FileExists(outputBufferPath) {
+			g.RaiseError(errors.New("output file not found after video conversion"))
+			return otto.FalseValue()
+		}
+
+		src, err := os.OpenFile(outputBufferPath, os.O_RDONLY, 0755)
+		if err != nil {
+			g.RaiseError(err)
+			os.Remove(outputBufferPath)
+			return otto.FalseValue()
+		}
+		defer src.Close()
+		err = fsh.FileSystemAbstraction.WriteStream(routput, src, 0775)
+		if err != nil {
+			g.RaiseError(err)
+			os.Remove(outputBufferPath)
+			return otto.FalseValue()
+		}
+		os.Remove(outputBufferPath)
+		return otto.TrueValue()
+	})
+
+	// _ffmpeg_conv_with_progress(input, output, progressFile)
+	// Passes input directly to ffmpeg without format detection.
+	// Suitable for cross-media conversions (e.g. mp4→gif) or unknown format pairs.
+	// progressFile: virtual path for the JSON progress file; omit or pass "" to disable.
+	vm.Set("_ffmpeg_conv_with_progress", func(call otto.FunctionCall) otto.Value {
+		vinput, err := call.Argument(0).ToString()
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		voutput, err := call.Argument(1).ToString()
+		if err != nil || voutput == "" || voutput == "undefined" {
+			g.RaiseError(errors.New("output filename not provided"))
+			return otto.FalseValue()
+		}
+		vprogressFile := ""
+		if !call.Argument(2).IsUndefined() {
+			vprogressFile, _ = call.Argument(2).ToString()
+		}
+
+		vinput = static.RelativeVpathRewrite(scriptFsh, vinput, vm, u)
+		voutput = static.RelativeVpathRewrite(scriptFsh, voutput, vm, u)
+
+		fsh, rinput, err := static.VirtualPathToRealPath(vinput, u)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		fsh, routput, err := static.VirtualPathToRealPath(voutput, u)
+		if err != nil {
+			g.RaiseError(err)
+			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)
+			return otto.FalseValue()
+		}
+
+		outputTmpFilename := uuid.NewV4().String() + filepath.Ext(routput)
+		outputBufferPath := filepath.Join(filepath.Dir(bufferedFilepath), outputTmpFilename)
+
+		err = ffmpegutil.FFmpeg_conv_with_progress(bufferedFilepath, outputBufferPath, rprogressFile)
+		os.Remove(bufferedFilepath)
+		if err != nil {
+			g.RaiseError(err)
+			return otto.FalseValue()
+		}
+		if !utils.FileExists(outputBufferPath) {
+			g.RaiseError(errors.New("output file not found after conversion"))
+			return otto.FalseValue()
+		}
+
+		src, err := os.OpenFile(outputBufferPath, os.O_RDONLY, 0755)
+		if err != nil {
+			g.RaiseError(err)
+			os.Remove(outputBufferPath)
+			return otto.FalseValue()
+		}
+		defer src.Close()
+		err = fsh.FileSystemAbstraction.WriteStream(routput, src, 0775)
+		if err != nil {
+			g.RaiseError(err)
+			os.Remove(outputBufferPath)
+			return otto.FalseValue()
+		}
+		os.Remove(outputBufferPath)
+		return otto.TrueValue()
+	})
+
 	vm.Run(`
 		var ffmpeg = {};
 		ffmpeg.convert = _ffmpeg_conv;
+		ffmpeg.audioConvert = _ffmpeg_audio_conv;
+		ffmpeg.imageConvert = _ffmpeg_image_conv;
+		ffmpeg.videoConvert = _ffmpeg_video_conv;
+		ffmpeg.convertWithProgress = _ffmpeg_conv_with_progress;
 	`)
 }

+ 329 - 0
src/mod/agi/static/ffmpegutil/ffmpegutil.go

@@ -1,10 +1,16 @@
 package ffmpegutil
 
 import (
+	"encoding/json"
 	"fmt"
+	"math"
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
 
 	"imuslab.com/arozos/mod/utils"
 )
@@ -16,6 +22,31 @@ import (
 
 */
 
+// ConversionProgress holds the state of an ongoing or completed conversion.
+// It is serialised as JSON and written to the caller-supplied progress file.
+type ConversionProgress struct {
+	InputSize      int64   `json:"input_size"`
+	OutputSize     int64   `json:"output_size"`
+	ConversionTime float64 `json:"conversion_time"`
+	Percentage     float64 `json:"percentage"`
+	Completed      bool    `json:"completed"`
+}
+
+// resolutionHeightMap maps common resolution names to their vertical pixel count.
+var resolutionHeightMap = map[string]int{
+	"144p":  144,
+	"240p":  240,
+	"360p":  360,
+	"480p":  480,
+	"576p":  576,
+	"720p":  720,
+	"1080p": 1080,
+	"1440p": 1440,
+	"2160p": 2160,
+	"4k":    2160,
+	"8k":    4320,
+}
+
 /*
 ffmpeg_conv support input of a limited video, audio and image formats
 Compression value can be set if compression / resize is needed.
@@ -96,3 +127,301 @@ func isImage(filename string) bool {
 	}
 	return utils.StringInArray(imageFormats, filepath.Ext(filename))
 }
+
+// isLossyImage returns true for image formats that support lossy compression.
+func isLossyImage(filename string) bool {
+	lossyFormats := []string{".jpg", ".jpeg", ".webp"}
+	return utils.StringInArray(lossyFormats, strings.ToLower(filepath.Ext(filename)))
+}
+
+// --- Progress helpers ---
+
+// fileSize returns the byte size of path, or 0 if the file is not accessible.
+func fileSize(path string) int64 {
+	if info, err := os.Stat(path); err == nil {
+		return info.Size()
+	}
+	return 0
+}
+
+// getMediaDurationMs returns the total duration of a media file in milliseconds
+// by invoking ffprobe.
+func getMediaDurationMs(input string) (int64, error) {
+	cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", input)
+	output, err := cmd.Output()
+	if err != nil {
+		return 0, err
+	}
+	var result struct {
+		Format struct {
+			Duration string `json:"duration"`
+		} `json:"format"`
+	}
+	if err := json.Unmarshal(output, &result); err != nil {
+		return 0, err
+	}
+	duration, err := strconv.ParseFloat(result.Format.Duration, 64)
+	if err != nil {
+		return 0, err
+	}
+	return int64(duration * 1000), nil
+}
+
+// writeProgressJSON writes a ConversionProgress snapshot as JSON to progressFile.
+// Errors are silently ignored so that a progress-write failure never aborts a conversion.
+func writeProgressJSON(progressFile string, inputSize int64, outputFile string, startTime time.Time, percentage float64, completed bool) {
+	progress := ConversionProgress{
+		InputSize:      inputSize,
+		OutputSize:     fileSize(outputFile),
+		ConversionTime: time.Since(startTime).Seconds(),
+		Percentage:     percentage,
+		Completed:      completed,
+	}
+	data, err := json.Marshal(progress)
+	if err != nil {
+		return
+	}
+	os.WriteFile(progressFile, data, 0644) //nolint:errcheck
+}
+
+// monitorFFmpegProgress reads the ffmpeg -progress pipe file every 500 ms and writes
+// updated JSON to userProgressFile.  It exits when done is closed.
+func monitorFFmpegProgress(ffmpegPipeFile, userProgressFile, outputFile string, inputSize, totalDurationMs int64, startTime time.Time, done <-chan struct{}) {
+	ticker := time.NewTicker(500 * time.Millisecond)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-done:
+			return
+		case <-ticker.C:
+			data, err := os.ReadFile(ffmpegPipeFile)
+			if err != nil {
+				continue
+			}
+			// Parse ffmpeg key=value progress output
+			parsed := make(map[string]string)
+			for _, line := range strings.Split(string(data), "\n") {
+				if parts := strings.SplitN(strings.TrimSpace(line), "=", 2); len(parts) == 2 {
+					parsed[parts[0]] = parts[1]
+				}
+			}
+			// Despite the name, out_time_ms is in microseconds
+			outTimeMsStr, ok := parsed["out_time_ms"]
+			if !ok {
+				continue
+			}
+			outTimeUs, err := strconv.ParseInt(outTimeMsStr, 10, 64)
+			if err != nil || outTimeUs < 0 {
+				continue
+			}
+			percentage := 0.0
+			if totalDurationMs > 0 {
+				percentage = math.Min(99.0, float64(outTimeUs)/float64(totalDurationMs*1000)*100.0)
+			}
+			writeProgressJSON(userProgressFile, inputSize, outputFile, startTime, percentage, false)
+		}
+	}
+}
+
+// startProgressMonitor launches the background goroutine that updates userProgressFile.
+// Returns the done channel (to be closed when conversion finishes) and a WaitGroup to
+// wait for the goroutine to exit before removing the pipe file.
+func startProgressMonitor(ffmpegPipeFile, userProgressFile, outputFile string, inputSize, totalDurationMs int64, startTime time.Time) (chan struct{}, *sync.WaitGroup) {
+	doneCh := make(chan struct{})
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		monitorFFmpegProgress(ffmpegPipeFile, userProgressFile, outputFile, inputSize, totalDurationMs, startTime, doneCh)
+	}()
+	return doneCh, &wg
+}
+
+// stopProgressMonitor signals the goroutine to stop, waits for it, and removes the
+// internal ffmpeg pipe file.  If convErr is nil it also writes the final 100 % entry.
+func stopProgressMonitor(doneCh chan struct{}, wg *sync.WaitGroup, ffmpegPipeFile, userProgressFile, outputFile string, inputSize int64, startTime time.Time, convErr error) {
+	close(doneCh)
+	wg.Wait()
+	os.Remove(ffmpegPipeFile)
+	if convErr == nil {
+		writeProgressJSON(userProgressFile, inputSize, outputFile, startTime, 100.0, true)
+	}
+}
+
+// --- New conversion functions ---
+
+// FFmpeg_audio_conv converts an audio (or video-to-audio) file.
+//
+//   - sampleRate  – target sample rate in Hz (e.g. 44100); 0 keeps the original.
+//   - progressFile – real filesystem path to write JSON progress updates; "" disables tracking.
+//
+// The internal ffmpeg progress pipe file is cleaned up before the function returns.
+func FFmpeg_audio_conv(input, output string, sampleRate int, progressFile string) error {
+	startTime := time.Now()
+	inputSize := fileSize(input)
+
+	args := []string{"-i", input, "-y"}
+	if sampleRate > 0 {
+		args = append(args, "-ar", strconv.Itoa(sampleRate))
+	}
+
+	var doneCh chan struct{}
+	var wg *sync.WaitGroup
+	ffmpegPipeFile := ""
+	if progressFile != "" {
+		ffmpegPipeFile = progressFile + ".ffprog"
+		args = append(args, "-progress", ffmpegPipeFile)
+		totalDurationMs, _ := getMediaDurationMs(input)
+		doneCh, wg = startProgressMonitor(ffmpegPipeFile, progressFile, output, inputSize, totalDurationMs, startTime)
+		writeProgressJSON(progressFile, inputSize, output, startTime, 0.0, false)
+	}
+
+	args = append(args, output)
+	cmd := exec.Command("ffmpeg", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	err := cmd.Run()
+
+	if progressFile != "" {
+		stopProgressMonitor(doneCh, wg, ffmpegPipeFile, progressFile, output, inputSize, startTime, err)
+	}
+	if err != nil {
+		return fmt.Errorf("ffmpeg audio conversion failed: %v", err)
+	}
+	return nil
+}
+
+// FFmpeg_image_conv converts an image file with optional uniform scaling and lossy compression.
+//
+//   - scaleFactor    – float multiplier applied to both dimensions (e.g. 0.5 = half size);
+//     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 {
+	args := []string{"-i", input, "-y"}
+
+	if scaleFactor > 0 && scaleFactor != 1.0 {
+		args = append(args, "-vf", fmt.Sprintf("scale=iw*%.6g:ih*%.6g", scaleFactor, scaleFactor))
+	}
+
+	if compressionRate > 0 && isLossyImage(output) {
+		ext := strings.ToLower(filepath.Ext(output))
+		switch ext {
+		case ".jpg", ".jpeg":
+			// ffmpeg q:v: 2 (best) – 31 (worst); compressionRate 0=best, 100=worst
+			q := 2 + compressionRate*29/100
+			args = append(args, "-q:v", strconv.Itoa(q))
+		case ".webp":
+			// ffmpeg q:v: 100 (best) – 0 (worst); invert compressionRate
+			q := 100 - compressionRate
+			if q < 0 {
+				q = 0
+			}
+			args = append(args, "-q:v", strconv.Itoa(q))
+		}
+	}
+
+	args = append(args, output)
+	cmd := exec.Command("ffmpeg", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("ffmpeg image conversion failed: %v", err)
+	}
+	return nil
+}
+
+// FFmpeg_video_conv converts a video file with optional resolution scaling, CRF compression,
+// and progress tracking.
+//
+//   - resolution     – target height string: "144p", "240p", "360p", "480p", "576p", "720p",
+//     "1080p", "1440p", "2160p", "4k", "8k"; "" keeps the original resolution.
+//   - compressionRate – 0-100; mapped to CRF 1-51 (0 = use encoder default, 100 = most compressed).
+//   - progressFile   – real filesystem path to write JSON progress updates; "" disables tracking.
+//
+// The internal ffmpeg progress pipe file is cleaned up before the function returns.
+func FFmpeg_video_conv(input, output, resolution string, compressionRate int, progressFile string) error {
+	startTime := time.Now()
+	inputSize := fileSize(input)
+
+	args := []string{"-i", input, "-y"}
+
+	if resolution != "" {
+		resHeight, ok := resolutionHeightMap[strings.ToLower(resolution)]
+		if !ok {
+			return fmt.Errorf("unsupported resolution %q (supported: 144p 240p 360p 480p 576p 720p 1080p 1440p 2160p 4k 8k)", resolution)
+		}
+		// -2 ensures the width is adjusted to maintain aspect ratio with an even number
+		args = append(args, "-vf", fmt.Sprintf("scale=-2:%d", resHeight))
+	}
+
+	if compressionRate > 0 {
+		// Map 1-100 -> CRF 1-51
+		crf := 1 + compressionRate*50/100
+		args = append(args, "-crf", strconv.Itoa(crf))
+	}
+
+	var doneCh chan struct{}
+	var wg *sync.WaitGroup
+	ffmpegPipeFile := ""
+	if progressFile != "" {
+		ffmpegPipeFile = progressFile + ".ffprog"
+		args = append(args, "-progress", ffmpegPipeFile)
+		totalDurationMs, _ := getMediaDurationMs(input)
+		doneCh, wg = startProgressMonitor(ffmpegPipeFile, progressFile, output, inputSize, totalDurationMs, startTime)
+		writeProgressJSON(progressFile, inputSize, output, startTime, 0.0, false)
+	}
+
+	args = append(args, output)
+	cmd := exec.Command("ffmpeg", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	err := cmd.Run()
+
+	if progressFile != "" {
+		stopProgressMonitor(doneCh, wg, ffmpegPipeFile, progressFile, output, inputSize, startTime, err)
+	}
+	if err != nil {
+		return fmt.Errorf("ffmpeg video conversion failed: %v", err)
+	}
+	return nil
+}
+
+// FFmpeg_conv_with_progress passes the input directly to ffmpeg without any format-detection
+// logic.  Suitable for cross-media-type conversions (e.g. MP4 → GIF) or any format pair
+// that FFmpeg_conv does not recognise.
+//
+//   - progressFile – real filesystem path to write JSON progress updates; "" disables tracking.
+//
+// The internal ffmpeg progress pipe file is cleaned up before the function returns.
+func FFmpeg_conv_with_progress(input, output, progressFile string) error {
+	startTime := time.Now()
+	inputSize := fileSize(input)
+
+	args := []string{"-i", input, "-y"}
+
+	var doneCh chan struct{}
+	var wg *sync.WaitGroup
+	ffmpegPipeFile := ""
+	if progressFile != "" {
+		ffmpegPipeFile = progressFile + ".ffprog"
+		args = append(args, "-progress", ffmpegPipeFile)
+		totalDurationMs, _ := getMediaDurationMs(input)
+		doneCh, wg = startProgressMonitor(ffmpegPipeFile, progressFile, output, inputSize, totalDurationMs, startTime)
+		writeProgressJSON(progressFile, inputSize, output, startTime, 0.0, false)
+	}
+
+	args = append(args, output)
+	cmd := exec.Command("ffmpeg", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	err := cmd.Run()
+
+	if progressFile != "" {
+		stopProgressMonitor(doneCh, wg, ffmpegPipeFile, progressFile, output, inputSize, startTime, err)
+	}
+	if err != nil {
+		return fmt.Errorf("ffmpeg conversion failed: %v", err)
+	}
+	return nil
+}

+ 99 - 0
src/web/FFmpeg Factory/agi/convert.agi

@@ -0,0 +1,99 @@
+/*
+    FFmpeg Factory - Convert
+    Runs an ffmpeg conversion and writes progress to a JSON file.
+
+    Expected POST parameters:
+      src        - virtual path of the input file
+      outputExt  - desired output file extension (e.g. "mp3", "mp4")
+      convType   - "audio" | "video" | "image" | "generic"
+      taskId     - unique task ID generated by the frontend
+      options    - JSON string with format-specific options:
+                     audio:   { sample_rate: 44100 }
+                     video:   { resolution: "720p", compression: 23 }
+                     image:   { scale: 0.5, compression: 80 }
+                     generic: {}
+*/
+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);
+
+    } else {
+        // generic / cross-media (e.g. mp4 → gif)
+        success = ffmpeg.convertWithProgress(src, outputVpath, progressVpath);
+    }
+} 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
+}));

+ 21 - 0
src/web/FFmpeg Factory/agi/dismiss.agi

@@ -0,0 +1,21 @@
+/*
+    FFmpeg Factory - Dismiss Task
+    Removes the task file and its associated progress file from the server.
+
+    Expected POST parameter:
+      id - the task ID to dismiss
+*/
+requirelib("filelib");
+
+if (typeof id === "undefined" || id === "" || id === "undefined") {
+    sendJSONResp(JSON.stringify({ error: "missing_id" }));
+} else {
+    var taskDir      = "tmp:/ffmpeg_factory";
+    var taskFile     = taskDir + "/" + id + ".task.json";
+    var progressFile = taskDir + "/" + id + ".progress.json";
+
+    if (filelib.fileExists(taskFile))     { filelib.deleteFile(taskFile); }
+    if (filelib.fileExists(progressFile)) { filelib.deleteFile(progressFile); }
+
+    sendJSONResp(JSON.stringify({ success: true }));
+}

+ 18 - 0
src/web/FFmpeg Factory/agi/ensuredirs.agi

@@ -0,0 +1,18 @@
+/*
+    FFmpeg Factory - Ensure Directories
+    Creates the required tmp folders for the factory to operate.
+    Call this before any file upload operation.
+*/
+requirelib("filelib");
+
+var taskDir = "tmp:/ffmpeg_factory";
+var uploadDir = "tmp:/ffmpeg_factory/uploads";
+
+if (!filelib.fileExists(taskDir)) {
+    filelib.mkdir(taskDir);
+}
+if (!filelib.fileExists(uploadDir)) {
+    filelib.mkdir(uploadDir);
+}
+
+sendJSONResp(JSON.stringify({ success: true }));

+ 26 - 0
src/web/FFmpeg Factory/agi/progress.agi

@@ -0,0 +1,26 @@
+/*
+    FFmpeg Factory - Progress
+    Returns the current conversion progress JSON for a given task ID.
+
+    Expected POST parameter:
+      id - the task ID
+*/
+requirelib("filelib");
+
+if (typeof id === "undefined" || id === "" || id === "undefined") {
+    sendJSONResp(JSON.stringify({ error: "missing_id" }));
+} else {
+    var progressVpath = "tmp:/ffmpeg_factory/" + id + ".progress.json";
+
+    if (!filelib.fileExists(progressVpath)) {
+        sendJSONResp(JSON.stringify({ error: "not_found" }));
+    } else {
+        var content = filelib.readFile(progressVpath);
+        if (content === false || content === "") {
+            sendJSONResp(JSON.stringify({ error: "empty" }));
+        } else {
+            // The progress file is already valid JSON written by the Go layer
+            sendResp(content);
+        }
+    }
+}

+ 40 - 0
src/web/FFmpeg Factory/agi/tasks.agi

@@ -0,0 +1,40 @@
+/*
+    FFmpeg Factory - List Tasks
+    Returns all task JSON objects from tmp:/ffmpeg_factory/ so the frontend
+    can resume the session after the browser tab is closed and reopened.
+*/
+requirelib("filelib");
+
+var taskDir = "tmp:/ffmpeg_factory";
+
+// Ensure task folder exists
+if (!filelib.fileExists(taskDir)) {
+    filelib.mkdir(taskDir);
+    sendJSONResp("[]");
+} else {
+    var filesJson = filelib.readdir(taskDir);
+    var files = [];
+    try { files = JSON.parse(filesJson); } catch (e) {}
+
+    var tasks = [];
+    for (var i = 0; i < files.length; i++) {
+        var f = files[i];
+        if (f.IsDir) continue;
+        // Only look at .task.json files (not .progress.json or .ffprog files)
+        if (f.Filename.length < 10) continue;
+        if (f.Filename.indexOf(".task.json") === f.Filename.length - 10) {
+            var content = filelib.readFile(taskDir + "/" + f.Filename);
+            if (content !== false && content !== "") {
+                try {
+                    var task = JSON.parse(content);
+                    tasks.push(task);
+                } catch (e) {}
+            }
+        }
+    }
+
+    // Sort newest first
+    tasks.sort(function (a, b) { return (b.created_at || 0) - (a.created_at || 0); });
+
+    sendJSONResp(JSON.stringify(tasks));
+}

+ 1129 - 0
src/web/FFmpeg Factory/index.html

@@ -0,0 +1,1129 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="UTF-8" />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
+    <link rel="stylesheet" href="../script/semantic/semantic.min.css">
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/semantic/semantic.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+    <title>FFmpeg Factory</title>
+    <style>
+        /* ── Design tokens (Notes-inspired minimal gray) ── */
+        :root {
+            --bg:       #f7f7f2;
+            --surface:  #ffffff;
+            --surface2: #f2f2f7;
+            --border:   #d1d1d6;
+            --border-s: #e5e5ea;
+            --text:     #1c1c1e;
+            --text2:    #636366;
+            --text3:    #aeaeb2;
+            --accent:   #ffd60a;
+            --accent-h: #e6be00;
+            --btn-text: #1c1c1e;
+            --hover:    rgba(0,0,0,0.05);
+            --success:  #30b950;
+            --danger:   #ff3b30;
+            --warning:  #ff9500;
+            --shadow-s: 0 1px 3px rgba(0,0,0,0.08);
+            --r:        11px;
+            --r-sm:     9px;
+            --r-xs:     7px;
+        }
+        body.dark {
+            --bg:       #1c1c1e;
+            --surface:  #2c2c2e;
+            --surface2: #3a3a3c;
+            --border:   #38383a;
+            --border-s: #2c2c2e;
+            --text:     #f2f2f7;
+            --text2:    #aeaeb2;
+            --text3:    #48484a;
+            --accent:   #ffd60a;
+            --accent-h: #ffe040;
+            --btn-text: #1c1c1e;
+            --hover:    rgba(255,255,255,0.06);
+            --success:  #32d74b;
+            --danger:   #ff453a;
+            --warning:  #ffd60a;
+            --shadow-s: 0 1px 4px rgba(0,0,0,0.4);
+        }
+
+        * { box-sizing: border-box; }
+        html, body {
+            margin: 0; padding: 0; height: 100%;
+            background: var(--bg);
+            color: var(--text);
+            font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
+            font-size: 13px;
+            overflow: hidden;
+            transition: background-color 0.3s ease, color 0.3s ease;
+        }
+        /* thin scrollbars – like Notes */
+        ::-webkit-scrollbar { width: 4px; }
+        ::-webkit-scrollbar-track { background: transparent; }
+        body.light ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.18); border-radius: 4px; }
+        body.dark  ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
+        #appWrapper {
+            display: flex;
+            height: 100vh;
+            overflow: hidden;
+        }
+
+        /* ── Left panel ── */
+        #leftPanel {
+            width: 300px;
+            min-width: 260px;
+            max-width: 340px;
+            background: var(--surface2);
+            border-right: 1px solid var(--border);
+            display: flex;
+            flex-direction: column;
+            overflow: hidden;
+            flex-shrink: 0;
+        }
+        #leftHeader {
+            padding: 13px 14px 11px;
+            border-bottom: 1px solid var(--border);
+            font-weight: 700;
+            font-size: 15px;
+            color: var(--text);
+            display: flex;
+            align-items: center;
+            gap: 7px;
+            flex-shrink: 0;
+        }
+        #leftHeader .hdr-icon { color: var(--accent); font-size: 0.9em; }
+        #leftHeader .hdr-spacer { flex: 1; }
+        #themeToggleBtn {
+            width: 28px; height: 28px;
+            border-radius: 7px;
+            border: none;
+            background: transparent;
+            color: var(--text3);
+            cursor: pointer;
+            display: flex; align-items: center; justify-content: center;
+            font-size: 15px;
+            transition: background 0.12s, color 0.12s;
+            flex-shrink: 0;
+        }
+        #themeToggleBtn:hover { background: var(--hover); color: var(--text2); }
+
+        #leftBody {
+            flex: 1;
+            overflow-y: auto;
+            overflow-x: hidden;
+            padding: 12px 14px;
+        }
+
+        /* Drop zone */
+        #dropZone {
+            border: 1.5px dashed var(--border);
+            border-radius: var(--r);
+            padding: 22px 12px 18px;
+            text-align: center;
+            color: var(--text3);
+            cursor: pointer;
+            transition: border-color 0.15s, background 0.15s;
+            margin-bottom: 8px;
+            user-select: none;
+            background: var(--surface);
+        }
+        #dropZone:hover, #dropZone.dragging {
+            border-color: var(--text2);
+            background: var(--surface);
+        }
+        #dropZone .dz-icon { font-size: 1.5em; display: block; margin-bottom: 7px; color: var(--accent); opacity: 0.85; }
+        #dropZone .dz-label { font-size: 0.86em; font-weight: 600; color: var(--text2); }
+        #dropZone .dz-sub { font-size: 0.75em; margin-top: 3px; color: var(--text3); }
+
+        /* Upload from computer button */
+        #uploadBtn {
+            width: 100%;
+            padding: 7px 12px;
+            background: transparent;
+            color: var(--text2);
+            border: 1px solid var(--border);
+            border-radius: var(--r-sm);
+            font-size: 0.84em;
+            font-weight: 500;
+            cursor: pointer;
+            margin-bottom: 10px;
+            display: flex; align-items: center; justify-content: center; gap: 6px;
+            transition: background 0.12s, color 0.12s;
+            font-family: inherit;
+        }
+        #uploadBtn:hover { background: var(--hover); color: var(--text); }
+
+        /* Selected file card */
+        #selectedFileInfo {
+            display: none;
+            background: var(--surface);
+            border: 1px solid var(--border);
+            border-radius: var(--r-sm);
+            padding: 9px 10px 9px 11px;
+            margin-bottom: 10px;
+            position: relative;
+        }
+        #selectedFileIcon {
+            font-size: 1.4em; float: left; margin-right: 9px;
+            color: var(--accent); line-height: 1;
+        }
+        #selectedFileName {
+            font-weight: 600; font-size: 0.87em; word-break: break-all;
+            padding-right: 20px; color: var(--text);
+        }
+        #selectedFileMeta { font-size: 0.74em; color: var(--text2); word-break: break-all; margin-top: 2px; }
+        #clearFileBtn {
+            position: absolute; top: 8px; right: 8px;
+            cursor: pointer; color: var(--text3); font-size: 0.9em;
+            line-height: 1; width: 16px; height: 16px;
+            display: flex; align-items: center; justify-content: center;
+            border-radius: 50%;
+            transition: background 0.12s, color 0.12s;
+        }
+        #clearFileBtn:hover { color: var(--danger); background: var(--hover); }
+
+        /* Upload progress */
+        #uploadProgressWrap {
+            display: none;
+            margin-bottom: 8px;
+        }
+        #uploadProgressWrap .up-label {
+            font-size: 0.76em; color: var(--text2); margin-bottom: 4px;
+        }
+        .prog-track {
+            height: 3px; background: var(--border-s);
+            border-radius: 2px; overflow: hidden;
+        }
+        .prog-fill {
+            height: 100%; background: var(--accent); border-radius: 2px;
+            transition: width 0.2s ease;
+        }
+
+        /* Divider */
+        .ff-divider {
+            height: 1px; background: var(--border); margin: 10px 0;
+        }
+
+        /* Form */
+        .ff-label {
+            font-size: 0.72em;
+            font-weight: 600;
+            color: var(--accent);
+            display: block;
+            margin-bottom: 4px;
+            margin-top: 12px;
+            text-transform: uppercase;
+            letter-spacing: 0.07em;
+        }
+        .ff-select, .ff-input {
+            width: 100%;
+            padding: 7px 9px;
+            border: 1px solid var(--border);
+            border-radius: var(--r-xs);
+            font-size: 0.86em;
+            background: var(--surface);
+            color: var(--text);
+            outline: none;
+            transition: border-color 0.15s;
+            appearance: auto;
+            font-family: inherit;
+        }
+        .ff-select:focus, .ff-input:focus { border-color: var(--accent); }
+        .ff-range {
+            width: 100%; cursor: pointer;
+            accent-color: var(--accent);
+        }
+        .ff-range-val { font-size: 0.75em; color: var(--text2); float: right; }
+        .options-section { margin-top: 2px; }
+
+        #convertBtn {
+            width: 100%;
+            padding: 9px;
+            background: var(--accent);
+            color: var(--btn-text);
+            border: none;
+            border-radius: var(--r-sm);
+            font-size: 0.9em;
+            font-weight: 600;
+            cursor: pointer;
+            margin-top: 14px;
+            transition: background 0.15s, opacity 0.15s;
+            letter-spacing: 0.01em;
+        }
+        #convertBtn:hover:not(:disabled) { background: var(--accent-h); }
+        #convertBtn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+        /* ── Right panel ── */
+        #rightPanel {
+            flex: 1;
+            display: flex;
+            flex-direction: column;
+            overflow: hidden;
+            background: var(--bg);
+        }
+        #rightHeader {
+            padding: 13px 18px 11px;
+            border-bottom: 1px solid var(--border);
+            font-weight: 700;
+            font-size: 15px;
+            color: var(--text);
+            display: flex;
+            align-items: center;
+            gap: 7px;
+            flex-shrink: 0;
+            background: var(--surface2);
+        }
+        #rightHeader .hdr-icon { color: var(--accent); }
+        #taskCountBadge {
+            display: none;
+            background: var(--accent);
+            color: var(--btn-text);
+            font-size: 0.69em;
+            font-weight: 700;
+            padding: 1px 7px;
+            border-radius: 10px;
+        }
+        #taskListWrap {
+            flex: 1;
+            overflow-y: auto;
+            padding: 14px 16px;
+        }
+
+        /* Empty state */
+        #emptyState {
+            text-align: center;
+            padding: 70px 20px;
+            pointer-events: none;
+        }
+        #emptyState .es-icon {
+            font-size: 3em; display: block; margin-bottom: 12px;
+            color: var(--accent);
+        }
+        #emptyState .es-title { font-size: 0.95em; font-weight: 600; color: var(--text3); }
+        #emptyState .es-sub { font-size: 0.81em; margin-top: 5px; color: var(--text3); }
+
+        /* Task card */
+        .task-card {
+            background: var(--surface);
+            border: 1px solid var(--border);
+            border-radius: var(--r);
+            padding: 12px 13px;
+            margin-bottom: 9px;
+            box-shadow: var(--shadow-s);
+            animation: slideIn 0.18s ease;
+        }
+        @keyframes slideIn {
+            from { opacity: 0; transform: translateY(-5px); }
+            to   { opacity: 1; transform: translateY(0); }
+        }
+        .tc-row1 {
+            display: flex; align-items: flex-start; gap: 9px; margin-bottom: 9px;
+        }
+        .tc-icon { font-size: 1.2em; color: var(--accent); flex-shrink: 0; margin-top: 2px; }
+        .tc-names { flex: 1; min-width: 0; }
+        .tc-input {
+            font-weight: 600; font-size: 0.87em;
+            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+            color: var(--text);
+        }
+        .tc-output {
+            font-size: 0.79em; color: var(--text2);
+            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+            margin-top: 1px;
+        }
+        .tc-badge {
+            flex-shrink: 0; font-size: 0.68em; font-weight: 600;
+            padding: 2px 7px; border-radius: 5px; margin-top: 2px;
+            letter-spacing: 0.03em;
+        }
+        .badge-running { background: var(--surface2); color: var(--accent); }
+        .badge-done    { background: var(--surface2); color: var(--success); }
+        .badge-failed  { background: var(--surface2); color: var(--danger);  }
+        .badge-unknown { background: var(--surface2); color: var(--text2); }
+
+        /* Progress bar */
+        .tc-prog-wrap { margin-bottom: 7px; }
+        .tc-prog-track {
+            height: 3px; background: var(--surface2); border-radius: 2px; overflow: hidden;
+        }
+        .tc-prog-fill {
+            height: 100%; background: var(--accent); border-radius: 2px;
+            transition: width 0.5s ease; min-width: 0;
+        }
+        .tc-prog-fill.done  { background: var(--success); }
+        .tc-prog-fill.error { background: var(--danger);  }
+        .tc-prog-label {
+            font-size: 0.72em; color: var(--text3); margin-top: 3px;
+            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+        }
+
+        /* Stats row */
+        .tc-stats {
+            display: flex; gap: 12px; font-size: 0.74em; color: var(--text2); margin-bottom: 9px;
+        }
+        .tc-stats span { display: flex; align-items: center; gap: 3px; }
+
+        /* Action buttons */
+        .tc-actions { display: flex; gap: 5px; flex-wrap: wrap; }
+        .tc-btn {
+            padding: 4px 10px; font-size: 0.76em; font-weight: 500;
+            border-radius: var(--r-xs);
+            border: 1px solid var(--border);
+            background: transparent;
+            color: var(--text2);
+            cursor: pointer;
+            transition: background 0.12s, color 0.12s;
+            display: flex; align-items: center; gap: 4px;
+            font-family: inherit;
+        }
+        .tc-btn:hover { background: var(--hover); color: var(--text); }
+        .tc-btn.primary {
+            background: var(--accent); color: var(--btn-text); border-color: var(--accent);
+        }
+        .tc-btn.primary:hover { background: var(--accent-h); border-color: var(--accent-h); }
+        .tc-btn.danger  { color: var(--danger); }
+        .tc-btn.danger:hover { background: var(--hover); }
+    </style>
+</head>
+<body class="light">
+<div id="appWrapper">
+
+    <!-- ════════════════ LEFT PANEL ════════════════ -->
+    <div id="leftPanel">
+        <div id="leftHeader">
+            <i class="film icon hdr-icon"></i>
+            <span>New Conversion</span>
+            <span class="hdr-spacer"></span>
+            <button id="themeToggleBtn" onclick="toggleTheme()" title="Toggle dark / light mode">
+                <i class="moon icon" id="themeIcon"></i>
+            </button>
+        </div>
+        <div id="leftBody">
+
+            <!-- Drop zone / system file browser -->
+            <div id="dropZone" onclick="openSystemFileSelector()">
+                <i class="cloud upload alternate icon dz-icon"></i>
+                <div class="dz-label">Click to browse system files</div>
+                <div class="dz-sub">or drag &amp; drop a file here</div>
+            </div>
+
+            <!-- Upload from local computer -->
+            <button id="uploadBtn" onclick="openLocalUploader()">
+                <i class="laptop icon"></i> Upload from Computer
+            </button>
+
+            <!-- Upload progress -->
+            <div id="uploadProgressWrap">
+                <div class="up-label">Uploading… <span id="uploadPct">0</span>%</div>
+                <div class="prog-track">
+                    <div class="prog-fill" id="uploadProgFill" style="width:0%"></div>
+                </div>
+            </div>
+
+            <!-- Selected file info -->
+            <div id="selectedFileInfo">
+                <span id="clearFileBtn" onclick="clearSelectedFile()" title="Clear selection">&#10005;</span>
+                <div id="selectedFileIcon"><i class="file outline icon"></i></div>
+                <div>
+                    <div id="selectedFileName">file.mp4</div>
+                    <div id="selectedFileMeta">user:/path/to/file.mp4</div>
+                </div>
+                <div style="clear:both"></div>
+            </div>
+
+            <!-- Output format -->
+            <label class="ff-label">Output Format</label>
+            <select id="outputFormat" class="ff-select" onchange="onFormatChange()">
+                <option value="">— select a file first —</option>
+            </select>
+
+            <!-- Audio options -->
+            <div id="audioOptions" class="options-section" style="display:none">
+                <label class="ff-label">Sample Rate</label>
+                <select id="sampleRate" class="ff-select">
+                    <option value="0">Original (unchanged)</option>
+                    <option value="8000">8,000 Hz</option>
+                    <option value="16000">16,000 Hz</option>
+                    <option value="22050">22,050 Hz</option>
+                    <option value="44100">44,100 Hz — CD quality</option>
+                    <option value="48000">48,000 Hz — HD audio</option>
+                    <option value="96000">96,000 Hz — High-res</option>
+                </select>
+            </div>
+
+            <!-- Video options -->
+            <div id="videoOptions" class="options-section" style="display:none">
+                <label class="ff-label">Resolution</label>
+                <select id="videoResolution" class="ff-select">
+                    <option value="">Original (unchanged)</option>
+                    <option value="144p">144p — Low bandwidth</option>
+                    <option value="240p">240p — SD</option>
+                    <option value="360p">360p</option>
+                    <option value="480p">480p — SD</option>
+                    <option value="576p">576p — PAL</option>
+                    <option value="720p">720p — HD</option>
+                    <option value="1080p">1080p — Full HD</option>
+                    <option value="1440p">1440p — 2K</option>
+                    <option value="2160p">2160p — 4K Ultra HD</option>
+                </select>
+                <label class="ff-label">
+                    Compression (CRF)
+                    <span class="ff-range-val"><span id="videoCompressionVal">0</span>% &nbsp;<small style="color:#bbb">(0 = encoder default)</small></span>
+                </label>
+                <input type="range" class="ff-range" id="videoCompression" min="0" max="100" value="0"
+                       oninput="document.getElementById('videoCompressionVal').textContent=this.value">
+            </div>
+
+            <!-- Image options -->
+            <div id="imageOptions" class="options-section" style="display:none">
+                <label class="ff-label">Scale Factor <small style="color:#bbb; font-weight:400">(1.0 = original, 0.5 = half size)</small></label>
+                <input type="number" id="imageScale" class="ff-input" step="0.05" min="0.05" max="10" value="1.0">
+
+                <div id="imageCompressionSection">
+                    <label class="ff-label">
+                        Quality Loss
+                        <span class="ff-range-val"><span id="imageQualityVal">0</span>% &nbsp;<small style="color:#bbb">(0 = best, 100 = most compressed)</small></span>
+                    </label>
+                    <input type="range" class="ff-range" id="imageQuality" min="0" max="100" value="0"
+                           oninput="document.getElementById('imageQualityVal').textContent=this.value">
+                </div>
+            </div>
+
+            <button id="convertBtn" onclick="startConversion()" disabled>
+                <i class="play icon"></i> Convert
+            </button>
+        </div><!-- /leftBody -->
+    </div><!-- /leftPanel -->
+
+    <!-- ════════════════ RIGHT PANEL ════════════════ -->
+    <div id="rightPanel">
+        <div id="rightHeader">
+            <i class="list ul icon hdr-icon"></i>
+            Task Queue
+            <span id="taskCountBadge">0</span>
+        </div>
+        <div id="taskListWrap">
+            <div id="emptyState">
+                <i class="film icon es-icon"></i>
+                <div class="es-title">No conversions yet</div>
+                <div class="es-sub">Select or upload a file on the left to get started.</div>
+            </div>
+            <div id="taskList"></div>
+        </div>
+    </div>
+
+</div><!-- /appWrapper -->
+
+<script>
+// ══════════════════════════════════════════════════════════════════
+//  FFmpeg Factory  –  frontend logic
+// ══════════════════════════════════════════════════════════════════
+
+// --- Extension groups ---
+var VIDEO_EXTS       = ["mp4","mkv","avi","mov","flv","webm"];
+var AUDIO_EXTS       = ["mp3","wav","aac","ogg","flac","m4a","opus"];
+var IMAGE_EXTS       = ["jpg","jpeg","png","gif","bmp","tiff","webp"];
+var LOSSY_IMAGE_EXTS = ["jpg","jpeg","webp"];
+
+// --- Global state ---
+var selectedFilePath   = null;
+var selectedFileUpload = false;
+var activeTasks        = {};   // taskId → { timer, data }
+
+// ──────────────────────────────────────────────
+// Theme  (preference key: ffmpeg_factory/theme)
+// ──────────────────────────────────────────────
+var PREF_KEY = "ffmpeg_factory/theme";
+
+function loadPref(key, cb) {
+    $.get("../../system/file_system/preference?key=" + encodeURIComponent(key), cb);
+}
+function savePref(key, value) {
+    $.get("../../system/file_system/preference?key=" + encodeURIComponent(key) + "&value=" + encodeURIComponent(value));
+}
+
+function applyTheme(isDark) {
+    if (isDark) {
+        $("body").removeClass("light").addClass("dark");
+        $("#themeIcon").attr("class", "sun icon");
+    } else {
+        $("body").removeClass("dark").addClass("light");
+        $("#themeIcon").attr("class", "moon icon");
+    }
+}
+
+function toggleTheme() {
+    var isDark = $("body").hasClass("dark");
+    applyTheme(!isDark);
+    savePref(PREF_KEY, isDark ? "light" : "dark");
+}
+
+function initTheme() {
+    // Step 1: apply system-wide theme immediately (same pattern as Notes app)
+    ao_module_getSystemThemeColor(function (sysTheme) {
+        applyTheme(sysTheme !== "whiteTheme");
+
+        // Step 2: check for a per-app manual override and apply it on top
+        loadPref(PREF_KEY, function (data) {
+            if (data && data !== "undefined" && data !== "" && !data.error) {
+                applyTheme(data === "dark");
+            }
+        });
+    });
+}
+
+// ──────────────────────────────────────────────
+// Safe JSON parse
+// jQuery auto-parses responses with Content-Type: application/json,
+// so the callback may already receive a JS object instead of a string.
+// ──────────────────────────────────────────────
+function safeJSON(data) {
+    if (data === null || data === undefined) return null;
+    if (typeof data === "object") return data;
+    if (typeof data === "string") {
+        try { return JSON.parse(data); } catch (e) { return null; }
+    }
+    return null;
+}
+
+// ──────────────────────────────────────────────
+// Initialisation
+// ──────────────────────────────────────────────
+$(document).ready(function () {
+    initTheme();
+
+    // Drag-and-drop onto drop zone
+    var dz = document.getElementById("dropZone");
+    dz.addEventListener("dragover", function (e) { e.preventDefault(); dz.classList.add("dragging"); });
+    dz.addEventListener("dragleave", function ()  { dz.classList.remove("dragging"); });
+    dz.addEventListener("drop", function (e) {
+        e.preventDefault();
+        dz.classList.remove("dragging");
+        var files = e.dataTransfer.files;
+        if (files.length > 0) handleLocalFile(files[0]);
+    });
+
+    // Load persisted tasks (session resume)
+    loadExistingTasks();
+
+    // File opened from the desktop file manager
+    var inputFiles = ao_module_loadInputFiles();
+    if (inputFiles && inputFiles.length > 0) {
+        var f = inputFiles[0];
+        var vpath = (typeof f === "string") ? f : (f.filepath || f.Filepath || "");
+        if (vpath) selectSourceFile(vpath, false);
+    }
+});
+
+// ──────────────────────────────────────────────
+// File selection
+// ──────────────────────────────────────────────
+function openSystemFileSelector() {
+    ao_module_openFileSelector(
+        handleFSSelection,
+        "user:/", "file", false,
+        { fnameOverride: "handleFSSelection" }
+    );
+}
+
+// Global callback for virtual-desktop mode (function name is looked up by the parent frame)
+function handleFSSelection(files) {
+    if (!files || files.length === 0) return;
+    var f = files[0];
+    var vpath = (typeof f === "string") ? f : (f.filepath || f.Filepath || "");
+    if (vpath) selectSourceFile(vpath, false);
+}
+
+function openLocalUploader() {
+    var input = document.createElement("input");
+    input.type = "file";
+    input.onchange = function (e) {
+        if (e.target.files.length > 0) handleLocalFile(e.target.files[0]);
+    };
+    input.click();
+}
+
+function handleLocalFile(file) {
+    // Ensure server-side upload directory exists, then upload
+    ao_module_agirun("FFmpeg Factory/agi/ensuredirs.agi", {}, function () {
+        $("#uploadProgressWrap").show();
+        $("#uploadProgFill").css("width", "0%");
+        $("#uploadPct").text("0");
+
+        ao_module_uploadFile(
+            file,
+            "tmp:/ffmpeg_factory/uploads/",
+            function () {
+                // Upload complete
+                $("#uploadProgressWrap").hide();
+                selectSourceFile("tmp:/ffmpeg_factory/uploads/" + file.name, true);
+            },
+            function (pct) {
+                var p = Math.round(pct);
+                $("#uploadProgFill").css("width", p + "%");
+                $("#uploadPct").text(p);
+            },
+            function (status) {
+                $("#uploadProgressWrap").hide();
+                alert("Upload failed (HTTP " + status + ").\nCheck that the file is not too large.");
+            }
+        );
+    });
+}
+
+function selectSourceFile(vpath, isUpload) {
+    selectedFilePath   = vpath;
+    selectedFileUpload = isUpload || false;
+
+    var parts    = vpath.split("/");
+    var filename = parts[parts.length - 1];
+    var ext      = filename.includes(".") ? filename.split(".").pop().toLowerCase() : "";
+
+    var icon = "file outline";
+    if (VIDEO_EXTS.includes(ext))  icon = "film";
+    else if (AUDIO_EXTS.includes(ext)) icon = "music";
+    else if (IMAGE_EXTS.includes(ext)) icon = "image";
+
+    $("#selectedFileIcon").html('<i class="' + icon + ' icon"></i>');
+    $("#selectedFileName").text(filename);
+    $("#selectedFileMeta").text(isUpload ? "Uploaded from this computer" : vpath);
+    $("#selectedFileInfo").show();
+
+    updateOutputFormats(ext);
+}
+
+function clearSelectedFile() {
+    selectedFilePath   = null;
+    selectedFileUpload = false;
+    $("#selectedFileInfo").hide();
+    $("#outputFormat").html('<option value="">— select a file first —</option>');
+    $("#audioOptions, #videoOptions, #imageOptions").hide();
+    $("#convertBtn").prop("disabled", true);
+}
+
+// ──────────────────────────────────────────────
+// Output format & options
+// ──────────────────────────────────────────────
+function updateOutputFormats(ext) {
+    var $fmt = $("#outputFormat").empty();
+
+    if (VIDEO_EXTS.includes(ext)) {
+        addOptGroup($fmt, "Video", [
+            ["mp4","MP4 (H.264)"], ["mkv","MKV (Matroska)"], ["avi","AVI"],
+            ["webm","WebM (VP9)"], ["mov","MOV (QuickTime)"]
+        ]);
+        addOptGroup($fmt, "Audio — extract from video", [
+            ["mp3","MP3"], ["aac","AAC"], ["wav","WAV (lossless)"],
+            ["flac","FLAC (lossless)"], ["ogg","OGG Vorbis"]
+        ]);
+        addOptGroup($fmt, "Animation", [["gif","GIF Animation"]]);
+
+    } else if (AUDIO_EXTS.includes(ext)) {
+        addOptGroup($fmt, "Audio", [
+            ["mp3","MP3"], ["aac","AAC"], ["wav","WAV (lossless)"],
+            ["flac","FLAC (lossless)"], ["ogg","OGG Vorbis"], ["opus","Opus"]
+        ]);
+
+    } else if (IMAGE_EXTS.includes(ext)) {
+        addOptGroup($fmt, "Image", [
+            ["jpg","JPEG (lossy)"], ["png","PNG (lossless)"],
+            ["webp","WebP"], ["bmp","BMP (lossless)"],
+            ["tiff","TIFF (lossless)"], ["gif","GIF"]
+        ]);
+
+    } else {
+        // Unknown type – offer common formats
+        addOptGroup($fmt, "Video",  [["mp4","MP4"], ["mkv","MKV"], ["avi","AVI"]]);
+        addOptGroup($fmt, "Audio",  [["mp3","MP3"], ["aac","AAC"], ["wav","WAV"]]);
+        addOptGroup($fmt, "Image",  [["jpg","JPEG"], ["png","PNG"]]);
+    }
+
+    onFormatChange();
+}
+
+function addOptGroup($sel, label, opts) {
+    var $g = $('<optgroup>').attr("label", label);
+    opts.forEach(function (o) { $g.append($('<option>').val(o[0]).text(o[1])); });
+    $sel.append($g);
+}
+
+function onFormatChange() {
+    var outputExt = $("#outputFormat").val() || "";
+    var inputExt  = selectedFilePath
+        ? selectedFilePath.split(".").pop().toLowerCase()
+        : "";
+    var convType  = getConvType(inputExt, outputExt);
+
+    $("#audioOptions").toggle(convType === "audio");
+    $("#videoOptions").toggle(convType === "video");
+
+    if (convType === "image") {
+        $("#imageOptions").show();
+        // Quality slider only useful for lossy formats
+        $("#imageCompressionSection").toggle(LOSSY_IMAGE_EXTS.includes(outputExt));
+    } else {
+        $("#imageOptions").hide();
+    }
+
+    $("#convertBtn").prop("disabled", !(selectedFilePath && outputExt));
+}
+
+function getConvType(inExt, outExt) {
+    if (IMAGE_EXTS.includes(inExt) && IMAGE_EXTS.includes(outExt)) return "image";
+    if ((VIDEO_EXTS.includes(inExt) || AUDIO_EXTS.includes(inExt)) && AUDIO_EXTS.includes(outExt)) return "audio";
+    if (VIDEO_EXTS.includes(inExt)  && VIDEO_EXTS.includes(outExt)) return "video";
+    return "generic";
+}
+
+// ──────────────────────────────────────────────
+// Start conversion
+// ──────────────────────────────────────────────
+function startConversion() {
+    if (!selectedFilePath) return;
+    var outputExt = $("#outputFormat").val();
+    if (!outputExt) return;
+
+    var inputExt = selectedFilePath.split(".").pop().toLowerCase();
+    var convType = getConvType(inputExt, outputExt);
+
+    // Collect format-specific options
+    var opts = {};
+    if (convType === "audio") {
+        opts.sample_rate = parseInt($("#sampleRate").val()) || 0;
+    } else if (convType === "video") {
+        opts.resolution  = $("#videoResolution").val() || "";
+        opts.compression = parseInt($("#videoCompression").val()) || 0;
+    } else if (convType === "image") {
+        opts.scale       = parseFloat($("#imageScale").val()) || 1.0;
+        if (LOSSY_IMAGE_EXTS.includes(outputExt)) {
+            opts.compression = parseInt($("#imageQuality").val()) || 0;
+        }
+    }
+
+    // Generate a unique task ID on the frontend so we can start polling immediately
+    var taskId = Date.now().toString(36) +
+                 Math.floor(Math.random() * 0x100000).toString(36);
+
+    // Compute the expected output path (same dir as source, new extension)
+    var lastSlash = selectedFilePath.lastIndexOf("/");
+    var inputDir  = selectedFilePath.substring(0, lastSlash);
+    var fname     = selectedFilePath.substring(lastSlash + 1);
+    var base      = fname.includes(".") ? fname.substring(0, fname.lastIndexOf(".")) : fname;
+    var outputVpath = inputDir + "/" + base + "." + outputExt;
+
+    // Build task data for the UI
+    var taskData = {
+        id:           taskId,
+        input_vpath:  selectedFilePath,
+        output_vpath: outputVpath,
+        conv_type:    convType,
+        options:      opts,
+        status:       "running",
+        created_at:   Date.now(),
+        isUpload:     selectedFileUpload
+    };
+
+    addTaskCard(taskData);
+    startPolling(taskId);
+    clearSelectedFile();
+
+    // Fire the long-running AGI request (no timeout → waits until ffmpeg finishes)
+    ao_module_agirun("FFmpeg Factory/agi/convert.agi", {
+        src:       taskData.input_vpath,
+        outputExt: outputExt,
+        convType:  convType,
+        taskId:    taskId,
+        options:   JSON.stringify(opts)
+    }, function (resp) {
+        stopPolling(taskId);
+        var result = safeJSON(resp);
+        if (result) {
+            if (result.success) {
+                finaliseTask(taskId, "completed", result.output, "");
+            } else {
+                finaliseTask(taskId, "failed", "", result.error || "Conversion failed");
+            }
+        } else {
+            // Response could not be parsed (e.g. server error page).
+            // Fallback: check the task file status – fast conversions may have
+            // already written "completed" before the first progress poll fires.
+            checkTaskFallback(taskId, outputVpath);
+        }
+    }, function () {
+        stopPolling(taskId);
+        finaliseTask(taskId, "failed", "", "Connection error or request timed out");
+    }, 0 /* no HTTP timeout */);
+}
+
+// ──────────────────────────────────────────────
+// Polling
+// ──────────────────────────────────────────────
+function startPolling(taskId) {
+    if (activeTasks[taskId] && activeTasks[taskId].timer) return;
+    if (!activeTasks[taskId]) activeTasks[taskId] = {};
+
+    activeTasks[taskId].timer = setInterval(function () {
+        ao_module_agirun("FFmpeg Factory/agi/progress.agi", { id: taskId }, function (resp) {
+            try {
+                var prog = JSON.parse(resp);
+                if (prog.error) return;          // file not yet created
+                renderProgress(taskId, prog);
+                if (prog.completed) {
+                    stopPolling(taskId);
+                    // progress shows done but the convert.agi AJAX may still be in-flight;
+                    // finaliseTask will be called again when it returns (idempotent)
+                    finaliseTask(taskId, "completed", activeTasks[taskId] && activeTasks[taskId].outputVpath || "", "");
+                }
+            } catch (e) {}
+        });
+    }, 1000);
+}
+
+function stopPolling(taskId) {
+    if (activeTasks[taskId] && activeTasks[taskId].timer) {
+        clearInterval(activeTasks[taskId].timer);
+        activeTasks[taskId].timer = null;
+    }
+}
+
+// ──────────────────────────────────────────────
+// Task card rendering
+// ──────────────────────────────────────────────
+function addTaskCard(task) {
+    $("#emptyState").hide();
+
+    var inFile  = task.input_vpath.split("/").pop();
+    var outFile = task.output_vpath.split("/").pop();
+    var icon    = convTypeIcon(task.conv_type);
+
+    var html =
+        '<div class="task-card" id="tc-' + task.id + '">' +
+
+          '<div class="tc-row1">' +
+            '<i class="' + icon + ' icon tc-icon"></i>' +
+            '<div class="tc-names">' +
+              '<div class="tc-input" title="' + esc(task.input_vpath) + '">' + esc(inFile) + '</div>' +
+              '<div class="tc-output">\u2192 ' + esc(outFile) + '</div>' +
+            '</div>' +
+            '<span class="tc-badge badge-running">Running</span>' +
+          '</div>' +
+
+          '<div class="tc-prog-wrap">' +
+            '<div class="tc-prog-track"><div class="tc-prog-fill" style="width:0%"></div></div>' +
+            '<div class="tc-prog-label">Waiting for ffmpeg\u2026</div>' +
+          '</div>' +
+
+          '<div class="tc-stats">' +
+            '<span title="Input size"><i class="arrow down icon"></i> <b class="stat-in">—</b></span>' +
+            '<span title="Output size"><i class="arrow up icon"></i> <b class="stat-out">—</b></span>' +
+            '<span title="Elapsed time"><i class="clock outline icon"></i> <b class="stat-time">0s</b></span>' +
+          '</div>' +
+
+          '<div class="tc-actions">' +
+            '<button class="tc-btn primary tc-action-open" style="display:none" ' +
+                    'onclick="openTaskOutput(\'' + task.id + '\')"><i class="folder open icon"></i> Open Location</button>' +
+            '<button class="tc-btn primary tc-action-download" style="display:none" ' +
+                    'onclick="downloadTaskOutput(\'' + task.id + '\')"><i class="download icon"></i> Download</button>' +
+            '<button class="tc-btn danger" ' +
+                    'onclick="dismissTask(\'' + task.id + '\')"><i class="times icon"></i> Dismiss</button>' +
+          '</div>' +
+
+        '</div>';
+
+    $("#taskList").prepend($(html));
+
+    if (!activeTasks[task.id]) activeTasks[task.id] = {};
+    activeTasks[task.id].data        = task;
+    activeTasks[task.id].outputVpath = task.output_vpath;
+    activeTasks[task.id].isUpload    = task.isUpload || false;
+
+    refreshTaskCount();
+}
+
+function renderProgress(taskId, prog) {
+    var $c = $("#tc-" + taskId);
+    if (!$c.length) return;
+
+    var pct = Math.max(0, Math.min(100, Math.round(prog.percentage || 0)));
+    $c.find(".tc-prog-fill").css("width", pct + "%");
+    $c.find(".tc-prog-label").text("Converting\u2026 " + pct + "%");
+
+    if (prog.input_size  > 0) $c.find(".stat-in").text(fmtBytes(prog.input_size));
+    if (prog.output_size > 0) $c.find(".stat-out").text(fmtBytes(prog.output_size));
+    $c.find(".stat-time").text(fmtTime(prog.conversion_time));
+}
+
+function finaliseTask(taskId, status, outputVpath, errMsg) {
+    var $c = $("#tc-" + taskId);
+    if (!$c.length) return;
+
+    // Idempotent guard – do not overwrite a "completed" card
+    if ($c.find(".tc-badge").hasClass("badge-done")) return;
+
+    stopPolling(taskId);
+
+    var $fill  = $c.find(".tc-prog-fill");
+    var $badge = $c.find(".tc-badge");
+    var $label = $c.find(".tc-prog-label");
+
+    if (status === "completed") {
+        $fill.css("width", "100%").addClass("done");
+        $badge.removeClass("badge-running badge-failed badge-unknown").addClass("badge-done").text("Done");
+        $label.text("Conversion complete.");
+
+        // Store output path for action buttons
+        if (activeTasks[taskId]) activeTasks[taskId].outputVpath = outputVpath || activeTasks[taskId].outputVpath;
+        var vpath = (activeTasks[taskId] && activeTasks[taskId].outputVpath) || outputVpath;
+
+        if (vpath) {
+            var isUp = activeTasks[taskId] && activeTasks[taskId].isUpload;
+            if (isUp) {
+                $c.find(".tc-action-download").attr("data-vpath", vpath).show();
+            } else {
+                $c.find(".tc-action-open").attr("data-vpath", vpath).show();
+            }
+        }
+    } else if (status === "failed") {
+        $fill.css("width", "100%").addClass("error");
+        $badge.removeClass("badge-running badge-done badge-unknown").addClass("badge-failed").text("Failed");
+        $label.text("Error: " + (errMsg || "unknown error"));
+    } else {
+        $fill.css("width", "0%");
+        $badge.removeClass("badge-running badge-done badge-failed").addClass("badge-unknown").text("Unknown");
+        $label.text(errMsg || "State unknown — may have finished while offline.");
+    }
+}
+
+function openTaskOutput(taskId) {
+    var vpath = $("#tc-" + taskId + " .tc-action-open").attr("data-vpath");
+    if (!vpath) return;
+    var dir   = vpath.substring(0, vpath.lastIndexOf("/"));
+    var fname = vpath.split("/").pop();
+    ao_module_openPath(dir, fname);
+}
+
+function downloadTaskOutput(taskId) {
+    var vpath = $("#tc-" + taskId + " .tc-action-download").attr("data-vpath");
+    if (!vpath) return;
+    window.open(ao_root + "media/download/?file=" + encodeURIComponent(vpath));
+}
+
+function dismissTask(taskId) {
+    stopPolling(taskId);
+    $("#tc-" + taskId).remove();
+    delete activeTasks[taskId];
+    ao_module_agirun("FFmpeg Factory/agi/dismiss.agi", { id: taskId }, function () {});
+    refreshTaskCount();
+    if ($("#taskList .task-card").length === 0) $("#emptyState").show();
+}
+
+function refreshTaskCount() {
+    var n = $("#taskList .task-card").length;
+    if (n > 0) { $("#taskCountBadge").text(n).show(); }
+    else        { $("#taskCountBadge").hide(); }
+}
+
+// ──────────────────────────────────────────────
+// Session resume
+// ──────────────────────────────────────────────
+// ──────────────────────────────────────────────
+// Fallback: if convert.agi response can't be parsed (small/fast files finish
+// before the first progress poll) — query tasks.agi for the recorded status.
+// ──────────────────────────────────────────────
+function checkTaskFallback(taskId, outputVpath) {
+    ao_module_agirun("FFmpeg Factory/agi/tasks.agi", {}, function (resp) {
+        var tasks = safeJSON(resp);
+        if (Array.isArray(tasks)) {
+            for (var i = 0; i < tasks.length; i++) {
+                var t = tasks[i];
+                if (t.id === taskId || t.task_id === taskId) {
+                    if (t.status === "completed") {
+                        finaliseTask(taskId, "completed", t.output_vpath || outputVpath || "", "");
+                    } else {
+                        finaliseTask(taskId, "failed", "", t.error || "Conversion failed");
+                    }
+                    return;
+                }
+            }
+        }
+        // Task not found — the task object may have already been cleaned up
+        if (activeTasks[taskId]) {
+            finaliseTask(taskId, "failed", "", "Server response could not be parsed");
+        }
+    });
+}
+
+function loadExistingTasks() {
+    ao_module_agirun("FFmpeg Factory/agi/tasks.agi", {}, function (resp) {
+        var tasks = safeJSON(resp);
+        if (!Array.isArray(tasks) || tasks.length === 0) return;
+
+        tasks.forEach(function (task) {
+            task.isUpload = task.input_vpath &&
+                            task.input_vpath.indexOf("tmp:/ffmpeg_factory/uploads/") === 0;
+            addTaskCard(task);
+
+            // Check current progress file to determine real status
+            ao_module_agirun("FFmpeg Factory/agi/progress.agi", { id: task.id }, function (progResp) {
+                var prog = safeJSON(progResp);
+
+                if (task.status === "completed" || (prog && prog.completed)) {
+                    finaliseTask(task.id, "completed", task.output_vpath, "");
+
+                } else if (task.status === "failed") {
+                    finaliseTask(task.id, "failed", "", task.error || "");
+
+                } else if (task.status === "running" && prog && !prog.error) {
+                    // Still running on the server — resume live polling
+                    renderProgress(task.id, prog);
+                    startPolling(task.id);
+
+                } else {
+                    // No progress file or unknown state; the conversion may have ended
+                    // while the browser was closed. Mark as indeterminate.
+                    finaliseTask(task.id, "unknown", task.output_vpath,
+                        "Conversion state unknown — check if the output file exists.");
+                }
+            });
+        });
+    });
+}
+
+// ──────────────────────────────────────────────
+// Helpers
+// ──────────────────────────────────────────────
+function convTypeIcon(t) {
+    switch (t) {
+        case "audio":   return "music";
+        case "video":   return "film";
+        case "image":   return "image outline";
+        default:        return "exchange alternate";
+    }
+}
+
+function fmtBytes(b) {
+    if (!b || b <= 0) return "0 B";
+    var u = ["B","KB","MB","GB"];
+    var i = Math.floor(Math.log(b) / Math.log(1024));
+    i = Math.min(i, u.length - 1);
+    return (b / Math.pow(1024, i)).toFixed(1) + "\u202f" + u[i];
+}
+
+function fmtTime(s) {
+    if (!s || s < 0) return "0s";
+    if (s < 60) return Math.round(s) + "s";
+    return Math.floor(s / 60) + "m\u202f" + (Math.round(s % 60)) + "s";
+}
+
+function esc(str) {
+    return String(str)
+        .replace(/&/g, "&amp;").replace(/</g, "&lt;")
+        .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
+}
+</script>
+</body>
+</html>

+ 25 - 0
src/web/FFmpeg Factory/init.agi

@@ -0,0 +1,25 @@
+/*
+	FFmpeg Factory
+	CopyRight tobychui 2020 - 2026
+
+	This is a port of the ArOZ Online Beta FFmpeg Factory to the ArOZ Online 1.0
+	If you are running on Windows, ffmpeg must be installed in your %PATH% environment 
+	variable before you can actually use this module.
+*/
+
+//Define the launchInfo for the module
+var moduleLaunchInfo = {
+    Name: "FFmpeg Factory",
+	Group: "Media",
+	IconPath: "FFmpeg Factory/img/small_icon.png",
+	Version: "3.0",
+	StartDir: "FFmpeg Factory/index.html",
+	SupportFW: true,
+	LaunchFWDir: "FFmpeg Factory/index.html",
+	InitFWSize: [1150, 640],
+	SupportedExt: []
+}
+
+
+registerModule(JSON.stringify(moduleLaunchInfo));
+