Offer a fix that works when the file has no video to copy #117

Merged
JMR-dev merged 4 commits from fix/invalid-suggestion-chip into main 2026-08-26 04:21:10 +00:00
JMR-dev commented 2026-08-26 02:33:20 +00:00 (Migrated from github.com)

Closes #114.

The defect, reproduced on 0eb00d2

ContainerCapabilities.validateVideo's "no video track to copy" branch built its
suggestion by hand rather than going through suggestions(), so nothing filtered it
by validate(...).isValid:

listOf(spec.copy(videoCodec = VideoCodec.NONE))

Dropping the video track is valid exactly when the audio axis already happened to be
fine, and refused otherwise. Measured before the fix:

validate(OutputSpec(MP4, COPY, COPY), InputProbe(audioCodec = "vorbis", hasVideo = false))
  -> Invalid("This file has no video track to copy.",
             suggestions=[OutputSpec(MP4, videoCodec=NONE, audioCodec=COPY)])

and that suggestion is itself refused — MP4 cannot hold Vorbis audio. Reachable with
any audio the target container cannot carry: Vorbis or PCM into MP4, MP3 into WebM.

Nothing unsafe shipped: ConversionWorker re-validates, so tapping the dead-end chip
failed the job with a reason. This is a UX dead end, fixed as one.

Does #113 change what this needs?

No. #113 hardened repairVideo for !probe.hasVideo and widened the property test from
one spec to five, but this branch never reaches repairVideo — it hand-builds and
returns. The ticket's proposal still fits as written, and the case is outside all five of
#113's rows.

The fix

suggestions(spec.copy(videoCodec = NONE), probe, exclude = spec), as every other branch
does. Measured after:

vorbis -> MP4 : suggestions=[OutputSpec(MP4, NONE, AAC)]
pcm    -> MP4 : suggestions=[OutputSpec(MP4, NONE, AAC)]
mp3    -> WebM: suggestions=[OutputSpec(WEBM, NONE, OPUS)]
mp3    -> MP4 : suggestions=[OutputSpec(MP4, NONE, COPY)]   <- the case that already worked

exclude = spec rather than the default is load-bearing. The default excludes the
already-repaired spec, and for copyable audio repairAudio returns COPY, so the
repair is that same spec — the offer would collapse to empty and a plain MP3-to-M4A remux
would lose its only chip.

Tests

Four rows added to the table-driven every suggestion is itself valid, plus a
vorbisSource and pcmSource probe. A fifth row covers the image-output branch: those
two are the only sites that assemble a suggestion list by hand, and so the only ones that
can break the promise at all — suggestions() ends by filtering on
validate(...).isValid, which makes everything routed through it valid by construction.
The table now reaches both on purpose rather than by luck.

Its two assertion messages now name the probe as well as the spec. Three rows share
OutputSpec(MP4, COPY, COPY) and differ only in the input, so the spec alone could not
say which row failed; no valid non-image spec plans to remove both tracks already used
"$spec on $probe" at the foot of the same file.

Pure JVM. No Robolectric, no device.

Mutations — all three verified red, quoted from the runs

  1. Revert the branch to the hand-built list:
    listOf(spec.copy(videoCodec = VideoCodec.NONE))
    -> every suggestion is itself valid FAILED:
    suggested OutputSpec(container=MP4, videoCodec=NONE, audioCodec=COPY) for OutputSpec(container=MP4, videoCodec=COPY, audioCodec=COPY) on InputProbe(videoCodec=null, audioCodec=vorbis, hasVideo=false, durationMs=0, kind=AUDIO_ONLY, container=OGG, width=0, height=0) is itself invalid

  2. Drop the exclusion: suggestions(spec.copy(videoCodec = VideoCodec.NONE), probe)
    -> FAILED:
    no alternatives offered for OutputSpec(container=MP4, videoCodec=COPY, audioCodec=AAC) on InputProbe(videoCodec=null, audioCodec=mp3, hasVideo=false, durationMs=0, kind=AUDIO_ONLY, container=MP3, width=0, height=0)
    (the new copyable row MP4/COPY/COPY on mp3Source was confirmed to bite this
    independently, with the earlier row that also catches it removed)

  3. Image branch suggests the spec itself: listOf(spec)
    -> FAILED:
    suggested OutputSpec(container=GIF, videoCodec=H264, audioCodec=AAC) for OutputSpec(container=GIF, videoCodec=H264, audioCodec=AAC) on InputProbe(videoCodec=h264, audioCodec=aac, hasVideo=true, durationMs=0, kind=VIDEO, container=MP4, width=0, height=0) is itself invalid

