diff --git a/cmd/server/main.go b/cmd/server/main.go index 8ca8c25..781b5d6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -84,6 +84,7 @@ func run() error { queue.Register(jobs.TypeConvert, ffmpeg) queue.Register(jobs.TypeCompress, ffmpeg) queue.Register(jobs.TypeImage, image) + queue.Register(jobs.TypeEdit, ffmpeg) if n, err := queue.Resume(ctx); err != nil { log.Warn("resume queued jobs", "err", err) } else if n > 0 { diff --git a/docs/api.md b/docs/api.md index b344dcd..8098551 100644 --- a/docs/api.md +++ b/docs/api.md @@ -239,8 +239,8 @@ or a file that is no longer on disk, 500 `upload_failed` for a write error. } ``` -`type` is `download`, `convert`, `compress` or `image`. A `download` job needs -`url`; the other three need `upload_id` instead. `params` is validated against +`type` is `download`, `convert`, `compress`, `image` or `edit`. A `download` job +needs `url`; the others need `upload_id` instead. `params` is validated against the type-specific struct with unknown fields rejected - a typo'd key is a 400, not a silently ignored option. @@ -276,6 +276,16 @@ which side of the threshold becomes paths), `quality` (1–100, default 82), `width`, `height` (0 keeps that dimension, max 20000), `strip_metadata` (default true), `retention_days`. +`edit`: `start` and `end` in seconds (`end: 0` runs to the end of the file, and +the cut must keep at least a tenth of a second), `crop_x`, `crop_y`, `crop_w`, +`crop_h` in source pixels (`crop_w: 0` keeps the whole frame; width and height +are set together, rounded down to an even number and at least 16), +`retention_days`. The cut lands exactly where it was asked for, which a stream +copy cannot do - it can only start on a keyframe - so video is re-encoded to +H.264 at CRF 18 while the audio is copied, landing in MP4 or, where H.264 does +not belong, MKV. A file with no video track (mp3, m4a, flac, ...) is copied +whole and keeps its own container. + Validation is not only per field. Container/codec combinations ffmpeg would refuse to mux are rejected up front - WebM only takes VP9 or AV1 with Opus, MP4 and MOV reject VP9 and FLAC. `video_codec: "copy"` with a non-custom preset is diff --git a/frontend/app.css b/frontend/app.css index ae40a48..4468632 100644 --- a/frontend/app.css +++ b/frontend/app.css @@ -1143,9 +1143,9 @@ th.num { display: block; font-size: var(--fs-micro); } -/* Echoes the queue flyout's own bar, so a running job reads the same way - whether it is watched from the card or the docked queue. */ + .telemetry .bar { + display: none; margin-top: 5px; } @@ -1301,8 +1301,8 @@ th.num { position: relative; z-index: 1; grid-area: 1 / 1; - width: 13px; - height: 13px; + width: 16px; + height: 16px; display: block; } .queue-ring > * { @@ -1798,8 +1798,9 @@ th.num { align-items: baseline; gap: var(--sp-2); } - /* The card-bottom rail would just repeat this bar; one progress - indicator per running job is plenty. */ + .telemetry .bar { + display: block; + } .job[data-status="running"] .job-rail { display: none; } @@ -2048,3 +2049,217 @@ select.flash-b ~ .xsel-btn { .library-item .file-name { display: block; } + +/* editor - trim and crop */ + +.editor-card { + width: min(94vw, 760px); +} + +.editor-inner { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.editor-body { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-4); + min-height: 0; + overflow-y: auto; +} + +.ed-frame { + position: relative; + width: 100%; + margin-inline: auto; + overflow: hidden; + border-radius: var(--radius); + background: #000; +} +.ed-frame video { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +/* Nothing to show for an audio file - the track below is the whole editor. */ +.ed-frame.is-audio { + height: 0; + background: transparent; +} + +.ed-busy { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.55); + color: #fff; + pointer-events: none; +} +.ed-busy svg { + width: 28px; + height: 28px; +} + +/* The spread shadow dims everything outside the box; the frame clips it. */ +.ed-crop { + position: absolute; + touch-action: none; + cursor: move; + border: 1px solid #fff; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); +} +/* Corner brackets rather than dots: they show which edges a grip moves, and + the ::before widens the target well past the ink for a fingertip. */ +.ed-grip { + position: absolute; + width: 20px; + height: 20px; + border: 3px solid #fff; + touch-action: none; +} +.ed-grip::before { + content: ""; + position: absolute; + inset: -12px; +} +.ed-grip.nw { left: -2px; top: -2px; border-right: 0; border-bottom: 0; cursor: nwse-resize; } +.ed-grip.ne { right: -2px; top: -2px; border-left: 0; border-bottom: 0; cursor: nesw-resize; } +.ed-grip.sw { left: -2px; bottom: -2px; border-right: 0; border-top: 0; cursor: nesw-resize; } +.ed-grip.se { right: -2px; bottom: -2px; border-left: 0; border-top: 0; cursor: nwse-resize; } + +.ed-track { + position: relative; + height: 38px; + margin-inline: 8px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-3); + touch-action: none; + cursor: pointer; +} +.ed-keep { + position: absolute; + top: 0; + bottom: 0; + background: var(--focus-soft); +} +.ed-head { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background: var(--run); +} +.ed-handle { + position: absolute; + top: -4px; + bottom: -4px; + width: 12px; + margin-left: -6px; + border-radius: 3px; + background: var(--ink); + cursor: ew-resize; + touch-action: none; +} +/* An invisible skirt, so the grab area is a thumb wide without the bar being. */ +.ed-handle::before { + content: ""; + position: absolute; + inset: -4px -9px; +} +/* A hairline down the middle reads as a grip without another element. */ +.ed-handle::after { + content: ""; + position: absolute; + left: 50%; + top: 12px; + bottom: 12px; + width: 2px; + margin-left: -1px; + border-radius: 1px; + background: var(--surface); +} + +.ed-times { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--fs-sm); + color: var(--muted); +} + +.ed-stat { + display: inline-flex; + align-items: baseline; + gap: 6px; + padding: 3px var(--sp-2); + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-2); +} +.ed-stat .mono { + color: var(--text); +} +.ed-stat-k { + font-size: var(--fs-micro); + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--muted); +} +/* The length is what the user is actually choosing, so it leads. */ +.ed-stat.is-kept { + margin-left: auto; + border-color: var(--line-strong); +} +.ed-stat.is-kept .mono { + font-weight: 600; +} + +.ed-foot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2); +} +.ed-play-ico { + display: inline-flex; +} +.ed-play-ico > :nth-child(2) { + display: none; +} +.ed-play-ico.is-playing > :nth-child(1) { + display: none; +} +.ed-play-ico.is-playing > :nth-child(2) { + display: inline-block; +} +.ed-reset { + margin-left: auto; +} +.btn[aria-pressed="true"] { + border-color: var(--ink); + background: var(--ink); + color: var(--ink-text); +} +.btn[aria-pressed="true"]:hover:not(:disabled) { + border-color: var(--ink-hover); + background: var(--ink-hover); + color: var(--ink-text); +} + +@media (max-width: 560px) { + .ed-stat { + padding: 3px 6px; + } + .ed-times { + gap: 6px; + } +} diff --git a/frontend/app.js b/frontend/app.js index e60adc2..62603a1 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -32,8 +32,45 @@ var TYPE_ICON = { convert: "repeat", compress: "minimize-2", image: "image", + edit: "scissors", }; +/* Smallest the crop box may be pinched to, as a fraction of the frame. */ +var CROP_MIN = 0.05; + +function clamp(v, lo, hi) { + return v < lo ? lo : v > hi ? hi : v; +} + +/* Applies one drag to the crop box. `mode` is "move" or a compass corner, and + every value is a fraction of the frame, so the player's size never matters. */ +function cropDrag(c, mode, dx, dy) { + if (mode === "move") { + return { + x: clamp(c.x + dx, 0, 1 - c.w), + y: clamp(c.y + dy, 0, 1 - c.h), + w: c.w, + h: c.h, + }; + } + var l = c.x, + t = c.y, + r = c.x + c.w, + b = c.y + c.h; + if (mode.indexOf("w") !== -1) l = clamp(l + dx, 0, r - CROP_MIN); + if (mode.indexOf("e") !== -1) r = clamp(r + dx, l + CROP_MIN, 1); + if (mode.indexOf("n") !== -1) t = clamp(t + dy, 0, b - CROP_MIN); + if (mode.indexOf("s") !== -1) b = clamp(b + dy, t + CROP_MIN, 1); + return { x: l, y: t, w: r - l, h: b - t }; +} + +function clockTime(sec) { + var s = Math.max(0, Number(sec) || 0); + var m = Math.floor(s / 60); + var rest = s - m * 60; + return m + ":" + (rest < 10 ? "0" : "") + rest.toFixed(1); +} + /* The two axes the URL carries: "#jobs", "#new/compress". These are tabs, not wire job types - Convert and Compress both send an "image" job for images. */ var JOBS_PER_PAGE = 50; @@ -151,6 +188,7 @@ var TYPE_LABEL = { convert: "Convert", compress: "Compress", image: "Image", + edit: "Edit", }; /* Queued and running are the states still in play; the rest are terminal. */ @@ -528,6 +566,8 @@ function mediaApp() { save: emptySave(), /* The open preview overlay: null when closed, else {job, kind, url}. */ preview: null, + /* The open trim/crop editor: null when closed. */ + editor: null, submitting: "", /* Keyed by tab: an image job from either file tab reports its errors @@ -2017,6 +2057,280 @@ function mediaApp() { this.preview = null; }, + /* Trimming a still makes no sense, and cropping one is the Convert tab's job. */ + canEdit(job) { + return ( + job.status === "done" && + !!job.output_name && + this.mediaKind(job) !== "image" + ); + }, + + openEditor(job) { + this.editor = { + job: job, + kind: this.mediaKind(job), + url: this.downloadUrl(job), + duration: 0, + start: 0, + end: 0, + at: 0, + cropOn: false, + playing: false, + loading: true, + crop: { x: 0, y: 0, w: 1, h: 1 }, + natural: { w: 0, h: 0 }, + busy: false, + error: "", + }; + this.refreshIcons(); + }, + + closeEditor() { + this.editor = null; + }, + + /* The player is the only thing that knows the real length and frame size. */ + editorReady(ev) { + if (!this.editor) return; + var el = ev.target; + this.editor.duration = isFinite(el.duration) ? el.duration : 0; + this.editor.end = this.editor.duration; + this.editor.natural = { w: el.videoWidth || 0, h: el.videoHeight || 0 }; + }, + + /* The player runs the whole file; only the cut is worth previewing, so + playback is held inside it. Each correction lands on the boundary itself, + so the next tick cannot bounce off it again. */ + editorTick(ev) { + var e = this.editor; + if (!e) return; + var el = ev.target; + if (e.end > 0 && el.currentTime > e.end) { + el.pause(); + el.currentTime = e.end; + } else if (el.currentTime < e.start) { + el.currentTime = e.start; + } + e.at = el.currentTime; + }, + + togglePlay() { + var el = this.$refs.edMedia; + var e = this.editor; + if (!el) return; + if (!el.paused) { + el.pause(); + return; + } + if (el.currentTime < e.start || el.currentTime >= e.end - 0.05) { + this.seekEditor(e.start); + } + el.play(); + }, + + editorCanCrop() { + return !!(this.editor && this.editor.natural.w && this.editor.kind === "video"); + }, + + toggleCrop() { + var e = this.editor; + e.cropOn = !e.cropOn; + if (e.cropOn && e.crop.w > 0.99 && e.crop.h > 0.99) { + e.crop = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }; + } + }, + + resetEditor() { + var e = this.editor; + e.start = 0; + e.end = e.duration; + e.crop = { x: 0, y: 0, w: 1, h: 1 }; + e.cropOn = false; + this.seekEditor(0); + }, + + editFrame() { + var e = this.editor; + var ratio = e && e.natural.w ? e.natural.w / e.natural.h : 16 / 9; + /* Caps the height without letterboxing: the crop box only maps onto the + source while the picture fills the frame exactly. */ + return ( + "aspect-ratio:" + ratio + + ";max-width:min(100%, calc(52vh * " + ratio + "))" + ); + }, + + pct(v) { + return (clamp(v, 0, 1) * 100).toFixed(3) + "%"; + }, + + cropStyle() { + var c = this.editor.crop; + return ( + "left:" + this.pct(c.x) + ";top:" + this.pct(c.y) + + ";width:" + this.pct(c.w) + ";height:" + this.pct(c.h) + ); + }, + + editTime(sec) { + return clockTime(sec); + }, + + editSpan() { + var e = this.editor; + return e ? Math.max(0, e.end - e.start) : 0; + }, + + /* Something has to change, or the job would just copy the file back. */ + editReady() { + var e = this.editor; + if (!e || !e.duration || e.busy) return false; + var cropped = e.cropOn && (e.crop.w < 0.999 || e.crop.h < 0.999); + return cropped || e.start > 0.05 || e.end < e.duration - 0.05; + }, + + /* Shared by the handles and the scrubber: the pointer is followed on the + element it went down on, so a finger that slides off the track keeps + dragging what it grabbed. */ + trackDrag(ev, onTime) { + var e = this.editor; + if (!e || !e.duration) return; + ev.preventDefault(); + var el = ev.currentTarget; + var track = this.$refs.edTrack; + var at = function (clientX) { + var r = track.getBoundingClientRect(); + return clamp((clientX - r.left) / r.width, 0, 1) * e.duration; + }; + var move = function (m) { + onTime(at(m.clientX)); + }; + var up = function () { + el.removeEventListener("pointermove", move); + el.removeEventListener("pointerup", up); + el.removeEventListener("pointercancel", up); + }; + el.setPointerCapture(ev.pointerId); + el.addEventListener("pointermove", move); + el.addEventListener("pointerup", up); + el.addEventListener("pointercancel", up); + onTime(at(ev.clientX)); + }, + + trimDown(which, ev) { + ev.stopPropagation(); // or the track would scrub underneath the handle + var self = this; + this.trackDrag(ev, function (t) { + self.setTrim(which, t); + }); + }, + + scrubDown(ev) { + var self = this; + this.trackDrag(ev, function (t) { + self.seekEditor(clamp(t, self.editor.start, self.editor.end)); + }); + }, + + setTrim(which, t) { + var e = this.editor; + var gap = Math.min(0.2, e.duration / 20); + if (which === "start") e.start = clamp(t, 0, e.end - gap); + else e.end = clamp(t, e.start + gap, e.duration); + this.seekEditor(which === "start" ? e.start : e.end); + }, + + seekEditor(t) { + var el = this.$refs.edMedia; + if (!el) return; + this.editor.at = t; // a paused player reports the move only on `seeked` + try { + el.currentTime = t; + } catch (err) {} // a source still loading its index refuses the seek + }, + + dragCrop(mode, ev) { + var e = this.editor; + if (!e) return; + ev.preventDefault(); + ev.stopPropagation(); + var box = this.$refs.edFrame.getBoundingClientRect(); + var from = { x: e.crop.x, y: e.crop.y, w: e.crop.w, h: e.crop.h }; + var ox = ev.clientX; + var oy = ev.clientY; + var el = ev.currentTarget; + var move = function (m) { + e.crop = cropDrag( + from, + mode, + (m.clientX - ox) / box.width, + (m.clientY - oy) / box.height, + ); + }; + var up = function () { + el.removeEventListener("pointermove", move); + el.removeEventListener("pointerup", up); + el.removeEventListener("pointercancel", up); + }; + el.setPointerCapture(ev.pointerId); + el.addEventListener("pointermove", move); + el.addEventListener("pointerup", up); + el.addEventListener("pointercancel", up); + }, + + /* The box is kept as fractions; ffmpeg wants source pixels. */ + editParams() { + var e = this.editor; + var c = e.crop; + var round3 = function (v) { + return Math.round(v * 1000) / 1000; + }; + var p = { + start: round3(e.start), + end: e.end >= e.duration - 0.05 ? 0 : round3(e.end), + }; + if (e.cropOn && e.natural.w && (c.w < 0.999 || c.h < 0.999)) { + p.crop_x = Math.round(c.x * e.natural.w); + p.crop_y = Math.round(c.y * e.natural.h); + p.crop_w = Math.min(Math.round(c.w * e.natural.w), e.natural.w - p.crop_x); + p.crop_h = Math.min(Math.round(c.h * e.natural.h), e.natural.h - p.crop_y); + } + return p; + }, + + /* The source is the finished file, so it is relinked as an upload first. */ + async submitEdit() { + var e = this.editor; + if (!e || e.busy) return; + e.busy = true; + e.error = ""; + try { + var reused = await api("/jobs/" + e.job.id + "/reuse", { + method: "POST", + body: { source: "output" }, + }); + var job = await api("/jobs", { + method: "POST", + body: { + type: "edit", + upload_id: reused.upload_id, + params: this.editParams(), + }, + }); + this.upsertJob(job); + this.enqueue(job.id); + this.announce = "Edit job queued."; + this.closeEditor(); + } catch (err) { + if (err instanceof OfflineError) this.offline = err.message; + e.error = err.message; + } finally { + if (this.editor) this.editor.busy = false; + this.refreshIcons(); + } + }, + /* Only where a plain would strand the user: see isStandalone. Everywhere else the anchor is left alone. */ managedSave() { @@ -2203,6 +2517,7 @@ function mediaApp() { delete this.live[id]; this.dismissFromQueue(id); if (this.preview && this.preview.job.id === id) this.closePreview(); + if (this.editor && this.editor.job.id === id) this.closeEditor(); }, /* Back in the foreground. A suspended app keeps an EventSource that still diff --git a/frontend/index.html b/frontend/index.html index 2cde3d1..2812aab 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1490,6 +1490,16 @@

Jobs

Preview +
Settings + +
+
+ +
+
+
editMaxSeconds { + return nil, invalid("start", "is outside the file") + } + if p.End < 0 || p.End > editMaxSeconds { + return nil, invalid("end", "is outside the file") + } + if p.End > 0 && p.End-p.Start < editMinSpan { + return nil, invalid("end", "must be at least a tenth of a second after the start") + } + if err := p.checkCrop(); err != nil { + return nil, err + } + if err := checkRetention(p.RetentionDays); err != nil { + return nil, err + } + return p, nil +} + +func (p *EditParams) checkCrop() error { + if p.CropW == 0 && p.CropH == 0 { + if p.CropX != 0 || p.CropY != 0 { + return invalid("crop_w", "an offset needs a crop size as well") + } + return nil + } + // H.264 cannot encode an odd width or height, so a stray pixel is dropped. + p.CropW &^= 1 + p.CropH &^= 1 + for field, v := range map[string]int{"crop_w": p.CropW, "crop_h": p.CropH} { + if v < cropMinPixels || v > cropMaxPixels { + return invalid(field, fmt.Sprintf("must be between %d and %d pixels", cropMinPixels, cropMaxPixels)) + } + } + if p.CropX < 0 || p.CropY < 0 || p.CropX > cropMaxPixels || p.CropY > cropMaxPixels { + return invalid("crop_x", "is outside the frame") + } + return nil +} + +func (p *EditParams) HasCrop() bool { return p.CropW > 0 && p.CropH > 0 } + +// Span is the output length in seconds, 0 when the cut runs to the end. +func (p *EditParams) Span() float64 { + if p.End <= p.Start { + return 0 + } + return p.End - p.Start +} + // ResolveFormat needs the input path because "source" is only decided once // there is a file: the extension picks an entry from imageFormatByExt, and a // type we cannot write back out is refused here rather than at ImageMagick. diff --git a/internal/runner/ffmpeg.go b/internal/runner/ffmpeg.go index e6f51ab..ac05ef8 100644 --- a/internal/runner/ffmpeg.go +++ b/internal/runner/ffmpeg.go @@ -16,7 +16,7 @@ import ( "aiofiles/internal/presets" ) -// FFmpeg serves both TypeConvert and TypeCompress jobs. +// FFmpeg serves the TypeConvert, TypeCompress and TypeEdit jobs. type FFmpeg struct { cfg *config.Config log *slog.Logger @@ -97,6 +97,10 @@ func (r *FFmpeg) Run(ctx context.Context, j *jobs.Job, emit jobs.Emit) (jobs.Res return res, errors.New("ffmpeg: input path is a directory") } + if j.Type == jobs.TypeEdit { + return r.runEdit(ctx, j, input, emit) + } + spec, err := r.specFor(j) if err != nil { return res, err @@ -151,7 +155,7 @@ func (r *FFmpeg) Run(ctx context.Context, j *jobs.Job, emit jobs.Emit) (jobs.Res func (r *FFmpeg) runOnePass(ctx context.Context, input, output string, s encodeSpec, duration float64, emit jobs.Emit) error { emitStage(emit, s.Stage) - return r.encode(ctx, input, output, s, &ffProgress{duration: duration, stage: s.Stage}, emit) + return r.encode(ctx, ffmpegArgs(input, output, s), &ffProgress{duration: duration, stage: s.Stage}, emit) } // runTwoPass drives the bitrate-targeted encode. Pass 1 only writes the analysis @@ -177,7 +181,7 @@ func (r *FFmpeg) runTwoPass(ctx context.Context, j *jobs.Job, input, output stri s.Pass = p.pass emitPercent(emit, p.stage, p.base) prog := &ffProgress{duration: duration, stage: p.stage, base: p.base, span: 50} - if err := r.encode(ctx, input, p.output, s, prog, emit); err != nil { + if err := r.encode(ctx, ffmpegArgs(input, p.output, s), prog, emit); err != nil { return err } } @@ -185,16 +189,122 @@ func (r *FFmpeg) runTwoPass(ctx context.Context, j *jobs.Job, input, output stri } // encode runs one ffmpeg invocation, forwarding its -progress block as updates. -func (r *FFmpeg) encode(ctx context.Context, input, output string, s encodeSpec, prog *ffProgress, emit jobs.Emit) error { +func (r *FFmpeg) encode(ctx context.Context, args []string, prog *ffProgress, emit jobs.Emit) error { onLine := func(line string) { if p, ok := prog.line(line); ok && emit != nil { emit(p) } } - return runProc(ctx, r.log, r.cfg.FFmpegBin, ffmpegArgs(input, output, s), - procOpts{OnStdout: onLine}) + return runProc(ctx, r.log, r.cfg.FFmpegBin, args, procOpts{OnStdout: onLine}) } +// runEdit trims and optionally crops. The cut has to land where the user put +// the handles, and a stream copy can only start on a keyframe - seconds out on +// a long GOP - so video is re-encoded at visually lossless quality. Audio is +// copied either way, and a file with no video track is copied whole. +func (r *FFmpeg) runEdit(ctx context.Context, j *jobs.Job, input string, emit jobs.Emit) (jobs.Result, error) { + var res jobs.Result + + p, err := presets.ParseEdit(j.Params, defaultRetentionDays) + if err != nil { + return res, err + } + + emitStage(emit, "probing") + span := p.Span() + if span <= 0 { + // Only the progress bar needs the length; a failed probe just leaves it blank. + if total, err := r.probeDuration(ctx, input); err == nil { + span = total - p.Start + } + } + + if err := os.MkdirAll(r.cfg.TmpDir, 0o750); err != nil { + return res, fmt.Errorf("create tmp dir: %w", err) + } + container, copyOnly := editPlan(input) + tmpOut := filepath.Join(r.cfg.TmpDir, "edit-"+sanitizeName(shortID(j.ID))+"."+container) + defer os.Remove(tmpOut) + + stage := "trimming" + if p.HasCrop() { + stage = "cropping" + } + emitStage(emit, stage) + prog := &ffProgress{duration: span, stage: stage} + if err := r.encode(ctx, editArgs(input, tmpOut, p, copyOnly), prog, emit); err != nil { + return res, err + } + if st, err := os.Stat(tmpOut); err != nil || st.Size() == 0 { + return res, errors.New("ffmpeg produced no output file") + } + + slug := firstNonEmpty(baseWithoutExt(j.Title), baseWithoutExt(j.Source), baseWithoutExt(input), "media") + dst := uniqueOutputPath(r.cfg.DownloadDir, slug, container, j.ID) + if err := moveFile(tmpOut, dst); err != nil { + return res, fmt.Errorf("move result: %w", err) + } + + emitPercent(emit, "done", 100) + return jobs.Result{OutputPath: dst}, nil +} + +// Extensions with no video track to re-encode, so an edit on one stays a +// lossless copy in its own container. +var audioOnlyExt = map[string]bool{ + "mp3": true, "m4a": true, "aac": true, "flac": true, "wav": true, + "ogg": true, "oga": true, "opus": true, "wma": true, +} + +// editPlan picks the output container and whether the streams can be copied. +// H.264 only belongs in MP4 and MOV among the containers we see, so anything +// else re-encoding lands in MKV rather than failing at the muxer. +func editPlan(input string) (container string, copyOnly bool) { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(input), ".")) + if ext == "" || len(ext) > 5 { + return "mp4", false + } + ext = sanitizeName(ext) + if audioOnlyExt[ext] { + return ext, true + } + if ext == "mp4" || ext == "mov" || ext == "m4v" { + return "mp4", false + } + return "mkv", false +} + +func editArgs(input, output string, p *presets.EditParams, copyOnly bool) []string { + args := []string{"-hide_banner", "-nostdin", "-y"} + if p.Start > 0 { + // Before -i, so ffmpeg seeks to the cut instead of decoding its way there. + args = append(args, "-ss", seconds(p.Start)) + } + args = append(args, "-i", input, "-progress", "pipe:1", "-nostats") + if span := p.Span(); span > 0 { + args = append(args, "-t", seconds(span)) + } + + if copyOnly { + args = append(args, "-c", "copy", "-avoid_negative_ts", "make_zero") + } else { + if p.HasCrop() { + args = append(args, "-vf", + fmt.Sprintf("crop=%d:%d:%d:%d", p.CropW, p.CropH, p.CropX, p.CropY)) + } + // CRF 18 is visually lossless; the audio is passed through untouched. + args = append(args, "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", + "-pix_fmt", "yuv420p", "-c:a", "copy") + } + + if strings.HasSuffix(output, ".mp4") || strings.HasSuffix(output, ".mov") { + args = append(args, "-movflags", "+faststart") + } + return append(args, output) +} + +func seconds(v float64) string { return strconv.FormatFloat(v, 'f', 3, 64) } + // Matching by prefix rather than glob keeps a metacharacter in the tmp path from // turning into a pattern. func removePassLogs(dir, prefix string) { diff --git a/internal/runner/ffmpeg_test.go b/internal/runner/ffmpeg_test.go index 231f684..fad83f7 100644 --- a/internal/runner/ffmpeg_test.go +++ b/internal/runner/ffmpeg_test.go @@ -5,6 +5,7 @@ import ( "testing" "aiofiles/internal/jobs" + "aiofiles/internal/presets" ) // A compress job takes no audio codec from the client, so the container has to @@ -59,3 +60,91 @@ func TestCompressSpecPicksAudioCodecFromContainer(t *testing.T) { }) } } + +func TestEditArgs(t *testing.T) { + tests := []struct { + name string + params string + input string + want []string + }{ + { + "a video trim re-encodes so the cut is exact", + `{"start":3.5,"end":10}`, + "/tmp/in.mkv", + []string{ + "-hide_banner", "-nostdin", "-y", + "-ss", "3.500", + "-i", "/tmp/in.mkv", + "-progress", "pipe:1", "-nostats", + "-t", "6.500", + "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", + "-pix_fmt", "yuv420p", "-c:a", "copy", + "/tmp/out.mkv", + }, + }, + { + "an audio file has nothing to re-encode", + `{"start":1,"end":2}`, + "/tmp/in.mp3", + []string{ + "-hide_banner", "-nostdin", "-y", + "-ss", "1.000", + "-i", "/tmp/in.mp3", + "-progress", "pipe:1", "-nostats", + "-t", "1.000", + "-c", "copy", "-avoid_negative_ts", "make_zero", + "/tmp/out.mp3", + }, + }, + { + "crop re-encodes the video only", + `{"crop_x":10,"crop_y":20,"crop_w":640,"crop_h":480}`, + "/tmp/in.mp4", + []string{ + "-hide_banner", "-nostdin", "-y", + "-i", "/tmp/in.mp4", + "-progress", "pipe:1", "-nostats", + "-vf", "crop=640:480:10:20", + "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", + "-pix_fmt", "yuv420p", "-c:a", "copy", + "-movflags", "+faststart", + "/tmp/out.mp4", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p, err := presets.ParseEdit(json.RawMessage(tc.params), 7) + if err != nil { + t.Fatalf("ParseEdit(%s) = %v", tc.params, err) + } + container, copyOnly := editPlan(tc.input) + got := editArgs(tc.input, "/tmp/out."+container, p, copyOnly) + if !equalStrings(got, tc.want) { + t.Errorf("argv =\n%q\nwant\n%q", got, tc.want) + } + }) + } +} + +// An edit cannot stay in a container H.264 does not mux, so it lands in MKV. +func TestEditPlan(t *testing.T) { + tests := []struct { + input string + container string + copyOnly bool + }{ + {"/tmp/in.webm", "mkv", false}, + {"/tmp/in.mov", "mp4", false}, + {"/tmp/in.flac", "flac", true}, + {"/tmp/in", "mp4", false}, + } + for _, tc := range tests { + container, copyOnly := editPlan(tc.input) + if container != tc.container || copyOnly != tc.copyOnly { + t.Errorf("editPlan(%q) = %q, %v; want %q, %v", + tc.input, container, copyOnly, tc.container, tc.copyOnly) + } + } +}