Skip to content

fix(video-note): crop recorded notes to the centred square - #260

Open
TiaraBasori wants to merge 3 commits into
iebb:masterfrom
TiaraBasori:fix/square-video-note-file
Open

TiaraBasori wants to merge 3 commits into
iebb:masterfrom
TiaraBasori:fix/square-video-note-file

Conversation

@TiaraBasori

@TiaraBasori TiaraBasori commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Change

Round video messages are recorded at ResolutionPreset.high — 1280×720 on both platforms — while the recorder preview and the received message bubble only ever show the centred square. So every uploaded note carried roughly 44% of its pixels in bands no client draws, and the file differed from what a client that records square produces.

The recorder now crops the finished recording to its centred square through the existing mithka/media_editor channel, and pops the cropped path. A crop failure of any kind — missing editor, platform error, malformed reply, 15s timeout — falls back to the recording, so a note is never lost to a failed re-encode, and nothing is shown to the user.

Crop ownership is per request, and it survives the whole service→recorder→send chain: VideoNoteCropService.start hands back a VideoNoteCropTicket that owns the export's file, the delivery flag is only set after videoNoteUploadPath has verified the file and deleted the recording it replaces, and the reclaim is attached to the platform future's own completion (memoized per request) — so an export that finishes long after the five-second cleanup wait gave up is still deleted, while a file the send path accepted is never touched. The recorder keeps its own ticket and abandons exactly that one on dispose or on an unmounted finish, so a route being torn down cannot reclaim a second live recorder's crop.

Affected platforms: iOS crops the file. Android still sends the recording uncropped — see below.

  • The crop is applied in _finishRecording, after stopVideoRecording() and before the screen pops, and only there: cancelling a recording still just stops and deletes the temp file.
  • On success the original temp recording is deleted, so the cache does not hold two copies of the same note. If the cropped path is missing or identical to the recording, nothing is deleted and the recording is sent.
  • The decision is a pure helper, videoNoteUploadPath, so the fallbacks are tested without a platform channel; the channel contract is tested against a mock messenger.

iOS. AVAssetExportSession at HighestQuality with a video composition whose single layer instruction maps the centred display-space square onto a square renderSize. A camera recording carries its rotation in preferredTransform — a portrait note is encoded 1280×720 and shown 720×1280 — so the square is computed in display space and the layer transform composes the preferred transform with a translation of the square's origin; the rotation and the crop are therefore one operation, and a rotated recording is neither upright-shifted nor off-centre. setCropRectangle is deliberately not used: it applies in the track's natural space, which leaves preferredTransform's translation pointing at the uncropped frame and pushes a 180° recording out of the render.

The export is verified before its path is returned: the output must be square within 1px and within 0.25s of the source, and a mismatch deletes the output and reports an error (which the caller treats as "send the recording").

Android does not ship a transcode, deliberately. This app carries no media3/transformer dependency, so a square crop means a hand-written MediaExtractor → MediaCodec decode → centred plane copy → MediaCodec encode → MediaMuxer pipeline. That pipeline is only correct when, for each device, the decoder layout (planar I420 vs semi-planar NV12/NV21, rowStride/sliceHeight differing from the frame size, U/V sharing one buffer), the chroma 2×2 alignment of the crop origin, the encoder's accepted input format, and PTS monotonicity plus codec-config extraction are all handled. Getting any of it wrong produces a note that looks fine as a thumbnail and is green, torn, or silent in playback — worse than a note that is merely wide. None of it can be compiled or run on hardware here, so cropVideoNote answers unsupported, Dart falls back, and VideoNoteCropper.kt records the exact pipeline and the per-device hazards for the implementation that replaces it. Android notes keep their 16:9 framing until then, and no Dart change is needed when they stop.

Telegram API Terms classification

  • Compliant/unchanged: this change complies with applicable Telegram API Terms, preserves Telegram's basic functionality, and does not introduce behavior covered by section 1.4.
  • Section 1.4 exception: the entire feature is guarded by allowTelegramTosViolations (I_DONT_FUCKING_CARE_ABOUT_TOS), disabled in official builds, and available only in an explicitly enabled self-build.
  • Disallowed or unresolved: this PR must not be approved or merged until its classification and implementation are resolved.

Reason and affected Telegram behavior:

