Loading the catalog…
Loading the catalog…
2026-09-26 영상 가장자리 번짐 수정, 영상 자르기 모드, 서버 9:16 정규화, 업데이트 안내 릴리즈 노트, Shorebird 핫픽스, 영상 반복 재생 수정, 피드 카드 key, 피드 영상 멈춤 수정 AI로 만든 다니엘과 사자굴 영상(1080×2304)을 움직이는 배경화면 만들기에 넣었더니 한쪽 가장자리가 모자이크처럼 뭉개져 보인다는 리포트로 시작했다. 겸사겸사 사진에만 있던 자르기 모드를 영상에도 붙여달라는 요청이 같이 왔다. 원인을 한 번 잘못 짚었다가 스크린샷을 받고서야 제대로 찾았고, 오전은 거의 이것만 했다. 끝나고 커밋·Shorebird 핫픽스까지 내보냈다. 오후엔 직접 써보다 보니 영상이 멈추는 현상이 여기저기서 나와서(편집기, 미리보기, 홈 피드) 그걸 잡고 핫픽스를 한 번 더 냈다. 1. 영상 가장자리 번짐 원본은 멀쩡했다 제일 먼저 의심한 건 영상 자체였다. 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을 무시하고 있다는 뜻이었다. 범인: Flutter 엔진의 ImageReader 경로 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 )를 만들어 그 Surface에 첫 프레임을 디코딩한다. 받은 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 와 편집 캔버스가 둘 다 이걸 쓰니까 편집·미리보기·피드·상세가 한 번에 고쳐졌다. 실기기에서 띠가 사라진 걸 확인받았다. 2. 영상 자르기 모드 사진 자르기 로직을 그대로 재사용 사진 자르기는 이미 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 엣지 함수 → 워커로 그대로 전달한다. 3. 서버에서 항상 9:16으로 워커( 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로 막히는 것까지 확인했다. 4. 업데이트 안내에 릴리즈 노트 오전에 따로 한 작업이다. 업데이트 안내 다이얼로그( upgrader 패키지를 상속한 _AppUpdateAlert )에서 showReleaseNotes: false 로 꺼뒀던 릴리즈 노트를 다시 켰다. upgrader 가 App Store/Play 스토어의 "새로운 기능"을 직접 가져오니까, 플랫폼별 패치노트 파일을 앱에 따로 넣을 필요 없이 스토어에 등록한 각자의 문구가 뜬다. 다이얼로그 겉모습을 직접 그리고 있어서( alertDialog 오버라이드) 본문 아래에 최대 높이 180 + SingleChildScrollView 로 노트 영역을 추가했다. 5. 커밋과 Shorebird 핫픽스 커밋은 성격별로 셋으로 나눴다: 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으로 저장된다. 6. 편집 중 영상이 끝나면 멈춘다 반복 재생 코드는 이미 있었다 움직이는 배경화면 편집기에서 영상이 끝까지 가면 마지막 프레임에 서버렸다. 게시 전 미리보기 시트도 정지 사진처럼 보인다는 리포트가 같이 왔다. 이상한 건 반복 재생 코드가 이미 있었다는 점이다. 트리머 패키지( video_trimmer )가 구간 끝에서 멈추고 onChangePlaybackState(false) 를 알려주면, 뷰모델의 _replay() 가 구간 시작으로 seekTo 하고 play() 를 다시 부른다. 그런데 이게 먹히지 않는 경우가 있었다. 구간 끝이 영상 끝과 같을 때 다. 영상이 3초 제한보다 짧거나, 끝부분을 고르면 늘 이렇게 된다. 범인: video_player의 뒤늦은 seekTo(끝) video_player 2.11.1에서 completed 이벤트를 받는 부분은 이렇다. case VideoEventType.completed: pause().then((_) => seekTo(value.duration)); 순서를 따라가 보면 이렇다. pause() 가 isPlaying = false 로 값을 바꾸는 순간 리스너가 동기로 불린다. 트리머가 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 로 버린다. 7. 연속으로 올리면 피드에 전 작품 영상이 보인다 움직이는 배경화면을 두 개 연속으로 올리니 홈 피드 맨 앞 카드에 전 작품 영상 이 나왔다. 상세에 들어가거나 앱을 재시작하면 정상으로 돌아왔다. 피드 카드( LiveWallpaperPreview )는 StatefulWidget 이고, State가 영상 컨트롤러를 들고 있다. 그런데 key가 없었다. 새 작품이 피드 맨 앞에 끼어들면 Flutter는 State를 작품이 아니라 자리(인덱스) 로 재사용한다. 맨 앞 자리의 State가 이전 작품 컨트롤러를 그대로 든 채 새 작품 카드로 그려진 것이다. key: ValueKey(product.id) 한 줄로 고쳤다. 다음 절에서 보겠지만, 이건 "피드 영상이 멈춘다"는 리포트의 원인이 아니었다. 처음엔 이것까지 원인으로 봤다가 틀렸다. 8. 홈 피드에서 영상이 한 장씩 멈춘다 (Android만) 처음엔 순서 문제인 줄 알았다 처음 리포트는 "사자굴의 다니엘 카드가 멈춘다, 영상마다 왜 다른지 모르겠다"였다. 영상부터 봤다. 두 작품의 피드용 영상을 받아 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는 멀쩡하다는 한 줄 "iOS는 잘 움직이는데 안드로이드가 자꾸 멈춘다"는 말이 결정적이었다. 연결된 기기(Galaxy Z Flip5, Android 16)로 직접 봤다. ExoPlayerImpl: Init 로그를 보니 피드 플레이어 두 개가 에러 없이 둘 다 만들어져 있었다. 화면을 0.7초 간격으로 세 번 캡처해 비교하니 왼쪽(여호수아) 반은 변화가 0, 오른쪽(다니엘)만 바뀌었다. 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 를 쓰는 곳이 한 군데도 없었다. 이러면 앞의 이상한 증상이 다 설명된다. 멈추는 카드가 다니엘이었다가 여호수아였다가 바뀐 건, 마지막에 재생을 시작한 쪽만 살아남았기 때문이다. 상세 화면의 영상도 뒤에 있는 피드
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
1.3.2 업데이트. 2026-09-26 영상 가장자리 번짐 수정, 영상 자르기 모드, 서버 9:16 정규화, 업데이트 안내 릴리즈 노트, Shorebird 핫픽스, 영상 반복 재생 수정, 피드 카드 key, 피드 영상 멈춤 수정 AI로 만든 다니엘과 사자굴 영상(1080×2304)을 움직이는 배경화면 만들기에 넣었더니 한쪽 가장자리가 모자이크처럼 뭉개져 보인다는 리포트로 시작했다. 겸사겸사 사진에만 있던 자르기 모드를 영상에도 붙여달라는 요청이 같이 왔다. 원인을 한 번 잘못 짚었다가 스크린샷을 받고서야 제대로 찾았고, 오전은 거의 이것만 했다. 끝나고 커밋·Shorebird 핫픽스까지 내보냈다. 오후엔 직접 써보다 보니 영상이 멈추는 현상이 여기저기서 나와서(편집기, 미리보기, 홈 피드) 그걸 잡고…
Open source