AI로 만든 다니엘과 사자굴 영상(1080×2304)을 움직이는 배경화면 만들기에 넣었더니 한쪽
가장자리가 모자이크처럼 뭉개져 보인다는 리포트로 시작했다. 겸사겸사 사진에만 있던 자르기
모드를 영상에도 붙여달라는 요청이 같이 왔다. 원인을 한 번 잘못 짚었다가 스크린샷을 받고서야
제대로 찾았고, 오전은 거의 이것만 했다. 끝나고 커밋·Shorebird 핫픽스까지 내보냈다. 오후엔
직접 써보다 보니 영상이 멈추는 현상이 여기저기서 나와서(편집기, 미리보기, 홈 피드) 그걸 잡고
핫픽스를 한 번 더 냈다.
제일 먼저 의심한 건 영상 자체였다. ffmpeg로 프레임을 원래 해상도로 뽑아서 왼쪽 영역을
잘라 봤는데 깨끗했다. 키프레임 구조(I 한 장 + P/B), 프로파일(High, level 5.0)도 평범했다.
서버 변환(Cloud Run ffmpeg 워커) 결과물도 의심했지만, DB를 보니 이 영상은 아직 게시된 적이
없었다. 그런데 사용자는 편집 캔버스·게시 미리보기·홈 피드·상세 전부 에서 보인다고 했다.
네 곳의 공통점은 VideoCover(영상을 cover로 잘라 그리는 위젯)였고, 이전에 멀쩡했던 테스트
영상들은 전부 정확히 9:16이었다. 이번 영상만 9:16보다 세로로 길어서 "실제로 잘리는" 경우라,
처음엔 거기에 원인이 있다고 봤다.
그래서 서버가 영상을 항상 정확히 1080×1920(9:16)으로 잘라 만들도록 바꿨다(아래 3번). 피드·상세·
적용 화면은 이걸로 "이전에 정상이던 조건"이 된다. 문제는 편집 캔버스였다. 편집기는 원본을
그대로 재생하니까, 원인이 따로 있으면 여기는 안 고쳐진다. 기기 없이 추측만으로 더 가는 건
위험해서 여기서 스크린샷을 부탁했다.
받아보니 "왼쪽"이 아니라 오른쪽 끝에 세로 띠가 있고, 띠 안은 가로로 쭉 번져 있었다. 블러가
아니라 가장자리 픽셀을 옆으로 복제한 모양이다. H.264 디코더가 참조 프레임 바깥을 채울 때 딱
이렇게 한다.
영상의 SPS 헤더를 다시 보니 실제로 그렇게 인코딩돼 있었다.
pic_width_in_mbs_minus1 = 67 → 68 × 16 = 1088px
frame_crop_right_offset = 4 → 오른쪽 8px은 버려라

