1.3.0 업데이트

노영택·2026년 9월 28일

아틀리에316

목록 보기
24/27

2026-09-23 움직이는 배경화면 Android 전용 UI, 구버전(스토어) 호환 처리, "무제" 번역 누락 수정, 댓글 시트 새로고침 수정, KG이니시스 본인인증 실 연동

움직이는 배경화면 기능이 Android 전용으로 확정된 뒤, 오늘은 iOS 사용자 UX를 다듬는
작업으로 시작해서 스토어 구버전 호환 문제까지 이어졌다. 중간에 몇 가지 실사용 버그
리포트("무제"가 한글로만 뜨는 문제, 댓글 시트가 새로고침 안 되는 문제)도 같이
잡았고, 마지막엔 판매자 정산용 KG이니시스 본인인증을 실제 서비스와 연동해 끝까지
테스트까지 마쳤다.

1. 움직이는 배경화면 — iOS에는 "Android 전용" 표시

iOS는 Live Photo 배경화면 자격 문제가 구조적으로 막혀있어(전날 결론) 움직이는
배경화면을 아예 못 쓴다. 지금까지는 상세 화면에만 안내 문구 박스가 있었는데,
피드 단계에서부터 알 수 있게 했다.

LiveWallpaperPreview(lib/widgets.dart) — 움직이는 배경화면 미리보기를 그리는
단일 공용 위젯이라 피드·상세·마이페이지·작가 프로필 등 어디서 써도 이 한 곳만
고치면 전부 적용된다. !isAndroid일 때만 Stack에 Positioned.fill로 반투명
검정(Colors.black.withValues(alpha: .45)) 스크림 + Icons.android + 기존
liveWallpaperAndroidOnly l10n 문구를 오버레이로 얹었다. 새 l10n 문자열을 추가하지
않고 이미 16개 언어로 있던 문구를 재사용해서 번역 작업을 늘리지 않았다.

2. 스토어 구버전이 홈피드에서 에러나는 문제 — 근본 원인 고치기