Gate

assembleDebug testDebugUnitTest compileDebugAndroidTestKotlin ktlintCheck detekt lintDebug --continue
-> BUILD SUCCESSFUL.

CI

Unit tests, Static analysis, FFmpeg binary and E2E API 33/34/35/36 all green. Both API 37
legs are red, neither for a reason in this diff.

The advisory leg is red on every PR by design. (Its baseline DEVIATION: the tree carries 4 tests marked @FailsOnEmulatorApi37 but the baseline says 3 is the known false positive
in #120, not this branch.)

The gating leg is #108. Its current attempt reads:

  expected: 57   received: 57   failed: 0   completed cleanly: yes
F DEBUG : pid: 3657, ppid: 3579, tid: 3933, name: TaskSnapshotPer  >>> system_server <<<
F DEBUG : Abort message: 'Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma'

All 57 gating tests pass; the leg goes red on the system_server abort after them. Four
attempts on this branch produced three different outcomes — one taking down
SafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard, one
ConversionWorkerTest.routesAFastMp4JobByDeviceCapability, two with no test failure at
all — with that same abort in every one. No test failed twice, and nothing in this
change's area ever failed. The same ConversionWorkerTest pairing was recorded on run
32810469166 on 2026-08-25, before this branch existed.

E2E API 33 was red on the previous attempt as #118 (WEDGED, expected: 60 / received: 60 / failed: unknown — every test ran, then the leg hung in teardown until the timeout)
and is green on the re-run.

🤖 Generated with Claude Code

Closes #114. ## The defect, reproduced on `0eb00d2` `ContainerCapabilities.validateVideo`'s "no video track to copy" branch built its suggestion by hand rather than going through `suggestions()`, so nothing filtered it by `validate(...).isValid`: ```kotlin listOf(spec.copy(videoCodec = VideoCodec.NONE)) ``` Dropping the video track is valid exactly when the audio axis already happened to be fine, and refused otherwise. Measured before the fix: ``` validate(OutputSpec(MP4, COPY, COPY), InputProbe(audioCodec = "vorbis", hasVideo = false)) -> Invalid("This file has no video track to copy.", suggestions=[OutputSpec(MP4, videoCodec=NONE, audioCodec=COPY)]) ``` and that suggestion is itself refused — `MP4 cannot hold Vorbis audio.` Reachable with any audio the target container cannot carry: Vorbis or PCM into MP4, MP3 into WebM. Nothing unsafe shipped: `ConversionWorker` re-validates, so tapping the dead-end chip failed the job with a reason. This is a UX dead end, fixed as one. ## Does #113 change what this needs? No. #113 hardened `repairVideo` for `!probe.hasVideo` and widened the property test from one spec to five, but this branch never reaches `repairVideo` — it hand-builds and returns. The ticket's proposal still fits as written, and the case is outside all five of #113's rows. ## The fix `suggestions(spec.copy(videoCodec = NONE), probe, exclude = spec)`, as every other branch does. Measured after: ``` vorbis -> MP4 : suggestions=[OutputSpec(MP4, NONE, AAC)] pcm -> MP4 : suggestions=[OutputSpec(MP4, NONE, AAC)] mp3 -> WebM: suggestions=[OutputSpec(WEBM, NONE, OPUS)] mp3 -> MP4 : suggestions=[OutputSpec(MP4, NONE, COPY)] <- the case that already worked ``` `exclude = spec` rather than the default is load-bearing. The default excludes the already-repaired spec, and for *copyable* audio `repairAudio` returns `COPY`, so the repair is that same spec — the offer would collapse to empty and a plain MP3-to-M4A remux would lose its only chip. ## Tests Four rows added to the table-driven `every suggestion is itself valid`, plus a `vorbisSource` and `pcmSource` probe. A fifth row covers the image-output branch: those two are the only sites that assemble a suggestion list by hand, and so the only ones that can break the promise at all — `suggestions()` ends by filtering on `validate(...).isValid`, which makes everything routed through it valid by construction. The table now reaches both on purpose rather than by luck. Its two assertion messages now name the probe as well as the spec. Three rows share `OutputSpec(MP4, COPY, COPY)` and differ only in the input, so the spec alone could not say which row failed; `no valid non-image spec plans to remove both tracks` already used `"$spec on $probe"` at the foot of the same file. Pure JVM. No Robolectric, no device. ## Mutations — all three verified red, quoted from the runs 1. Revert the branch to the hand-built list: `listOf(spec.copy(videoCodec = VideoCodec.NONE))` -> `every suggestion is itself valid` FAILED: `suggested OutputSpec(container=MP4, videoCodec=NONE, audioCodec=COPY) for OutputSpec(container=MP4, videoCodec=COPY, audioCodec=COPY) on InputProbe(videoCodec=null, audioCodec=vorbis, hasVideo=false, durationMs=0, kind=AUDIO_ONLY, container=OGG, width=0, height=0) is itself invalid` 2. Drop the exclusion: `suggestions(spec.copy(videoCodec = VideoCodec.NONE), probe)` -> FAILED: `no alternatives offered for OutputSpec(container=MP4, videoCodec=COPY, audioCodec=AAC) on InputProbe(videoCodec=null, audioCodec=mp3, hasVideo=false, durationMs=0, kind=AUDIO_ONLY, container=MP3, width=0, height=0)` (the new copyable row `MP4/COPY/COPY on mp3Source` was confirmed to bite this independently, with the earlier row that also catches it removed) 3. Image branch suggests the spec itself: `listOf(spec)` -> FAILED: `suggested OutputSpec(container=GIF, videoCodec=H264, audioCodec=AAC) for OutputSpec(container=GIF, videoCodec=H264, audioCodec=AAC) on InputProbe(videoCodec=h264, audioCodec=aac, hasVideo=true, durationMs=0, kind=VIDEO, container=MP4, width=0, height=0) is itself invalid` ## Gate `assembleDebug testDebugUnitTest compileDebugAndroidTestKotlin ktlintCheck detekt lintDebug --continue` -> BUILD SUCCESSFUL. ## CI Unit tests, Static analysis, FFmpeg binary and E2E API 33/34/35/36 all green. Both API 37 legs are red, neither for a reason in this diff. The advisory leg is red on every PR by design. (Its `baseline DEVIATION: the tree carries 4 tests marked @FailsOnEmulatorApi37 but the baseline says 3` is the known false positive in #120, not this branch.) The gating leg is #108. Its current attempt reads: ``` expected: 57 received: 57 failed: 0 completed cleanly: yes F DEBUG : pid: 3657, ppid: 3579, tid: 3933, name: TaskSnapshotPer >>> system_server <<< F DEBUG : Abort message: 'Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma' ``` All 57 gating tests pass; the leg goes red on the system_server abort after them. Four attempts on this branch produced three different outcomes — one taking down `SafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard`, one `ConversionWorkerTest.routesAFastMp4JobByDeviceCapability`, two with no test failure at all — with that same abort in every one. No test failed twice, and nothing in this change's area ever failed. The same `ConversionWorkerTest` pairing was recorded on run `32810469166` on 2026-08-25, before this branch existed. E2E API 33 was red on the previous attempt as #118 (`WEDGED`, `expected: 60 / received: 60 / failed: unknown` — every test ran, then the leg hung in teardown until the timeout) and is green on the re-run. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.