diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-14 14:09:16 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-14 14:09:16 -0500 |
| commit | 4a7529393ebbbc225939f21be783ec624d0a8168 (patch) | |
| tree | ece00458140ecac7a590b826e7e7a1917fa844e9 /tests/test-transcription-video.el | |
| parent | d914f1d0fc134356065416d1f489a577b8ffa1bd (diff) | |
| download | dotemacs-4a7529393ebbbc225939f21be783ec624d0a8168.tar.gz dotemacs-4a7529393ebbbc225939f21be783ec624d0a8168.zip | |
feat(transcription): extend dired T to transcribe videos via ffmpeg, with tests
Pressing `T' in dired/dirvish on an audio file already transcribed
it; on a video file it bounced with "Not an audio file". Real
recordings ship as .mp4 / .mkv at least as often as raw .m4a, so
the one-key flow ended at the wrong place.
Pipeline now:
- audio path -> direct into `cj/--start-transcription-process'
(unchanged).
- video path -> async ffmpeg extracts the audio track to a temp
.mp3 under `temporary-file-directory' (libmp3lame, VBR q:a 4,
~165kbps -- right size for speech, accepted by every backend),
then transcribes that file with the temp marked for cleanup
after the transcription sentinel fires.
Surface changes:
- `cj/video-file-extensions' added to user-constants.el (mp4, mkv,
mov, webm, avi, m4v, wmv, flv, mpg, mpeg, 3gp, ogv).
- New predicates `cj/--video-file-p' / `cj/--media-file-p'.
- New `cj/--extract-audio-from-video' (async ffmpeg with success
callback; surfaces `cj/--notify' on failure; user-errors if
ffmpeg isn't on PATH).
- `cj/--start-transcription-process' gains optional `cleanup-file'.
Sentinel deletes it after the existing logic runs. Backwards
compatible -- the audio flow doesn't pass it.
- `cj/transcribe-audio' renamed to `cj/transcribe-media' (dispatcher
on audio vs video). `cj/transcribe-audio-at-point' renamed to
`cj/transcribe-media-at-point'. Both old names kept as
`defalias' so M-x history and any external references still work.
- `T' in dired-mode-map + dirvish-mode-map points at
`cj/transcribe-media-at-point'.
- Module commentary USAGE block updated.
15 new ERT tests in `tests/test-transcription-video.el' cover the
predicates (happy/boundary/error), ffmpeg invocation (correct args
+ missing-ffmpeg path), the dispatcher (audio direct, video via
extraction, non-media rejected), the aliases, and the T binding.
One existing test in `test-transcription-status-and-commands.el'
updated to stub the new delegate name.
Verified locally that ffmpeg is on PATH with libmp3lame, and that
the exact arg list my code uses produces a valid MP3 from a
synthetic test video.
Diffstat (limited to 'tests/test-transcription-video.el')
| -rw-r--r-- | tests/test-transcription-video.el | 156 |
1 files changed, 156 insertions, 0 deletions
diff --git a/tests/test-transcription-video.el b/tests/test-transcription-video.el new file mode 100644 index 00000000..8327fa32 --- /dev/null +++ b/tests/test-transcription-video.el @@ -0,0 +1,156 @@ +;;; test-transcription-video.el --- Tests for video transcription dispatch -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the video branch of the transcription pipeline. Audio +;; files keep flowing through `cj/--start-transcription-process' +;; unchanged (covered by sibling test files). Video files go through +;; ffmpeg audio extraction first, then into the same transcription +;; pipeline with the extracted file marked for cleanup once +;; transcription completes. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'transcription-config) + +;;; cj/--video-file-p + +(ert-deftest test-tx-video-file-p-recognizes-common-video-extensions () + "Normal: common video extensions are recognized." + (dolist (path '("clip.mp4" "talk.mkv" "demo.mov" "ad.webm" "old.avi" + "screencast.m4v" "promo.mpg")) + (should (cj/--video-file-p path)))) + +(ert-deftest test-tx-video-file-p-rejects-audio-and-non-media-extensions () + "Boundary: audio and unrelated extensions return nil." + (dolist (path '("song.mp3" "notes.txt" "image.png" "archive.tar.gz")) + (should-not (cj/--video-file-p path)))) + +(ert-deftest test-tx-video-file-p-case-insensitive () + "Boundary: uppercase extensions count too." + (should (cj/--video-file-p "Clip.MP4")) + (should (cj/--video-file-p "TALK.MKV"))) + +(ert-deftest test-tx-video-file-p-handles-no-extension () + "Boundary: extensionless and nil/empty input returns nil." + (should-not (cj/--video-file-p "README")) + (should-not (cj/--video-file-p "")) + (should-not (cj/--video-file-p nil))) + +;;; cj/--media-file-p + +(ert-deftest test-tx-media-file-p-accepts-audio () + "Normal: audio passes." + (should (cj/--media-file-p "song.mp3"))) + +(ert-deftest test-tx-media-file-p-accepts-video () + "Normal: video passes." + (should (cj/--media-file-p "clip.mp4"))) + +(ert-deftest test-tx-media-file-p-rejects-non-media () + "Boundary: text, image, etc. fail." + (should-not (cj/--media-file-p "notes.txt")) + (should-not (cj/--media-file-p "image.png"))) + +;;; cj/--extract-audio-from-video + +(ert-deftest test-tx-extract-audio-invokes-ffmpeg-with-expected-args () + "Normal: extraction shells ffmpeg with -vn and the chosen MP3 encoder." + (let* ((video "/clips/demo.mp4") + (out "/tmp/cj-tx-extract.mp3") + make-process-kwargs) + (cl-letf (((symbol-function 'cj/executable-find-or-warn) + (lambda (&rest _) "/usr/bin/ffmpeg")) + ((symbol-function 'make-process) + (lambda (&rest kw) (setq make-process-kwargs kw) 'fake-process))) + (cj/--extract-audio-from-video video out #'ignore)) + (should make-process-kwargs) + (let ((cmd (plist-get make-process-kwargs :command))) + (should (equal (car cmd) "/usr/bin/ffmpeg")) + (should (member "-vn" cmd)) + (should (member video cmd)) + (should (member out cmd)) + (should (member "libmp3lame" cmd))))) + +(ert-deftest test-tx-extract-audio-errors-when-ffmpeg-missing () + "Error: ffmpeg not on PATH signals user-error before make-process." + (cl-letf (((symbol-function 'cj/executable-find-or-warn) + (lambda (&rest _) nil)) + ((symbol-function 'make-process) + (lambda (&rest _) (error "make-process must not be called")))) + (should-error (cj/--extract-audio-from-video "/x.mp4" "/tmp/y.mp3" #'ignore) + :type 'user-error))) + +;;; cj/transcribe-media dispatcher + +(ert-deftest test-tx-transcribe-media-audio-routes-directly () + "Normal: audio paths go straight to the transcription worker, no ffmpeg." + (let* ((tmp (make-temp-file "cj-tx-aud-" nil ".mp3")) + worker-arg ffmpeg-called) + (unwind-protect + (cl-letf (((symbol-function 'cj/--start-transcription-process) + (lambda (file &rest _) (setq worker-arg file) 'fake-proc)) + ((symbol-function 'cj/--extract-audio-from-video) + (lambda (&rest _) (setq ffmpeg-called t)))) + (cj/transcribe-media tmp)) + (delete-file tmp)) + (should (equal worker-arg tmp)) + (should-not ffmpeg-called))) + +(ert-deftest test-tx-transcribe-media-video-extracts-then-transcribes () + "Normal: video paths invoke ffmpeg; on success the extracted audio +goes through `cj/--start-transcription-process' with a cleanup hint." + (let* ((tmp (make-temp-file "cj-tx-vid-" nil ".mp4")) + extract-args worker-call) + (unwind-protect + (cl-letf (((symbol-function 'cj/--extract-audio-from-video) + (lambda (vid out cb) + (setq extract-args (list vid out cb)) + ;; Simulate immediate ffmpeg success. + (funcall cb))) + ((symbol-function 'cj/--start-transcription-process) + (lambda (file &rest rest) + (setq worker-call (cons file rest)) + 'fake-proc))) + (cj/transcribe-media tmp)) + (delete-file tmp)) + ;; ffmpeg was asked to extract from tmp. + (should extract-args) + (should (equal (car extract-args) tmp)) + ;; The temp audio path passed to ffmpeg matches the path passed to + ;; the worker -- in other words the extraction output IS what the + ;; worker transcribes. + (should (equal (nth 1 extract-args) (car worker-call))) + ;; The worker got the temp-audio as cleanup-file (so it gets + ;; deleted after transcription completes). + (should (equal (nth 1 extract-args) (cadr worker-call))))) + +(ert-deftest test-tx-transcribe-media-rejects-non-media () + "Error: non-media paths get rejected up front." + (should-error (cj/transcribe-media "/notes/readme.txt") :type 'user-error)) + +;;; Aliases + +(ert-deftest test-tx-old-transcribe-audio-aliases-new-media-command () + "Backwards compat: `cj/transcribe-audio' still resolves to the new +media dispatcher via defalias." + (should (eq (symbol-function 'cj/transcribe-audio) 'cj/transcribe-media))) + +(ert-deftest test-tx-old-at-point-aliases-new-media-at-point () + "Backwards compat: `cj/transcribe-audio-at-point' still resolves." + (should (eq (symbol-function 'cj/transcribe-audio-at-point) + 'cj/transcribe-media-at-point))) + +;;; Keybinding + +(ert-deftest test-tx-dired-T-binds-media-at-point () + "Normal: T in dired-mode-map invokes `cj/transcribe-media-at-point'." + (require 'dired) + (should (eq (lookup-key dired-mode-map (kbd "T")) + #'cj/transcribe-media-at-point))) + +(provide 'test-transcription-video) +;;; test-transcription-video.el ends here |