1080은 16으로 안 나눠떨어져서, 인코더는 1088px로 만들고 "오른쪽 8px은 잘라서 보여라"는 crop
정보를 붙인다. 디코더도 정렬 단위만큼 더 넓은 버퍼에 그리고 crop을 같이 넘긴다. 그러니까 누군가
이 crop을 무시하고 있다는 뜻이었다.
video_player_android 2.9.5 소스를 따라가 보니 텍스처 모드에서 이런 분기가 있었다.
if (!surfaceProducerHandlesCropAndRotation) {
// ImageReader 백엔드일 때는 회전을 직접 보정한다
}
회전만 보정하고 crop은 안 한다. Flutter 엔진 쪽 FlutterRenderer.java를 보니 API 29 이상에선
영상 텍스처를 ImageReaderSurfaceProducer로 만들고, 이 클래스의 handlesCropAndRotation()은
그냥 false를 돌려준다. 엔진 Android 코드 전체에서 getCropRect를 쓰는 곳이 한 군데도 없었다.
즉 Impeller에선 디코더 버퍼가 crop 없이 통째로 텍스처가 되고, 그게 1080 폭 박스에 눌려 그려지면서
남는 칸이 오른쪽 띠가 된 것이다.
| 경로 | crop 처리 | 비고 |
|---|---|---|
| SurfaceTexture (API 28 이하 / Skia) | O | 변환 행렬에 crop이 들어 있다 |
| ImageReader (API 29+, Impeller) | X | handlesCropAndRotation() == false |
라이브 배경화면 (MediaPlayer → SurfaceView) | O | Flutter를 안 거친다 |
엔진에 debugForceSurfaceProducerGlTextures라는 SurfaceTexture 강제 스위치가 있긴 한데, 주석에
"Vulkan(Impeller) 컨텍스트에서 켜면 동작 미정"이라고 박혀 있어서 못 썼다. VideoViewType.platformView
(SurfaceView)는 crop을 지키지만, 편집기에서 영상을 Transform.rotate·FittedBox로 돌리고 키우는데
SurfaceView는 그런 변환을 따라가지 않아서 역시 탈락.
처음엔 "16의 배수가 아니라서니까 폭을 1088로 계산해 보정하면 되겠지" 했는데, 스크린샷에서 띠 폭을
재보니 캔버스의 약 4.3%였다. 역산하면 버퍼 폭이 약 1128px인데, 16·32·64·128 어느 정렬 단위로도
딱 떨어지지 않는다. 기기·코덱·해상도마다 다르다는 뜻이라 계산식으로 때우면 다른 폰에서 또 깨진다.
그래서 기기에서 직접 재기로 했다. VideoDecoderProbe.kt가 하는 일은 이렇다.
MediaExtractor로 영상 트랙 포맷을 읽는다(파일 경로든 URL이든).MediaCodecList.findDecoderForFormat으로 ExoPlayer와 같은 규칙(포맷 지원하는 코덱, 하드웨어ImageReader(ImageFormat.PRIVATE, HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE)를Image의 hardwareBuffer.width/height와 cropRect를 돌려준다.MethodChannel atelier316/video로 부르고, 디코딩이라 수백 ms 걸릴 수 있어서 Thread로 돌린 뒤
runOnUiThread로 결과만 넘긴다.
Dart 쪽은 CropCorrectedVideo(lib/presentation/crop_corrected_video.dart)를 VideoPlayer 대신
쓴다. 잰 값으로 VideoPlayer를 버퍼 비율만큼 넓게 Positioned로 깔고, ClipRect로 crop 영역만
박스에 보이게 한다. 폰으로 찍은 영상은 rotationCorrection(90° 단위, 시계방향)만큼 crop 사각형도
같이 돌려서 화면 좌표로 옮긴다. 측정 결과는 Future 캐시에 넣어 같은 영상은 한 번만 재고, 네트워크
영상(피드·상세)은 전부 서버 워커가 같은 코덱으로 만든 것이라 크기로 묶어서 카드마다 따로
받아 재지 않게 했다. iOS·API 29 미만·측정 실패는 그냥 기존 VideoPlayer로 떨어진다.
VideoCover와 편집 캔버스가 둘 다 이걸 쓰니까 편집·미리보기·피드·상세가 한 번에 고쳐졌다.
실기기에서 띠가 사라진 걸 확인받았다.
사진 자르기는 이미 imgScale/imgOffset/imgRotation + displayedImageSize()(회전해도 모서리가
안 비는 최소 배율 계산) + cropDragStart/Update/End(두 손가락 기준점 앵커링, 가운데 안내선 자석)로
다 짜여 있었다. 이 로직은 imageWidth/imageHeight만 보고 돌아가서, 영상을 불러올 때
VideoPlayerController.value.size를 거기에 넣어주는 것만으로 영상에도 그대로 붙었다.
video_player_android가 넘겨주는 크기가 회전 전인지 후인지는 소스로 확인했다. media3가 API 21+에서
회전된 영상의 VideoSize 가로세로를 이미 바꿔서 주기 때문에 화면 방향 기준이 맞다.
사진과 딱 하나 다르게 한 건 최소 배율이다. 사진은 줄여서 여백과 함께 전체를 보여줄 수 있는데,
영상 배경화면에 검은 여백을 남길 이유가 없어서 minImgScale = 1.0(항상 꽉 채움)으로 뒀다.
화면 쪽은 사진/영상으로 갈려 있던 캔버스 분기를 하나로 합쳤다. _buildPhoto()가 사진이면
Image.file, 영상이면 CropCorrectedVideo를 같은 Positioned + Transform.rotate 자리에 넣는다.
자르기 버튼도 영상일 때 같이 보인다. 게시 미리보기 시트는 컨트롤러를 받던 걸 _buildPhoto() 결과
위젯을 받게 바꿔서, 편집 화면과 같은 구도가 보인다.
사진은 캔버스를 RepaintBoundary로 캡처하면 끝이지만, 영상은 캡처가 안 된다. 서버가 같은 값으로
잘라야 한다. 편집기 캔버스 폭은 기기마다 다르니까 캔버스 픽셀을 그대로 보내면 안 되고, 원본 영상
픽셀 기준으로 바꿔서 보낸다(VideoCrop 엔티티: rotation, width, height, dx, dy).
final p = displayed.width / imageWidth; // 캔버스 px / 원본 px
VideoCrop(width: canvas.width / p, height: canvas.height / p,
dx: imgOffset.dx / p, dy: imgOffset.dy / p, rotation: imgRotation)
LiveWallpaperDraft.crop에 실어서 transcode-live-wallpaper 엣지 함수 → 워커로 그대로 전달한다.
워커(worker/index.js)의 스케일 필터를 "원본 비율 유지, 최대 1440×3120"에서 "자르기 → 1080×1920
고정"으로 바꿨다. 필터 순서는 회전 → 자르기 → 스케일이다.
rotate=a:ow='rotw(a)':oh='roth(a)':c=black,
crop='min(iw,w)':'min(ih,h)':'(iw-ow)/2-(dx)':'(ih-oh)/2-(dy)',
scale=1080:1920,setsar=1
회전을 먼저 하면 외접 사각형으로 커지고 빈 모서리는 검정이 된다. 편집기 캔버스 배경도 검정이라
결과가 같다. Flutter Transform.rotate와 ffmpeg rotate 둘 다 양수가 시계방향이라 부호는 그대로
넘기면 된다. 확대를 먼저 하면 4배 확대 시 프레임이 4320×9216까지 커지니까, 원본 좌표에서 자른 뒤
마지막에 한 번만 스케일하게 했다. 자르기 값이 없거나(구버전 앱) 이상하면(validCrop에서 숫자·양수
검사) 가운데 cover로 자른다.
로컬 ffmpeg로 이 영상을 두 경우로 잘라봤다. 둘 다 1080×1920이 나왔고, 오른쪽으로 민 만큼 왼쪽이
더 보이고 회전 방향도 편집기와 같았다.

| 전 | 후 | |
|---|---|---|
| 저장 해상도 | 원본 비율 그대로 (예: 1080×2304) | 항상 1080×1920 |
| 편집기 구도 반영 | cover 가운데 고정 | 확대·이동·회전 그대로 |
말씀 오버레이 합성(/compose) | scale2ref로 9:16 오버레이를 늘림 | 영상이 이미 9:16이라 안 늘어남 |
배포는 엣지 함수(supabase functions deploy --use-api) → 워커(gcloud run deploy --source ./worker,
리비전 00011) 순서로 했다. 엣지 함수가 crop을 넘겨도 구버전 워커는 무시하고, 새 워커는 crop이
없으면 가운데 cover라서 어느 순서로 떠도 안전하다. 배포 후 /가 응답하고 비밀키 없는 /transcode는
401로 막히는 것까지 확인했다.

오전에 따로 한 작업이다. 업데이트 안내 다이얼로그(upgrader 패키지를 상속한 _AppUpdateAlert)에서
showReleaseNotes: false로 꺼뒀던 릴리즈 노트를 다시 켰다. upgrader가 App Store/Play 스토어의
"새로운 기능"을 직접 가져오니까, 플랫폼별 패치노트 파일을 앱에 따로 넣을 필요 없이 스토어에 등록한
각자의 문구가 뜬다. 다이얼로그 겉모습을 직접 그리고 있어서(alertDialog 오버라이드) 본문 아래에
최대 높이 180 + SingleChildScrollView로 노트 영역을 추가했다.
커밋은 성격별로 셋으로 나눴다: v1.3.0+21 버전업·스토어 패치노트 / 업데이트 안내 릴리즈 노트 /
영상 자르기·번짐 수정.
그리고 shorebird patch로 1.3.0+21에 핫픽스를 냈다. 여기서 한 가지 걸리는 게 있었다. Shorebird는
Dart 코드만 패치한다. 이번 수정 중 VideoDecoderProbe.kt와 MainActivity 채널은 네이티브라 패치에
안 실린다. 그래서 --allow-native-diffs로 진행했고, 이미 깔린 1.3.0+21 바이너리에선 atelier316/video
채널이 없어 MissingPluginException이 나는데 이걸 catch로 받아 기존 VideoPlayer로 그리게 해놨다.
정리하면 핫픽스로는 영상 자르기 모드와 릴리즈 노트가 나가고, 가장자리 번짐 보정은 다음 스토어 빌드부터
켜진다. 서버 쪽 9:16 정규화는 이미 배포돼 있어서, 새로 올리는 영상은 핫픽스와 상관없이 9:16으로 저장된다.
움직이는 배경화면 편집기에서 영상이 끝까지 가면 마지막 프레임에 서버렸다. 게시 전 미리보기
시트도 정지 사진처럼 보인다는 리포트가 같이 왔다. 이상한 건 반복 재생 코드가 이미 있었다는
점이다. 트리머 패키지(video_trimmer)가 구간 끝에서 멈추고 onChangePlaybackState(false)를
알려주면, 뷰모델의 _replay()가 구간 시작으로 seekTo 하고 play()를 다시 부른다.
그런데 이게 먹히지 않는 경우가 있었다. 구간 끝이 영상 끝과 같을 때다. 영상이 3초 제한보다
짧거나, 끝부분을 고르면 늘 이렇게 된다.
video_player 2.11.1에서 completed 이벤트를 받는 부분은 이렇다.
case VideoEventType.completed:
pause().then((_) => seekTo(value.duration));
순서를 따라가 보면 이렇다.
pause()가 isPlaying = false로 값을 바꾸는 순간 리스너가 동기로 불린다._replay()가 바로 seekTo(시작) → play()를 보낸다.pause()의 플랫폼 호출이 끝나고 .then의 seekTo(끝)이 나간다.게시 미리보기 시트는 편집기와 같은 컨트롤러를 공유한다. 그래서 미리보기가 정지 사진처럼
보인 것도 원인이 같았다. 미리보기를 열 땐 이미 끝에 멈춰 있던 거다.
_replay() 맨 앞에서 한 틱 넘기고 pause()를 한 번 더 부른 뒤에 되감는다.
await Future<void>.delayed(Duration.zero);
await c.pause();
await c.seekTo(Duration(milliseconds: trimStartMs));
await c.play();
한 틱 넘기면 video_player의 pause() 플랫폼 호출이 먼저 나간다. 플랫폼 채널은 순서를 지키니까
우리 pause()가 돌아올 땐 그쪽의 seekTo(끝)이 이미 나간 뒤다. 그래서 우리 seekTo(시작)이
항상 마지막 명령이 된다.
하나 더 막았다. 화면을 닫거나 영상을 다시 고르면 TrimViewer.dispose()가 false를 알리고 바로
컨트롤러를 dispose한다. 한 틱 뒤엔 죽은 컨트롤러라 pause()가 assert로 터진다. 이 경우는
catch로 버린다.
움직이는 배경화면을 두 개 연속으로 올리니 홈 피드 맨 앞 카드에 전 작품 영상이 나왔다. 상세에
들어가거나 앱을 재시작하면 정상으로 돌아왔다.
피드 카드(LiveWallpaperPreview)는 StatefulWidget이고, State가 영상 컨트롤러를 들고 있다. 그런데
key가 없었다. 새 작품이 피드 맨 앞에 끼어들면 Flutter는 State를 작품이 아니라 자리(인덱스)로
재사용한다. 맨 앞 자리의 State가 이전 작품 컨트롤러를 그대로 든 채 새 작품 카드로 그려진 것이다.
key: ValueKey(product.id) 한 줄로 고쳤다. 다음 절에서 보겠지만, 이건 "피드 영상이 멈춘다"는
리포트의 원인이 아니었다. 처음엔 이것까지 원인으로 봤다가 틀렸다.
처음 리포트는 "사자굴의 다니엘 카드가 멈춘다, 영상마다 왜 다른지 모르겠다"였다. 영상부터 봤다.
두 작품의 피드용 영상을 받아 ffprobe로 비교하니 규격이 똑같았다(720×1280, 3초, 16fps). 프레임
사이 밝기 변화량(signalstats의 YDIF)을 보면 다니엘 쪽이 오히려 3초 내내 더 많이 움직였다.
파일 문제는 아니었다.
| 여호수아 | 다니엘 | |
|---|---|---|
| 해상도 / 길이 / fps | 720×1280 / 3초 / 16 | 720×1280 / 3초 / 16 |
| 프레임 간 YDIF 평균 | 약 0.4 | 약 1.3 |
그래서 7번의 key 문제와, 동시 재생 제한(최대 4개)에서 자리를 못 받은 카드가 다시 시도하지 않는
문제를 원인으로 봤다. 둘 다 고쳤다(LiveFeedPlaybackLimiter를 ChangeNotifier로 바꿔 자리가 비면
알리고, 기다리던 카드가 재시도). 그런데 바로 "이번엔 여호수아가 안 움직인다"는 답이 왔다. 작품은
두 개뿐이라 제한 4개엔 걸릴 수가 없었다. 헛다리였다.
"iOS는 잘 움직이는데 안드로이드가 자꾸 멈춘다"는 말이 결정적이었다. 연결된 기기(Galaxy Z Flip5,
Android 16)로 직접 봤다.
ExoPlayerImpl: Init 로그를 보니 피드 플레이어 두 개가 에러 없이 둘 다 만들어져 있었다.dumpsys media.resource_manager로 보니 하드웨어 디코더(c2.qti.avc.decoder)도 둘 다 붙어 있었다.여기까지 보고 처음엔 Impeller 텍스처 쪽 문제를 의심했다. 그런데 setCodecState 로그가 이상했다.
15:57:48 16611 setCodecState state(0) ← 한쪽 idle
15:57:53 16606 setCodecState state(1) ← 다른 쪽 running
15:58:06 16611 setCodecState state(1)
15:58:07 16606 setCodecState state(0)
두 플레이어가 번갈아 멈추고 있었다. 한쪽이 재생을 시작하면 다른 쪽이 선다. 디코딩이나 텍스처
문제가 아니라 누군가 일시정지를 걸고 있다는 뜻이다.
video_player_android의 VideoPlayer.java를 열어 보니 바로 있었다.
exoPlayer.setAudioAttributes(..., !isMixMode); // handleAudioFocus
VideoPlayerOptions(mixWithOthers: true)를 주지 않으면 ExoPlayer가 오디오 포커스를 직접 관리한다.
재생을 시작하면 포커스를 가져가고, 포커스를 잃은 쪽은 스스로 일시정지한다. 피드 영상에
소리 트랙이 없어도 똑같다. 앱은 이 일시정지를 모르니 다시 틀지도 않는다. 코드 전체를 찾아보니
mixWithOthers를 쓰는 곳이 한 군데도 없었다.
이러면 앞의 이상한 증상이 다 설명된다.
피드 카드와 상세 화면의 컨트롤러 두 곳에 mixWithOthers: true를 넣었다. 에디터(트리머 패키지)는
소리가 있는 영상을 트니까 포커스를 잡게 그대로 뒀다. 피드 쪽이 포커스를 아예 안 쓰니, 에디터가
포커스를 가져가도 피드는 안 멈춘다. 덤으로 무음 피드 영상이 사용자가 듣던 음악을 끊는 일도
없어졌다. 실기기에서 두 카드가 같이 도는 걸 확인받았다.
돌아보면 "영상마다 다르다"에 끌려서 영상과 카드 순서만 봤다. "어느 플랫폼에서만 나는가"를 먼저
물었으면 한 바퀴 덜 돌았을 것이다.
6~8번은 전부 Dart 코드라(변경 파일 4개: upload_view_model.dart, widgets.dart, detail.dart,
providers.dart) Shorebird로 나갈 수 있다. 처음엔 Shorebird 최신 릴리스인 1.3.0+22에만 내려고
했다. 그런데 +22는 아직 스토어 심사 중이고, 사용자들이 실제로 쓰는 건 +21이었다. 둘 다 내야 했다.
| 릴리스 | 상태 | 패치 |
|---|---|---|
| Android 1.3.0+22 | 심사 중 | Patch 1 |
| Android 1.3.0+21 | 스토어 배포 중 | #2 또는 #3 |
| iOS 1.3.0+21 | 스토어 배포 중 | #2 또는 #3 |
Android +21은 오전 핫픽스 때와 같은 사정이다. 지금 코드엔 +21 이후 추가된 Kotlin(VideoDecoderProbe)이
있어서 --allow-native-diffs가 필요하다. 채널이 없으면 Dart가 예외를 잡아 일반 영상으로 그리니까
안전하다. 이 두 개(Android +21, iOS +21)는 내 쪽 권한 확인에 막혀서 사용자가 직접 돌렸다. iOS 빌드가
끝나고 보니 역시 project.pbxproj의 objectVersion이 70에서 54로 낮아져 있어서 되돌렸다.
shorebird patches list에 +21 패치가 #2·#3으로 올라갔는데, 목록에 플랫폼이 안 나와서 어느 게
Android고 어느 게 iOS인지는 확인하지 못했다.
MediaExtractor + MediaCodecList.findDecoderForFormat + ImageReader(PRIVATE,USAGE_GPU_SAMPLED_IMAGE, 엔진과 같은 설정)로 첫 프레임 디코딩 → Image.hardwareBuffer 크기·cropRect. MethodChannel atelier316/video, 백그라운드 Thread + runOnUiThread.CropCorrectedVideo — LayoutBuilder + ClipRect + Stack/Positioned로 버퍼 전체를rotationCorrection 90° 단위 사각형 회전. 결과는 Future 맵 캐시displayedImageSize, focal point 앵커링, 스냅 안내선) 재사용,VideoCrop 엔티티로 원본 픽셀 좌표 변환.rotate(rotw/roth, c=black) → crop(표현식, 경계 자동 보정) →scale=1080:1920,setsar=1. Cloud Run 워커 + Supabase Edge Function.ffmpeg -bsf:v trace_headers로 SPS의 frame_crop_* 확인, video_player_android·FlutterFlutterRenderer.java 소스 확인, 스크린샷 픽셀 열 분석(가로 변화량이 0인 구간 = 번진 띠)으로 띠shorebird patch android|ios --release-version=1.3.0+21 --allow-native-diffs.video_player completed 처리(pause().then(seekTo(duration)))와의 순서 문제를Future.delayed(Duration.zero) + pause() 한 번 더로 정렬(플랫폼 채널 FIFO 이용).ValueKey(product.id)로 State 재사용 방지. 동시 재생 제한기는 ChangeNotifier로VideoPlayerOptions(mixWithOthers: true) → ExoPlayer handleAudioFocus = false.adb logcat(ExoPlayerImpl Init/Release, setCodecState),dumpsys media.resource_manager(프로세스별 코덱 점유), adb exec-out screencap 연속 캡처 + PILImageChops.difference로 좌/우 카드 변화 비교, ffprobe·signalstats YDIF로 영상 움직임 비교.VideoDecoderProbe)은 Shorebird로 안 나간다.findDecoderForFormat으로 고른다. ExoPlayer가 드물게 다른 디코더를 고르는 기기에선crop이 창을 외접 사각형 안으로 당겨서 편집 화면과pad로 여유를 두고 자르게 바꾼다.flutter/flutter에 ImageReader crop 무시 이슈가 고쳐지면 CropCorrectedVideo는 걷어낸다.video_trimmer)는 아직 오디오 포커스를 잡는다. 에디터에 들어가면 사용자가 듣던 음악이Trimmer.loadVideo에mixWithOthers를 넣는다(로컬 패치 패키지라 PATCH.md도 같이 갱신).