구버전(움직이는 배경화면을 모르는) 클라이언트가 홈피드에서 type: 'live' 상품을
만나면 verse_ref/verse_text가 null이라고 가정하고 캐스팅하다가 "불러오지
못했어요" 에러를 냈다(2026.09.22 재현). 처음엔 LIVE_WALLPAPER_AUTO_PUBLISH
시크릿으로 자동 공개 자체를 꺼서 구버전이 아예 못 보게 막는 임시 게이트를 만들었는데,
오늘은 "구버전도 볼 수 있게" 근본 해결로 바꿨다.

  • 더미 텍스트: SupabaseLiveWallpaperUploadRepository.publish()
    (lib/data/repositories/supabase_live_wallpaper_upload_repository.dart)에서
    상품 insert 시 verse_ref/verse_text를 draft.title로 채운다. 구버전은 이
    값을 카드 제목처럼 그대로 보여줄 뿐이라 크래시 없이 넘어간다.
  • 안내 이미지: 구버전은 previews/{productId}/verse-preview.jpg·
    plain-preview.jpg 경로에 파일이 있다고 가정하고 무조건 그 URL로 이미지를
    요청한다. Cloud Run 영상 변환 워커(worker/index.js)가 변환 성공 시마다 그 두
    경로에 고정 이미지 한 장을 그대로 복사 업로드하도록 추가했다. 이미지는 Python
    Pillow로 만들었다 — 앱 기존 디자인 토큰(카드 배경 #F3EAD9, 잉크 #2B2420)과
    assets/images/wordmark.png(로고 텍스트를 새로 그리지 않고 기존 에셋 재사용,
    logo/README.md 규칙)를 그대로 써서 "움직이는 작품은 업데이트하면 볼 수 있어요"
    문구를 얹었다. worker/legacy-preview.jpg로 저장해 Dockerfile에
    COPY legacy-preview.jpg ./로 번들시켰다.
  • 이중 안전장치: LIVE_WALLPAPER_AUTO_PUBLISH 게이트(transcode-live-wallpaper
    Edge Function)는 되살려뒀다 — 위 두 가지로 이론상 안전해졌지만, 실기기(구버전
    빌드)로 검증되기 전까지는 계속 hidden 상태로 남겨두는 편이 안전하다고 판단했다.
    실제로 오늘 supabase secrets set LIVE_WALLPAPER_AUTO_PUBLISH=true로 켜고,
    이미 hidden으로 남아있던 테스트 상품(title: 'test')을
    supabase db query --linked로 직접 status = 'published'로 바꿔서 검증했다.
    previews/{id}/ 스토리지에 verse-preview.jpg·plain-preview.jpg가 실제로
    올라간 것도 supabase storage ls --experimental로 확인.

문구는 처음 "업데이트하면 볼 수 있어요"였다가 "움직이는 작품은 업데이트하면 볼 수
있어요"로, 영어도 "Update the app to view moving wallpapers"로 다시 구웠다.
Cloud Run은 gcloud run deploy atelier316-video-worker --source ./worker --region asia-northeast3로 재배포(저장소 루트에서 실행해야 함 — ios/ 같은
하위 디렉터리에서 실행하면 --source .가 엉뚱한 폴더를 빌드하려다 실패한다).

3. iOS 전용 팝업 추가했다가 취소 — ARB 파일 복구 사고

iOS에서 움직이는 배경화면 상세로 들어오면 뜨는 팝업(_IosLiveWallpaperPopup,
detail.dart)을 추가했었는데, 위 2번 작업으로 카드 단계에서부터 이미 충분히
안내되니 필요 없다는 판단으로 다시 제거했다.

제거 과정에서 사고가 하나 있었다 — 16개 언어 ARB 파일에서 키 하나를 지우는 파이썬
스크립트가 json.load(fh, object_pairs_hook=lambda pairs: pairs)를 썼는데, 이
훅이 최상위뿐 아니라 모든 중첩 객체에 적용돼서 @sellerStats 같은 플레이스홀더
메타데이터({"placeholders": {"subs": {"type": "int"}, ...}})가 통째로
[["placeholders", [["subs", [["type","int"]]]]]] 같은 배열로 뒤집혀버렸다.
flutter gen-l10n이 "리소스 속성이 올바른 Map이 아니다"라고 실패해서 바로
발견했고, 중첩 리스트-of-2-tuple 패턴을 재귀적으로 dict로 되돌리는 복구
스크립트를 따로 짜서 16개 파일 전부 고쳤다. git diff로 원본 커밋 대비 순수
추가분만 남았는지 확인 후 flutter gen-l10n·dart analyze·flutter test(97개)
전부 통과 재확인.

4. "무제"가 항상 한글로만 뜨는 버그

말씀 블록 없이 올린 정지 배경화면은 verse_ref에 한국어 리터럴 "무제"를 그대로
저장하는데(UploadViewModel.publish(), upload_view_model.dart:609), 책 이름
번역 함수 locRef()(presentation/format.dart)는 "책이름 + 장:절" 정규식에만
매칭해서 "무제"는 어느 언어로 봐도 번역 없이 그대로 통과됐다.

locRef()에 if (ref == '무제') return untitledByCode[code] ?? ref; 특수
분기를 추가하고, content.dart에 untitledByCode(16개 언어 "무제"/"Untitled"/
"無題"/... 번역표)를 새로 만들어 해결했다. 새 DB 값을 쓰지 않고 클라이언트
번역표만으로 해결해서, 예전에 "무제"로 저장된 기존 상품들도 즉시 소급 적용된다.

5. 댓글 시트 진입 시 새로고침 안 되던 버그

commentsProvider(FutureProvider.family, providers.dart)가 autoDispose가
아니라서 한 번 읽으면 앱이 살아있는 동안 계속 캐시로 남는다 — 댓글 바텀시트를
닫았다 다시 열어도 다른 사람이 그 사이 단 댓글·답글·좋아요가 안 보였다.
CommentsSection(comments_section.dart)의 initState에서
Future.microtask(() => ref.invalidate(commentsProvider(widget.productId)))를
추가해서, 시트를 열 때마다 서버에서 새로 읽어오게 했다. Future.microtask로
감싼 건 initState 동기 실행 중 Riverpod 프로바이더를 건드리다 생기는 lifecycle
경합을 피하기 위한 보수적 선택.

6. 그 외 확인만 하고 코드는 안 건드린 것들

  • 작가 구독 FCM 알림: notify_follow 트리거(2026.08.17, on_follow_created)가
    이미 알림 행을 만들고, send-push의 PUSHABLE 세트에 'follow'가 이미
    있으며 16개 언어 문구도 이미 있었다. 실제 DB에도 최근(9/21)까지 follow 알림이
    쌓이고 있는 걸 supabase db query로 확인 — 이미 정상 동작 중이라 추가 구현
    불필요했다.
  • KG이니시스 본인인증 코드 연결: 서버 코드(verify-identity,
    _shared/portone.ts)는 포트원 V1 범용 REST API(api.iamport.kr)만 쓰고
    어떤 PG(이니시스·다날 등)를 쓰는지 전혀 모른다 — 라우팅은 전부 포트원 콘솔
    채널 설정으로 결정되므로 앱 코드 자체는 처음부터 손댈 게 없었다(7번 참고).

7. KG이니시스 본인인증 — 실제로 붙여서 끝까지 테스트

이니시스 통합인증서비스 심사가 끝났다는 연락을 받고 실기기로 테스트를 진행했다.

  • 1차 시도: payout.dart가 쓰던 pg: 'inicis'(포트원 V1 일반결제용 코드)로
    이니시스 간편인증 화면(삼성패스·IBK·금융인증서·우리WON·하나·토스·네이버) 자체는
    떴지만 카카오·페이코가 빠져 있었다.
  • 원인 조사: 포트원 공식 문서 확인 결과 inicis_unified가 통합인증서비스
    전용 pg 값이고, inicis는 별개의(일반결제) 상품이었다 — pg: 'inicis_unified'로
    바꿔서 재시도했지만 화면이 똑같았다(사용자가 hot restart까지 해서 재확인).
  • 진짜 원인: inicis_unified로 바꾸자 이번엔 "정상적인 통합인증 처리를 위해
    m_redirect_url 파라미터 세팅이 필요합니다" 에러가 떴다. portone_flutter
    패키지(CertificationData.mRedirectUrl, IamportCertification 위젯 소스
    직접 확인)의 기본 리다이렉트 도메인을 이니시스가 안 받아준 것 — 심사 때 등록한
    도메인만 허용한다(이니시스 안내 메일 "등록된 도메인 외 이용 불가" 그대로).
    이미 스토어 등록용 법적 문서(개인정보처리방침 등)에 쓰고 있던
    https://nohyeongtaek.github.io/atelier316-info/(GitHub Pages, 우리가 실제
    소유·사용 중인 도메인)를 mRedirectUrl로 채웠더니 정상 동작했다.
  • 화면이 안 바뀐 원인(카카오 미노출)은 별개로, KG이니시스 통합인증서비스와
    포트원 통합인증(inicis_unified)이 계약과 무관하게 자체적으로 제공하는
    민간인증 목록(간편인증 vs 전자서명 vs 본인확인 구분 포함)을 웹 검색으로 정리한
    결과, 카카오 인증은 별도 서류 제출 + 이니시스 계약담당자를 통한 신청이
    필요한 것으로 확인됐다(가맹점관리자사이트 iniweb.inicis.com →
    변경/추가 → 4. 계약 및 특약 체결 → 인증서비스 계약 메뉴로 추정). 지금은
    이미 뜨는 토스·네이버만으로도(판매자 전용 플로우라 전환율 이슈 없음) 충분하다고
    판단해 카카오 신청은 보류하기로 했다.
  • 정산 자동 인증과의 연결: 본인인증이 실제로 동작하면서,
    payout.dart:166의 기존 로직(국내계좌 + 본인인증 완료 시 저장 직후
    verify-payout-account를 자동 호출해 예금주명 일치까지 확인되면 관리자 확인
    없이 바로 payout_verified: true)도 실사용 가능해졌다. PayPal 등 해외 계좌나
    예금주명 불일치 케이스는 여전히 관리자 수동 인증 목록(/admin/payouts)에
    남는 안전망 구조는 그대로 유지.



기술 스택 요약

  • Flutter/Riverpod: FutureProvider.family 캐시 무효화(ref.invalidate)로
    댓글 새로고침 처리. 공용 위젯(LiveWallpaperPreview) 한 곳만 고쳐 여러 화면에
    동시 반영.
  • Python Pillow: 서버(워커)에 번들할 구버전 호환 안내 이미지를 앱 폰트
    (NotoSerifKR-Regular.ttf)와 기존 wordmark PNG 에셋으로 직접 렌더링.
  • Node.js/Express (Cloud Run 워커): ffmpeg 변환 성공 시 고정 안내 이미지를
    previews/{id}/verse-preview.jpg·plain-preview.jpg에 추가 업로드하도록
    worker/index.js 확장, Dockerfile에 정적 자산 COPY 추가.
    gcloud run deploy --source로 재배포.
  • Supabase Edge Functions (Deno): LIVE_WALLPAPER_AUTO_PUBLISH 시크릿
    기반 조건부 게이트 유지. supabase secrets set으로 원격 시크릿 토글,
    supabase db query --linked로 운영 DB 직접 조회/수정, supabase storage ls --experimental로 업로드 결과 검증.
  • PortOne(포트원) V1 + KG이니시스 통합인증서비스: portone_flutter 패키지의
    CertificationData(pg: 'inicis_unified', mRedirectUrl: ...)로 웹뷰 기반
    간편인증 호출. 서버는 포트원 V1 REST API(api.iamport.kr/certifications/ {impUid})로 재검증 — PG사가 바뀌어도 서버 코드는 그대로인 구조.
  • l10n: flutter gen-l10n + ARB 16개 언어 직접 편집. JSON 중첩 구조를
    깨뜨리지 않도록 object_pairs_hook 없이 표준 json.load/json.dump로
    프로그래매틱 편집하는 게 안전하다는 걸 이번에 사고로 배웠다.

2026-09-25 영상 트림 UI 라이브러리 전환, 아이폰식 트림 프레임, 움직이는 배경화면 말씀 위치 통일, 게시·적용 미리보기와 진행률, 작품 제목, 코치마크 SKIP·iOS 위치 수정, 댓글 새로고침

어제 점심에 움직이는 배경화면 트림 UI를 video_trimmer 라이브러리로 갈아엎던 중에
주간 토큰 한도에 걸려 대화가 그대로 끊겼다. 오늘은 그 세션을 이어받아서 끝내는 것부터
시작했는데, 끝내고 나서 실제로 써보니 "영상이 안 움직인다", "긴 영상에선 구간을 못
옮긴다", "피드에서 말씀이 카드를 꽉 채운다", "적용하면 말씀이 길쭉해진다"가 줄줄이
나왔다. 결국 하루 종일 움직이는 배경화면 하나를 붙잡고 있었고, 마지막에 코치마크·
댓글 같은 자잘한 것들을 정리했다.

1. 끊긴 세션 이어받기

컴파일이 안 되는 상태로 멈춰 있었다

새 세션에서 dart analyze를 돌리니 upload.dart에 지워진 _pickAny를 부르는 곳이
하나 남아 있었다. 어제 마지막 요청이 "+ 버튼 분기 없애고 통합 피커로 가자 → 아니다
그냥 분기하자"로 뒤집혔고, 분기로 되돌리는 도중(교체 아이콘 두 곳 중 하나를 고치다가)
끊긴 것이었다.

처음엔 이 맥락을 모르고 주변 코드만 보고 vm.pickVideo로 고쳤는데, 사용자가 "뭐 하고
있었는지 알고 한 거냐"고 물어서 제대로 확인했다. Claude Code 대화는
~/.claude/projects/<프로젝트>/에 세션별 .jsonl로 남아 있어서, 파이썬으로 파일 수정
시각과 마지막 사용자 메시지를 뽑아 어제 세션을 찾았다. 결과적으로 고친 방향은 맞았지만,
추측으로 진행한 건 반성할 점이다. (claude --resume <세션id>로 그대로 이어갈 수도 있다.)

2. 영상을 골라도 캔버스에서 안 움직였다

원인: 한 번만 오는 이벤트를 놓쳤다

video_trimmer의 Trimmer.loadVideo()는 로드가 끝날 때 TrimmerEvent.initialized를
브로드캐스트 스트림으로 딱 한 번 보낸다. TrimViewer와 VideoViewer는 이 이벤트를
받아야 비로소 필름스트립과 영상을 그린다. 그런데 뷰모델은 영상을 먼저 로드하고, 그 다음
notifyListeners()로 위젯을 화면에 붙였다. 위젯이 붙었을 땐 이벤트가 이미 지나간 뒤라
트림 UI는 통째로 안 뜨고 영상은 첫 프레임에 멈춰 있었다. 사용자 눈엔 "일반 배경화면처럼
동작"하는 걸로 보였다.

notifyListeners();                        // 뷰어 위젯을 먼저 붙이고
await WidgetsBinding.instance.endOfFrame; // 한 프레임 기다린 뒤
await trimmer.loadVideo(videoFile: file); // 로드 → 이벤트를 받을 수 있다

순서만 바꾸면 끝이었다. 두 가지가 더 있었다.

  • 자동 재생이 없다. 패키지는 재생을 알아서 시작하지 않는다. TrimViewer가 초기화될 때
    처음 부르는 onChangeEnd에서 재생을 시작하고, 구간 끝에서 멈췄다는 신호
    (onChangePlaybackState(false))가 오면 구간 처음으로 seekTo 후 다시 play()해서
    반복 재생을 만들었다.
  • 두 번째 영상에서 죽는다. VideoViewer.dispose()가 trimmer.dispose()로 스트림을
    닫아버려서, 같은 Trimmer로 영상을 다시 로드하면 "Cannot add new events after calling
    close" 예외가 났다. 영상을 고를 때마다 Trimmer를 새로 만든다.

덤: "동영상을 업로드 해주세요" 문구가 안 보였다

사진 모드엔 빈 캔버스에 "이미지를 업로드 해주세요"가 있는데 영상 모드엔 없다는 요청이
왔다. 문구를 새로 만들 필요는 없었다. 이미 있었는데 영상을 고르기 전에도 빈 영상 뷰어가
캔버스를 덮어서 가려지고 있었다. 뷰어는 videoFile != null일 때만 그리고, 문구 키
uploadSlotVideo를 16개 언어로 추가했다.

3. 긴 영상에서 구간 가운데를 잡아 옮길 수가 없었다

요구사항은 "양끝을 잡으면 길이 조절, 가운데를 잡으면 구간 이동"이었다. 이게 제일 오래
걸렸다. 세 번 고쳤다.

1차: 뷰어 타입이 auto라서

TrimViewer의 기본값 ViewerType.auto는 영상이 길면 scrollable 뷰어로 바뀐다. 이
뷰어는 창이 고정되고 필름이 밀리는 방식이라 가운데 드래그 자체가 없다.
type: ViewerType.fixed로 고정했다.

2차: 35초 영상에선 여전히 안 됐다

fixed 뷰어는 필름 전체 폭이 영상 전체 길이라서, 35초 영상이면 3초 창이 약 29px밖에
안 된다. 패키지 소스(fixed_trim_viewer.dart)를 읽어보니 문제가 두 겹이었다.

  • 옆면 터치 영역(sideTapSize, 기본 24px)이 창 안쪽을 잡아먹어서 가운데 영역이
    "창 폭 − 24×2"로 음수가 된다.
  • 더 근본적인 건 Flutter의 드래그 인식 방식이었다. 손가락이 터치 슬롭(약 18px)만큼 움직인
    뒤의 위치를 onHorizontalDragStart로 넘기는데, 패키지는 그 위치가 창 안인지로 판정한다.
    창이 29px이면 가운데를 눌러도 판정 시점엔 이미 창 밖이다.

설정값으로는 못 고친다. 그래서 video_trimmer 5.0.0을 packages/video_trimmer/로
복사하고 pubspec.yaml에서 path: 의존성으로 바꿨다. 포크해서 GitHub에 올리는 것보다,
고칠 곳이 파일 하나라 저장소 안에 두는 쪽이 추적하기 쉽다. 고친 곳마다 [PATCH] 주석을
달고 PATCH.md에 사유를 적어뒀다.

3차: 손잡이를 구간 바깥으로 — 아이폰처럼

2차 패치 뒤에도 "여전히 잘 안 된다", "오른쪽 쉐브론을 왼쪽 끝까지 밀면 두 손잡이가
엇갈린다"는 리포트가 왔다. 둘 다 손잡이를 구간 안쪽에 그리는 구조 때문이었다. 아이폰
사진 앱은 손잡이가 선택 구간 바깥에 붙어 있다. 그대로 따라했다.

  • 패키지 필름 양옆에 손잡이 폭(16px)만큼 Padding을 두고, 왼쪽 손잡이 = [start−16, start],
    오른쪽 = [end, end+16]. 그 외(구간 안쪽 포함) 어디를 끌어도 구간이 통째로 움직인다.
  • dragStartBehavior: DragStartBehavior.down: 터치한 그 자리로 판정한다.
  • 필름을 탭하면 그 자리를 가운데로 구간이 이동한다(onTapUp, 사용자가 제안한 방식).
  • 이동량을 경계에 맞춰 clamp하고 최소 구간을 0.5초로 둬서 손잡이가 엇갈리지 않는다.
35초 영상, 3초 창(약 29px)원본 패키지오늘 패치 후
창 가운데를 눌러 끌기창 밖 판정 → 무시구간 이동
필름 아무 데나 탭아무 일 없음그 자리로 구간 이동
오른쪽 손잡이를 왼쪽 끝까지두 손잡이 엇갈림0.5초에서 멈춤
빠르게 끝까지 끌기끝에 못 미쳐 멈춤끝에 딱 붙음

4. 트림 바를 아이폰 사진 앱처럼

패키지 페인터(TrimEditorPainter)는 테두리·원·재생선만 그릴 수 있어서 색만 바꿀 수
있었다. 처음엔 노란색(#FFD60A)으로만 바꿨다가, 쉐브론까지 해달라는 요청에 직접
그리기로 했다. 패키지 테두리·재생선은 Colors.transparent로 죽이고, 그 위에
IgnorePointer + CustomPaint(_TrimFramePainter)를 겹쳤다. 제스처는 아래 패키지가 그대로
받는다.

  • 노란 프레임은 바깥 둥근 사각형에서 구간 자리를 뚫는 PathFillType.evenOdd로 그린다.
  • 양끝 손잡이에 검은 쉐브론 ‹ ›, 구간 밖 필름은 검정 55%로 어둡게.
  • 패키지는 필름 폭을 "높이의 정수배"로 깎아 쓴다. 같은 계산으로 폭을 미리 맞춰 넘겨야 오버레이가
    필름과 어긋나지 않았다.

흰 재생선이 쉐브론을 넘어갔다

사용자가 "쉐브론이 짧은 건지 흰 선이 넘어가는 건지 봐달라"고 했다. 넘어간 건 흰 선이었다.
패키지 재생선은 실제 재생 위치가 아니라 AnimationController로 구간 오른쪽 끝까지 가는
애니메이션
이라 손잡이 위까지 그려졌다. 재생선도 오버레이가 직접 그린다.
CustomPainter(repaint: videoPlayerController)로 재생 위치가 바뀔 때마다 다시 그리고,
(position − start) / length를 구간 안쪽 폭에 매핑해서 절대 구간 밖으로 안 나간다.

5. 피드 카드에서 말씀이 카드를 꽉 채웠다

말씀을 넣은 움직이는 배경화면이 홈 피드에서 편집할 때 자리에 안 보이고 카드를 꽉 채웠다.
원인은 단순했다. 피드 오버레이(_LiveVerseBlock)가 VerseBlock.size(편집 캔버스 기준
px)를 작은 카드에 그대로 썼다. 360px 캔버스에서 24px인 글자가 120px 카드에서도 24px이면
당연히 꽉 찬다.

정지 이미지는 래스터를 구워서 이 문제가 없다. 영상은 위젯을 얹는 구조라 LiveCanvas라는
공용 위젯을 만들었다. 편집 캔버스 크기(canvasWidth × 16/9) 그대로 배경과 말씀을
배치한 뒤 FittedBox(fit: BoxFit.cover)로 통째로 줄인다. 편집기와 같은 좌표계·같은 글자
크기로 레이아웃하니 줄바꿈까지 똑같다. 편집기 블록과 비교해서 빠져 있던 여백(6px),
정렬별 crossAxisAlignment, 구절 표기 위 간격(8px)도 맞췄다. 시스템 글자 크기에 안 흔들리게
MediaQuery.withNoTextScaling도 씌웠다.

편집기 캔버스의 영상 표시도 바꿨다. VideoViewer는 영상 비율대로 여백(레터박스)을 남기는데
피드는 꽉 채우기(cover)라 기준이 달랐다. 이제 VideoCover(FittedBox cover +
VideoPlayer)로 편집기·피드·미리보기가 전부 같은 규칙이다.

6. 배경화면으로 적용하면 말씀이 길쭉해졌다

원인이 두 개 겹쳐 있었다.

  1. 안드로이드가 영상을 늘려 그렸다. VideoWallpaperService.kt는
    MediaPlayer.setSurface로 배경화면 Surface에 직접 그리는데, 기본 스케일 모드
    (SCALE_TO_FIT)는 비율을 무시하고 화면을 꽉 채운다. 9:16 영상이 9:20 화면에 가면 세로로
    늘어나고, 구워 넣은 글자도 같이 늘어난다. onPrepared에서
    setVideoScalingMode(VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING)으로 바꿨다.
    비율을 지킨 채 채우고 넘치는 쪽을 자르는 방식으로, 앱의 cover와 같은 규칙이다.
  2. 오버레이를 영상 비율로 늘려 붙였다. 말씀 오버레이 PNG를 항상 1080×1920(9:16)으로
    그려서 서버에 보내면, 워커의 ffmpeg가 scale2ref로 영상 실제 크기에 맞춰 늘렸다. 이제
    product_media에서 원본 영상의 실제 크기를 읽어 그 크기로 오버레이를 그리고, 블록은
    영상 안의 가운데 9:16 영역(cover로 잡히는 캔버스 자리)에 배치한다(renderVerseOverlay에서
    canvas.translate).
전후
오버레이 PNG 크기항상 1080×1920영상과 같은 픽셀 크기
ffmpeg scale2ref비율이 다르면 글자 늘어남크기가 같아 그대로
기기 표시화면 비율로 늘림비율 유지 + 넘치는 쪽 자름

서버 코드는 한 줄도 안 고쳤다. 클라이언트에서 오버레이만 제대로 그려 보내면 워커는 이미
올바르게 합성한다. 그래서 이미 올라간 작품에도 그대로 적용된다.

7. 게시·적용 전 미리보기와 0~100% 진행률

게시 미리보기

정지 이미지는 "게시"를 누르면 미리보기 시트로 확인하고 올린다. 영상은 "클라이언트 합성이
없어 보여줄 게 없다"는 이유로 이 단계를 건너뛰고 있었다. 하지만 5번의 LiveCanvas가 생기니
보여줄 게 생겼다. 편집기에서 이미 재생 중인 VideoPlayerController를 그대로 배경으로 쓰는
_LivePreviewSheet를 만들었다. 말씀 버전/배경만, 15개 언어 전환은 LiveCanvas(lang: ...)만
바꾸면 번역해서 다시 그린다.

진행률

업로드와 서버 변환(Cloud Run ffmpeg)은 수십 초가 걸리는데 진짜 진행률을 알 수가 없다.
Supabase uploadBinary도 Edge Function 호출도 진행 콜백이 없다. 그래서 단계별 구간 안에서
천천히 채우는 추정치로 갔다. 적용은 블록 로드 10% → 합성·다운로드 90%까지 → 기기 반영
98% → 100%. 각 구간 안에서는 Timer.periodic으로 남은 거리의 일부씩 다가간다. 공용
ProgressRing 위젯(원형 인디케이터 + 가운데 퍼센트)으로 게시·적용 둘 다 표시한다.

적용 미리보기

상세 > 배경화면으로 설정 시트 맨 위에 피드 미리보기 영상(feed-preview.mp4)을 반복
재생하고, 고른 버전·언어의 말씀을 LiveCanvas로 얹었다. 말씀이 없는 작품은 원래 시트 없이
바로 적용했는데, 이제 미리보기는 보여주고 버전·언어 선택만 숨긴다.

8. 작품에 제목을 붙이게 했다

지금까지 정지 이미지는 첫 말씀 구절이 곧 제목이었다(products.verse_ref). 움직이는
배경화면만 title 컬럼이 있었다. 이제 둘 다 제목이 필수이고, 첫 말씀 구절은 부제목으로
내려간다. 컬럼은 이미 있어서 스키마 변경은 없다(live_requires_title 제약도 still엔 안
걸린다).

  • ProductDraft.title 추가, 업로드 저장, PublishOutcome.noTitle 검증, 임시저장에도 포함.
  • 표시는 productTitle()/productSubtitle()(format.dart) 두 함수로 모았다. 제목이 없는
    예전 작품은 예전처럼 구절이 제목 자리에 온다("이미 올라온 작품은 그냥 둬"). 말씀 없이 올린
    정지 이미지의 자리값 '무제', 말씀 없는 영상의 verse_ref = title(구버전 호환 더미)은
    부제목으로 안 나오게 거른다.
  • 앞으로 올리는 움직이는 배경화면은 verse_ref에 제목 대신 첫 말씀 구절을 넣는다. 그래서
    홈 피드 말씀 검색에도 걸린다.

9. 코치마크 SKIP이 안 되고, iOS에선 설명이 엉뚱한 데 떴다

SKIP을 눌렀는데 또 뜬다

업로드 화면 코치마크는 "캔버스 → 말씀 추가 → (사진 올리면) 교체·자르기 → (블록 고르면)
색상"으로 단계마다 TutorialCoachMark를 따로 띄운다. 중간에 스크롤을 맞추려고 일부러
그렇게 나눈 건데, SKIP은 지금 띄운 것 하나만 닫는다. 닫자마자 다음 단계가 또 떠서 사용자
입장에선 SKIP이 안 되는 것과 같았다. _toursSkipped 플래그를 두고, SKIP 시 남은 모든 투어의
"본 적 있음" 키를 shared_preferences에 한꺼번에 기록해서 이후 단계가 아예 시작하지 않게 했다.

iOS에서 사진 업로드 설명이 제자리에 안 뜬다

TutorialCoachMark.show()는 호출하는 순간 대상 위치를 한 번만 잰다. 확실한 원인은
기기에서 확인하지 못해서, 가능성 높은 두 가지를 다 막았다.

  • 설명 글이 화면 밖으로 밀려났을 가능성. 캔버스(9:16)가 화면을 거의 채우는데 설명은
    항상 대상 아래(ContentAlign.bottom)에 뒀다. 세로 긴 iPhone에선 아래 공간이 없다.
    이제 아래 공간이 170px 미만이면 위로, 위도 없으면 ContentAlign.custom으로 대상 안쪽
    가운데에 둔다.
  • 자리 잡기 전에 쟀을 가능성. 사진 선택기에서 돌아올 때 iOS는 inactive → resumed를 거치며
    레이아웃이 한 번 더 움직인다. AppLifecycleListener로 resumed를 기다리고, 대상의
    localToGlobal 위치·크기가 3프레임 연속 같을 때까지(최대 약 1초) 기다린 뒤 띄운다.
    대상 목록도 이 대기가 끝난 다음에 만들어야 설명 위치 판단이 최종 좌표 기준이 돼서,
    _showTour가 TargetFocus 대신 (id, key, 문구) 레코드를 받도록 바꿨다.

10. 댓글창을 다시 열어도 새 댓글이 안 보였다 (두 번째)

9/23에 한 번 고친 문제다. 그때는 commentsProvider가 autoDispose가 아니라 앱 수명 내내
캐시가 남는 걸, 시트 initState에서 ref.invalidate하는 우회책으로 막았다. 그래도 다시
리포트가 왔다. 무효화해도 새로 읽는 동안 옛 목록이 그대로 보이는 구조라 근본 해결이
아니었다. FutureProvider.autoDispose.family로 바꿔서 시트를 닫으면 캐시를 버리고, 열 때마다
항상 서버에서 새로 읽는다. 상세 상단의 댓글 수(Product.commentCount)는 별도 provider라
시트를 열 때 productProvider도 같이 새로고침한다.

11. 구버전 앱용 안내 이미지 문구 변경 (배포는 못 했다)

스토어에 나가 있는 구버전 앱은 움직이는 배경화면을 모른다. 그래서 워커가
previews/{id}/verse-preview.jpg 자리에 안내 이미지를 채워준다(9/23). 문구를
"움직이는 작품은…"에서 "움직이는 배경화면은 업데이트하면 볼 수 있어요"로 바꿔 달라는
요청이었다. 이미지를 처음부터 다시 만들지 않고, Pillow로 기존 이미지의 한글 줄 영역만
배경색으로 덮고 같은 폰트(NotoSerifKR-Regular.ttf)로 다시 썼다. 원래 글자 크기는 기존 줄의
잉크 폭(894px)을 재서 48px로 역산했다. 새 문구는 두 글자가 늘어 46px로 살짝 줄였다.

이 이미지는 Cloud Run 워커 컨테이너에 번들돼 있어서 재배포해야 반영된다. 그런데
gcloud run deploy는 운영 배포라 Claude Code 권한 분류기에서 거부됐다. 배포는 직접 해야
한다. 이미 올라간 작품의 이미지도 옛 문구 그대로다(지금은 전부 hidden 테스트 작품이라 급하진 않다).

12. 자잘한 UI

  • 영상 모드 화면 제목을 "움직이는 배경화면 만들기"(uploadTitleLive, 16개 언어)로 바꿨다.
  • 영상 교체 아이콘을 사진 모드와 같은 Icons.image_outlined로 바꿨다.
  • 영상 모드에서 길이 조절 바를 "말씀 추가" 위로 올리고, "3초 구간을 골라주세요" 안내 문구를 지웠다.
  • "+" 유형 선택 시트와 그 안의 카드 두 개를 width: double.infinity로 가로 꽉 채웠다. 카드는
    Column(crossAxisAlignment.start) 안에 있어서 글자 폭만큼 줄어들어 있었다.

13. 움직이는 영상을 위해 사용한 생성형 AI 모델

Hugging Face(AI 오픈 소스 사이트)


쓴 기술 정리

  • video_trimmer 5.0.0 (로컬 패치, path: 의존성): 필름스트립·트림·재생을 직접 만들던
    코드를 대체했다. 썸네일이 느리고 듬성듬성했던 자체 구현보다 낫다는 사용자 판단이었다.
    대안인 flutter_native_video_trimmer는 트림 실행만 있고 UI가 없다. 드래그 판정만
    packages/video_trimmer에서 패치했고, 버전을 올리면 PATCH.md의 패치를 다시 적용해야 한다.
  • CustomPainter + repaint: Listenable: 트림 프레임 오버레이. 재생 위치(VideoPlayerController
    는 ValueNotifier)가 바뀔 때마다 위젯 재빌드 없이 페인트만 다시 한다.
  • FittedBox 기반 고정 좌표계(LiveCanvas): 편집 캔버스 크기로 레이아웃한 뒤 통째로 축소해서
    편집기·피드·미리보기의 말씀 위치·줄바꿈을 일치시켰다.
  • Android MediaPlayer.setVideoScalingMode: 라이브 배경화면 Surface에 cover 규칙 적용.
  • Supabase product_media: 원본 영상 픽셀 크기 조회 → 오버레이를 같은 크기로 렌더.
  • Riverpod FutureProvider.autoDispose.family: 댓글 목록 캐시 수명을 시트와 맞췄다.
  • tutorial_coach_mark ContentAlign.custom + AppLifecycleListener: 코치마크 위치 안정화.
  • Python Pillow: 워커 안내 이미지 문구 부분 교체.

To-Do

오늘 한 것 정리

  • 끊긴 세션 이어받기(_pickAny 컴파일 에러)
  • 영상 트림 UI video_trimmer 전환 마무리: 초기화 이벤트 경합, 반복 재생, Trimmer 재생성
  • 트림 바: fixed 뷰어, 손잡이를 구간 바깥으로, 탭 이동, 최소 0.5초, 경계 clamp (로컬 패치)
  • 아이폰식 노란 프레임·쉐브론·구간 밖 어둡게, 재생선이 구간 밖으로 안 나가게
  • LiveCanvas/VideoCover로 편집기·피드·미리보기 말씀 위치 통일
  • 적용 시 오버레이를 영상 실제 크기로, Android 배경화면 비율 유지 크롭
  • 영상 게시 미리보기 시트 + 0~100% 진행률, 적용 미리보기 + 진행률
  • 정지 이미지·영상 제목 필수, 첫 말씀 구절은 부제목
  • 코치마크 SKIP 전체 종료, iOS 위치 안정화·설명 글 자동 배치
  • 댓글 목록 autoDispose로 진입마다 새로 조회
  • 구버전용 안내 이미지 문구 변경(파일만)
  • 영상 모드 화면 제목·교체 아이콘·"동영상을 업로드 해주세요"·유형 시트 폭 정리

남은 것 — 출시 전 필수

  • 워커 재배포: gcloud run deploy atelier316-video-worker --source ./worker --region asia-northeast3
    (저장소 루트에서). 운영 배포라 직접 실행.
  • 실기기 확인(Android): 긴 화면 기기에서 적용 시 말씀이 안 늘어나는지.
    SCALE_TO_FIT_WITH_CROPPING이 배경화면 Surface에서 먹히는지는 실기기에서만 알 수 있다.
  • 실기기 확인(iOS): 업로드 첫 진입·사진 선택 후 코치마크 위치.
  • 새 버전이 스토어에 충분히 퍼지면 LIVE_WALLPAPER_AUTO_PUBLISH를 켜고, "+" 유형 선택의
    관리자 전용 조건을 푼다.

남은 것 — 그 외

  • 이미 올라간 움직이는 작품의 verse-preview.jpg/plain-preview.jpg를 새 안내 이미지로 교체
    (지금은 전부 hidden 테스트 작품).
  • 게시·적용 진행률은 추정치다. 실제 업로드 진행률이 필요하면 Storage 업로드를 진행 콜백이 있는
    방식(TUS resumable 등)으로 바꿔야 한다.
  • liveUploadTrimInstruction 번역 키는 이제 안 쓰니 정리해도 된다.
  • 9:16이 아닌 영상은 편집기에서부터 가장자리가 잘려 보인다(cover). 레터박스 옵션이 필요해지면 추가.
profile
https://github.com/NohYeongtaek

0개의 댓글