Local media preparation only. The file is re-framed on the device before the existing send path receives it; no request is issued by the crop, no message data is read, and read status, typing status, last seen, self-destructing content, and actions taken on the user's behalf are unaffected.

Basic checks (every PR)

  • I reviewed the diff for correctness, unintended behavior, secrets, and unrelated changes.
  • Formatting passed, or I recorded why it is inapplicable: dart format --output=none --set-exit-if-changed lib/chat/video_note_crop_service.dart lib/chat/video_note_recorder_view.dart test/video_note_crop_test.dart.
  • Analysis passed, or I recorded why it is inapplicable: flutter analyze.
  • Relevant tests passed, or I recorded why they are inapplicable; name the commands and test cases below. Quality CI runs flutter test --concurrency=4.
  • I checked affected build/platform paths and applicable localization/generated files; list the evidence below.
  • I completed the Terms classification above and verified that official builds cannot enable section 1.4 exceptions.

Commands, results, platform/build/UI evidence, and reasons for inapplicable checks:

/tmp/flutter-3.44.2 is the toolchain Quality CI pins (Flutter 3.44.2).

  • Format: 3 files, 0 changed, exit 0.
  • Analysis, whole repository: flutter analyze → no issues.
  • Tests, serially (--no-pub -j 1, one file per invocation):
    • test/video_note_crop_test.dart (new) → 31/31 passed. Channel contract: the payload is {path: …} on cropVideoNote, a returned path is accepted, and MissingPluginException, PlatformException, a null reply, an empty path (which never reaches the channel), an echoed path, and a timeout all resolve to null. Route-cancellation ownership: a crop whose caller gave up keeps its export registered, so a completion that lands afterwards has its file deleted by the reclaim attached to the platform future (expect(lateFile.existsSync(), isFalse)), while a crop that completed inside the window hands the path to its caller and leaves the file alone. New per-request-ticket regressions (review 5604259229): late beyond cleanup timeout is still reclaimed by the platform itself (crop timed out, abandon(timeout: Duration.zero) already returned, platform completes with the temp path long afterwards → temp deleted by the true completion while a second, accepted send file is asserted to survive the same late reclaim); dispose between delivery and handoff keeps the accepted send file / … alive (a recorder disposed between the service delivering the crop and the send path taking it has exactly its own crop reclaimed — temp deleted — while the accepted flow's file survives the same gap, driven through the ticket that videoNoteUploadPath hands out); and a surrendered ticket does not touch another recorder's accepted crop (two concurrent tickets; abandoning the dying one deletes only its own temp, which the old global abandonInFlight sweep used to take the live one's file too). isSquareVideoNote: 720×720 and 721×720 are square within the tolerance, 1280×720 and 720×1280 are not, and 0 or negative dimensions are not. videoNoteUploadPath: the cropped path wins and the recording is deleted; a null crop, a throwing crop, a cropped path that does not exist, and a crop equal to the recording all keep the recording (and never delete the file being sent). Source pins: the crop is reached from _finishRecording and not from _cancelRecording, the popped result uses the resolved path, the recorder threads its own ticket (no global sweep in _finishRecording), and accept runs only after the recording it replaces is deleted.
    • test/video_note_recorder_test.dart → passed.
    • test/video_trim_service_test.dart → 2/2 passed (the existing trimVideo contract still holds after the channel handler was restructured into a switch/when).
    • test/telegram_ai_surface_style_test.dart → passed (it source-pins the recorder file).
  • Platform/build paths: iOS adds a cropVideoNote case to the existing mithka/media_editor channel in AppDelegate.swift; Android adds the same case answering unsupported. No dependency, Gradle, or pubspec change; no native build file touched; no string added or changed, and python3 tool/check_l10n_strings.py reports none.
  • Native export regressions: not available here, and the body no longer implies otherwise. A macOS XCTest target already exists (ios/RunnerTests/RunnerTests.swift, wired into the Runner scheme), so the natural place for 0/90/180/270-degree synthetic-input cases is a RunnerTests case that builds a small H.264 fixture, runs cropVideoNote and asserts the square/duration/audio result — but this environment has no Apple toolchain (swiftc and xcodebuild are both absent) and cannot run that target. A credential-free simulator workflow was prepared for the fork to run it; GitHub refuses to run any workflow on TiaraBasori/mithka (HTTP 422 Actions has been disabled for this repository on dispatch, no run created by a push trigger, while actions/permissions still reports enabled: true, allowed_actions: all), and the fork has no self-hosted macOS runner. The probe was removed and the fork's master restored. So: the exporter's orientation/audio behaviour stays unproven on hardware, the Dart-side fallback and the output verification are what keep a bad native result from being sent, and that is the accurate state of this PR.
  • Not verifiable here, and stated plainly: no Swift and no Kotlin is compiled by this environment, and no device is attached. Every Swift and Kotlin line above was reviewed by eye, and the output verification in both the export and the Dart fallback is what protects a bad native result from being sent. iOS behaviour on device — rotation handling, the duration check, and whether AVAssetExportPresetHighestQuality accepts HEVC/HDR camera output — is unverified; every such failure falls back to the recording instead of failing the send.
  • Known cost: the crop re-encodes before the recorder pops, so finishing a recording takes up to a couple of seconds longer on a long note (bounded at 15s, after which the recording is sent as-is). Doing it asynchronously would mean changing the send flow, which this PR deliberately does not touch.

Line counts (git diff --numstat origin/master...HEAD):
31 19 android/app/src/main/kotlin/ad/neko/mithka/MainActivity.kt, 52 0 android/app/src/main/kotlin/ad/neko/mithka/VideoNoteCropper.kt, 313 25 ios/Runner/AppDelegate.swift, 674 1 ios/RunnerTests/RunnerTests.swift, 307 0 lib/chat/video_note_crop_service.dart, 141 2 lib/chat/video_note_recorder_view.dart, 595 0 test/video_note_crop_test.dart.

Section 1.4 exception checks

Complete every item for an exception. For a compliant/unchanged PR, write Not applicable here.

Not applicable — compliant/unchanged classification; no guarded feature is added or touched.

  • All UI, routes, commands, callbacks, background work, TDLib/native effects, and registrations use the compile-time guard; a runtime preference alone cannot enable the feature.
  • With the flag omitted, the feature is absent and ordinary Telegram behavior remains intact, including with old saved settings.
  • With --dart-define=I_DONT_FUCKING_CARE_ABOUT_TOS=false, the feature is absent and ordinary Telegram behavior remains intact.
  • With --dart-define=I_DONT_FUCKING_CARE_ABOUT_TOS=true, the feature works through the guarded entry points in a self-build; I included relevant test and build/UI evidence.
  • Official release, nightly, TestFlight, and store builds keep the flag false and have no option that enables it. All store versions remain compliant with applicable Telegram API Terms.

Guard locations and evidence for each flag variant:

Not applicable.

Agentic reviewer confirmation

Agentic code reviewer only: record this confirmation in the PR review or check these boxes before approval.

  • I independently confirmed the Terms classification and inspected all required guards and evidence.
  • Applicable checks and Quality CI pass; no unresolved classification or unguarded section 1.4 behavior remains.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-18T20:57:26.593252Z bacb5c8 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Automated PR review

Head: 3570f3709287592472354f6986e00fce6b636b66 · master: 82397aef906f40e9034e062ae56eb3a3640efe6a
LLM recommendation: needs_human. This is not a GitHub approval.
Auto-merge: blocked. Only iebb can authorize the current head.

Review unavailable: Model claimed ready despite incomplete source context.

Remaining gates

  • Waiting for iebb to approve this exact current head.
  • A reviewer still requests changes; obtain a superseding review or dismiss it explicitly.
  • master source incomplete: android/app/src/main/kotlin/ad/neko/mithka/MainActivity.kt
  • head source incomplete: android/app/src/main/kotlin/ad/neko/mithka/MainActivity.kt
  • master source incomplete: ios/Runner/AppDelegate.swift
  • head source incomplete: ios/Runner/AppDelegate.swift
  • head source incomplete: ios/RunnerTests/RunnerTests.swift
  • Context budget reached; remaining source omitted.

No PR code runs with the model key or write token. Free-only routing; provider/rate-limit failures never authorize a merge. Existing review findings must be resolved, not hidden by a new recommendation.

@iebb iebb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current-head review: square video-note export is a legitimate improvement, but the production iOS exporter in ios/Runner/AppDelegate.swift:cropVideoNote has no compiled/native execution evidence. The new Dart tests mock the channel or pin source strings; they do not execute AVAssetExportSession, the preferredTransform/translation, output verification or audio handling. The PR explicitly acknowledges no Swift/Kotlin compilation, so the current green Linux Quality run is not platform validation.

Before merging, please provide credential-free hosted iOS build coverage and native export regressions using real synthetic input files for 0/90/180/270-degree orientations, square output, duration and retained audio. Cover route cancellation/timeout with a late exporter completion so temporary output is not left unowned. Android currently answers unsupported and sends the original; retain that clearly documented partial-platform scope rather than claiming Android square capture is fixed. This remains open for validation/revision, not rejected as an invalid improvement.

@TiaraBasori
TiaraBasori force-pushed the fix/square-video-note-file branch from bacb5c8 to 4cdca94 Compare October 21, 2026 09:49
@TiaraBasori

Copy link
Copy Markdown
Contributor Author

Addressed one boundary at head 4cdca94, and reported the two platform gates honestly.

Route cancellation with a late exporter completion (fixed). VideoNoteCropService now keeps the export registered until the platform request actually settles, instead of dropping it with a timed-out caller. abandonInFlight() waits for that request and deletes the file it produced, and the recorder calls it when its _finishRecording continuation finds the route gone (!mounted) and again from dispose(). A crop that did reach its caller clears the registration at that moment, so cleanup can never delete a note that is already being sent. Two new cases in test/video_note_crop_test.dart (18/18): a late completion after the route is left leaves no unowned file parks the channel, lets the caller time out, then completes with a real temp file and asserts existsSync() == false; a completion inside the window is handed to the caller, not deleted asserts the accepted file survives abandonInFlight().

Hosted iOS build + native export regressions — not delivered, and here is why. There is an existing macOS XCTest target (ios/RunnerTests/RunnerTests.swift in the Runner scheme), so the 0/90/180/270 synthetic-input cases belong there. This box has no Apple toolchain (swiftc/xcodebuild absent) and no device. I prepared a credential-free simulator workflow for the fork (secrets_example.dart, no signing, no Firebase plist, pod install, xcodebuild build -destination 'generic/platform=iOS Simulator', optional -only-testing:RunnerTests), but GitHub will not run any workflow on TiaraBasori/mithka: dispatch returns HTTP 422 Actions has been disabled for this repository and pushing a probe branch created no run, while repos/TiaraBasori/mithka/actions/permissions still answers {"enabled": true, "allowed_actions": "all"}. The fork also has no self-hosted macOS runner. I removed the probe workflow and restored the fork's master to its original commit, so nothing was left behind. The PR body now says exactly that instead of implying platform validation; the square/rotation/audio behaviour of cropVideoNote remains unproven on hardware.

Android scope is unchanged and still documented as partial: cropVideoNote answers unsupported, VideoNoteCropper.kt records the per-vendor pipeline hazards, and the recorder sends the original file. The body does not claim Android square capture is fixed.

Format Formatted 3 files (0 changed), analyze No issues found!, flutter test --no-pub -j 1 → 18/18 on the crop suite, 1/1 on the recorder suite.

@TiaraBasori
TiaraBasori force-pushed the fix/square-video-note-file branch 2 times, most recently from 23af1c8 to e095919 Compare October 21, 2026 16:41
@TiaraBasori

Copy link
Copy Markdown
Contributor Author

Native regression coverage added at head e095919, and the exact state of the hosted build, since I can now run one.

What landed. ios/RunnerTests/RunnerTests.swift gains VideoNoteCropTests, eight cases over AppDelegate.centredSquareCrop and the layer transform cropVideoNote installs: landscape 1280x720 (square 720 at x=280), a 90-degree portrait recording (square at the display origin), 180 degrees (square must stay inside the frame instead of being pushed out by preferredTransform's translation), 270 degrees, square input, odd dimensions rounding down to an even square, a 2x1 recording that must not degenerate, and a check that the square's corner maps to the origin of the square render size — the property that makes setCropRectangle wrong for a rotated recording. The class is @MainActor because AppDelegate is main-actor isolated. Local gates: flutter test --no-pub -j 1 test/video_note_crop_test.dart 18/18, format and analyze clean.

What the hosted build actually proved. I built a credential-free simulator workflow on the fork (secrets_example.dart, no signing, no Firebase plist, the pinned public TDLib xcframework, pod install, then xcodebuild). On macos-26 with Xcode 26.6 the app target now compiles end to end from a clean checkout — the native Swift, the Icon Composer app icons, the pods, tdjson — which is the first platform evidence this PR has ever had; Xcode 26.3 on macos-15 cannot compile ApplePCCBridge.swift at all (#available(iOS 27) needs the 26.6 SDK).

The RunnerTests bundle then failed at compile time on one thing of mine, now fixed at this head: a synchronous test calling the main-actor-isolated centredSquareCrop (call to main actor-isolated static method ... in a synchronous nonisolated context). Two harness bugs were fixed first — the shared scheme's RunnerUITests target (sources are git-ignored) had to be dropped from both Testables and BuildAction, which is why the earlier attempts died on a missing RunnerUITests.swift.

What is still not proven, stated plainly. The hosted macos-26 pool is failing every job before a runner is acquired (The job was not acquired by Runner of type hosted even after multiple attempts, ~15 min in queue, "Due to capacity constraints, jobs targeting macOS arm64 runners may experience longer queue times"), so the eight new cases have not executed on a hosted mac yet; I retried through the day and every attempt after the green app build died in the queue. The export path itself — AVAssetExportSession, real H.264 input, audio track — also remains untested here: that needs a device or a runner that stays up, not a unit test of the crop maths. The PR stays open until those two are observed; nothing in the body claims otherwise.

@iebb iebb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed e095919. Moving the square into the layer transform and adding 0/90/180/270/square geometry cases are useful progress; the author's distinction between compilation and export evidence is correct. A remaining [P2] cleanup boundary is visible in VideoNoteCropService: abandonInFlight clears the only reference then waits at most five seconds; when that wait times out, no continuation remains to reclaim a still-later successful export. Also crop marks a result delivered before videoNoteUploadPath finishes its awaited file checks; if the recorder is disposed in that gap, both dispose and the !mounted continuation find _inFlight null and the accepted-but-unsent crop is orphaned. Please keep per-request ownership until the caller either hands the file to the send path or explicitly abandons it, and attach cleanup to the actual eventual platform completion (not just a five-second wait). Add late-beyond-cleanup-timeout and dispose-between-service-delivery/recorder-handoff regressions that assert temp files are removed while accepted send files survive. Native evidence remains incomplete: the app build step in fork run 39632511955 succeeded on an earlier head, but its XCTest compile failed on main-actor isolation; the new @mainactor head's tests and real AVAssetExportSession rotation/audio/duration exports still have not passed. Please retain the iOS/native gate and the honest Android-unsupported scope. This is current-source review plus observed hosted-run evidence, not a new local/device reproduction. Keep this legitimate fix open.

@TiaraBasori

Copy link
Copy Markdown
Contributor Author

Addressed at head 537d4696. The red iOS run you cited was a genuine failure of this PR's own XCTest bundle, and its cause is now fixed at the root.

The native evidence you asked for existed and failed — fork run 39652952933 checked out e095919a (visible in its "Show what is being verified" step), compiled the app (Build Succeeded) and then failed xcodebuild test with Failing tests: testLayerTransformMapsTheSquareCornerToTheOrigin / testPortraitRecordingUsesTheDisplayedFrame / testQuarterTurnTheOtherWayIsMirroredCorrectly, the first burning 44.894 s. The 44 s is the signature of the main-actor dispatcher: the XCTest class was @MainActor and reached a static member of a @MainActor AppDelegate synchronously. Two of the three failures were also bad assertions in my own fixture (the rotation helper mixed natural and display sizes); the production maths was right — 1280x720 at 90 degrees yields a 720 square at y=280, at 270 degrees at x=280, y=0.

Root fix (iOS). centredSquareCrop and the new exportCentredSquareVideoNote are nonisolated static (ios/Runner/AppDelegate.swift:817, :850) — they touch only geometry and a local encoder — and VideoNoteCropTests is a plain XCTestCase again: no main-actor hop, no expectations masking isolation, plain sync cases plus plain async export cases. The export was extracted out of the method-channel handler into that same function, so the code a hosted XCTest exercises is the code the app runs; the channel handler is now a thin Task { @MainActor in … } around it and keeps the identical video_crop_failed messages and delete-on-mismatch behaviour. It is bridged with withCheckedContinuation rather than await exporter.export(), which only exists in the iOS 18 SDK.

Real export regressions, not source pins: makeSyntheticMovie writes genuine .mov files with AVAssetWriter — 640x360, 30 fps, one second, H.264, plus a silent PCM track encoded to AAC when the case needs audio — then applies the preferredTransform a camera would write through a passthrough pass, and assertExportedSquare runs the real AVAssetExportSession and asserts the output file exists, its natural and displayed size are square within one pixel, its duration is within 0.25 s of the input, and the audio track survives when the input carried one. Cases cover 0/90/180/270 degrees, square input, audio retention and a missing input. XCTSkip is used only for genuine runtime capability checks (canAdd, a refused writer, no pixel buffer pool) and never next to an assertion.

I am not claiming a local pass: this box has no Apple toolchain, and I will not pretend a compile. A fresh credential-free run was dispatched against fix/square-video-note-file at this head the moment it was pushed and its result goes here.

Dart ownership (the [P2] cleanup boundary). VideoNoteCropService no longer keeps one _inFlight slot: every crop() owns a request record in a set, crop() never transfers ownership, accept(path) is the only way a caller takes the file, and abandonInFlight() deletes settled outputs immediately and rides the platform future's own completion for pending ones, so a wait that gives up no longer drops the continuation. _reclaim refuses the input path, an empty path and anything accepted, and re-checks accepted after its await exists(). videoNoteUploadPath calls accept only after the exists() check and the original-file delete succeed, immediately before returning the crop; every fallback returns the recording unaccepted. _finishRecording's !mounted branch now abandons and deletes a crop it accepted just before the route went away, closing the accept-then-dispose gap you described.

test/video_note_crop_test.dart → 26/26 (was 18). New: a slow export still has its file reclaimed after a cleanup wait gives up (parked completion, abandonInFlight(timeout: 50ms), completion lands later, file gone — your late-beyond-cleanup-timeout case), a completed but unaccepted crop is removed when the route is left, an accepted crop survives cleanup, per-request ownership with one accept and one abandon in the same registry, unknown-path accept, input-path never deleted, and the videoNoteUploadPath accept-before/after boundary. The injected defect (removing the abandonment continuation) fails the late case with Expected: false / Actual: <true> at video_note_crop_test.dart:136; restored, green.

Format Formatted 3 files (0 changed), analyze on the touched files No issues found!.

Android is unchanged and still answers unsupported, with the Dart side sending the original recording; I am not claiming a square capture there.

@TiaraBasori

Copy link
Copy Markdown
Contributor Author

The native evidence you asked for now exists and passes, so I am not asking you to take it on faith.

Fork run 39698978524 dispatched against fix/square-video-note-file: its "Show what is being verified" step prints 5701d7b414cb12e4914ab5e8a66fb2a1776d04a8 — this exact head — then xcodebuild build succeeds and xcodebuild test -only-testing:RunnerTests/RunnerTests/VideoNoteCropTests reports Test Succeeded, Executed 15 tests, with 0 failures (0 unexpected) in 220.254 s. The XCTAssert results are in the artifact's xcresult.

The three cases that failed on e095919a pass here, with the real exporter:

  • testPortraitRecordingUsesTheDisplayedFrame 0.000 s, testQuarterTurnTheOtherWayIsMirroredCorrectly 0.004 s, testUpsideDownRecordingKeepsTheSquareInsideTheFrame 0.000 s — the 44.9 s main-actor hang is gone (the helper and the export are nonisolated static, the test class is a plain XCTestCase), and the two wrong expectations were my fixture arithmetic, fixed.
  • Real AVAssetExportSession exports of AVAssetWriter-generated movies, asserting output file, square natural and displayed size (≤1 px), duration ±0.25 s, audio present: testExportedLandscapeNoteIsSquareWithAudio 28.296 s, testExportedPortraitNoteIsSquareWithAudio 0.691 s, testExportedQuarterTurnNoteIsSquareWithAudio 173.788 s, testExportedUpsideDownNoteIsSquareWithAudio 4.138 s, testExportedSilentNoteIsStillSquare 13.302 s, testExportOfAMissingRecordingFailsWithoutLeavingAFile 0.030 s.
  • Plus testLayerTransformMapsTheSquareForEveryRotation (new, 0/90/180/270 corners mapped to the render origin) and the existing geometry cases.

That is real executor evidence on a simulator, not a source pin. It is still not a physical-camera recording, and the PR does not claim one.

The Dart [P2] from the last review is unchanged from my previous comment and green here (26/26 in test/video_note_crop_test.dart, including the late-beyond-cleanup-timeout case). Android remains unsupported and documented as such.

@TiaraBasori
TiaraBasori force-pushed the fix/square-video-note-file branch 5 times, most recently from e426e2c to 489b73f Compare October 24, 2026 07:08

@TiaraBasori TiaraBasori left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed review 5604259229 on top of the current rebase head 489b73f — the new head is 9867001, an incremental fix that keeps the per-request ownership and iOS isolation already on the branch and closes the two races you described precisely.

Cleanup is bound to the actual platform completion, not the wait. Each export now gets a VideoNoteCropTicket (from VideoNoteCropService.start). The platform future's own whenComplete is the single unconditional reclaim hook: it runs whenever the export truly finishes, marks the request settled, and — if the request is abandoned and unaccepted — deletes its temp file then. abandon and the global sweep only bound how long they wait; they never cancel or detach the reclaim. The reclaim itself is memoized on the request (one future, not parallel unawaited deletions), so the abandon, the resolve, the sweep and the platform completion all race the same deletion instead of firing their own.

The delivery flag is set only after the caller truly has the file. videoNoteUploadPath now resolves through the ticket: the request is only marked accepted after the cropped path is non-null, differs from the recording, exists on disk, and the recording it replaces has been deleted. Before that point the request stays unaccepted, so a recorder disposed in the gap is seen by cleanup exactly as "delivered but not handed off" and its own crop is reclaimed — and only its own, because:

The recorder no longer sweeps every in-flight export. _finishRecording threads its ticket out via onStarted: and keeps it on the state; dispose and the !mounted branch abandon exactly that ticket. A second live recorder handing its crop to the send path is untouched by another route being torn down.

Regressions (test/video_note_crop_test.dart, 31/31 passing):

  • late beyond cleanup timeout is still reclaimed by the platform itself — the crop times out, abandon(timeout: Duration.zero) returns with nothing reclaimed, and the platform completes with the temp path long afterwards. Asserts the temp file is then deleted (by the platform completion, not the wait) while a second, accepted send file survives the same late reclaim. Fails on the pre-fix head.
  • dispose between delivery and handoff keeps the accepted send file / … alive — the two halves of the gap you named: the disposed flow's own crop is reclaimed (temp deleted) and the accepted flow's file survives, driven through the ticket videoNoteUploadPath hands out, with pumpEventQueue letting the file IO land.
  • a surrendered ticket does not touch another recorder's accepted crop — two concurrent tickets; abandoning the dying one deletes only its temp. This is the mutation the old global abandonInFlight sweep failed: it took the live recorder's accepted file too.

Source pins updated to match: _finishRecording contains no abandonInFlight, dispose uses the state's own ticket, and accept runs only after the recording it replaces is deleted.

Android remains unsupported, unchanged and documented — cropVideoNote still answers unsupported on Android and the Dart side sends the uncropped recording; VideoNoteCropper.kt keeps the exact MediaExtractor→MediaCodec pipeline and per-device hazards for the implementation that replaces it. No media3/transformer dependency was added.

Diff against origin/master is unchanged in scope: MainActivity.kt, VideoNoteCropper.kt, AppDelegate.swift, RunnerTests.swift, video_note_crop_service.dart, video_note_recorder_view.dart, video_note_crop_test.dart.

The camera records 16:9 while the recorder preview and the received bubble
both show the centred square, so nearly half of every uploaded note was
pixels no client draws. iOS re-encodes the recording to that square through
AVAssetExportSession with the layer transform carrying both the
preferredTransform and the crop offset (setCropRectangle applies in the
track's natural space and misplaces a 180-degree recording), and verifies
the output - square within 1px, duration within 0.25s, audio track kept -
before returning it. Android answers `unsupported` and sends the recording
unchanged: a hand-written MediaCodec crop is only correct per vendor
(decoder layout, chroma alignment, encoder input format, PTS) and cannot be
validated on hardware here, so it stays documented as unlanded rather than
shipping a transcode that corrupts uploads.

A crop that outlives its route no longer leaves an unowned temporary: the
export stays registered until it settles, and the recorder reclaims its file
when it is disposed mid-export. The one file a caller accepted is forgotten
at that moment, so cleanup can never delete a note that is already being
sent.
VideoNoteCropService now tracks each export as a request instead of one
"last export" slot: crop() leaves the produced file owned by the service,
accept() hands it to the caller once the send path has taken it, and
abandonInFlight() attaches deletion to the platform future itself, so an
export that lands after the bounded cleanup wait gives up is still
reclaimed. The recorder accepts the crop only after the recording it
replaces has been deleted, and a disposed recorder drops the crop it
resolved as well.

iOS: centredSquareCrop is nonisolated, so the plain XCTest class no
longer routes synchronous asserts through the main-actor dispatcher, and
the export is extracted as a nonisolated async function the app and the
tests share. The XCTest suite now drives real AVAssetExportSession
exports of synthetic AVAssetWriter inputs for 0/90/180/270 degrees,
asserting square output, duration and surviving audio. The 270-degree
case's expectation was corrected (the maths was right; the assertion was
not). Android still answers unsupported and the Dart side sends the
original.
…it over

The review's P2 flagged ownership leaking across the service/recorder
gap: a recorder disposed between the service's delivery (crop resolved)
and the recorder's handoff (file verified + recording deleted + accepted
for sending) could leave an accepted-but-unsent crop that neither the
global sweep nor the !mounted continuation touched, and the 5-second
wait could outlive the reclaim of a late platform success.

Boundaries, precisely closed here, building on 489b73f's per-request
ownership and its iOS isolation:

* A live crop handle is now a per-request ticket (VideoNoteCropTicket)
  — not a single global "last export" slot. The service keeps the file
  until the caller either accepts it for the send path or explicitly
  abandons it. Cleanup rides the platform future's own whenComplete
  (the final reclaim is unconditional on that completion), so an export
  finishing long after the bounded wait gives up is still reclaimed.

* The reclaim is memoized on the request: abandon / resolve / the
  global sweep / the platform completion all race the same single
  deletion future instead of each firing an unawaited one. The delivery
  flag (accepted) is only set after the file exists and the recording
  it replaces is gone (videoNoteUploadPath), so a recorder disposed
  between delivery and handoff still sees an unaccepted request.

* The recorder no longer sweeps every in-flight export. videoNoteUploadPath
  threads its ticket through onStarted=, _finishRecording keeps the ticket,
  and dispose / the !mounted branch abandon exactly that ticket — so a
  second live recorder's accepted send file survives. The file-content
  effects are those already reviewed: a 60s 720p export bounded at 15s
  (timeout → send the recording), and a verified square output within
  1px and 0.25 s of the source; out-of-scope failures fall back.

Regression coverage, barrier-driven where needed:

* "late beyond cleanup timeout is still reclaimed by the platform itself"
  — the crop timed out, cleanup already returned with Duration.zero, and
  the platform completed with a temp path long afterwards. The temp is
  deleted by the true completion; a second accepted send file is asserted
  to survive the late reclaim. Fails without the whenComplete-attached
  reclaim.

* "dispose between delivery and handoff keeps (accepted) send file"
  — two ticks: a disposed flow's own crop is reclaimed (temp deleted,
  service delivery happened but handoff never completed), and an
  accepted flow's file survives that same gap (new VideoNoteCropTicket
  scope). An awaited-accept hook barriers drive delivery→handoff while
  the ticket is abandoned, and pumpEventQueue runs the file IO.

* "a surrendered ticket does not touch another recorder's accepted crop"
  — two concurrent tickets; the dying one's ticket abandon deletes only
  the dying temp. Fails with the old global abandonInFlight sweep.

Android still deliberately answers unsupported and the Dart side sends
the uncropped recording — 44% wide on 1280×720, MediaExtractor →
MediaCodec YUV scope documented for a future change, no bytes touched.
@TiaraBasori
TiaraBasori force-pushed the fix/square-video-note-file branch from 9867001 to 3570f37 Compare October 24, 2026 14:31

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants