# 최종 i2i 변환 — provider 교체와 재료 전달 (설계)

2026-08-25 · 브랜치 `feat/cine-camera-light` · Codex 교차검증 2회전 반영

## ⓪ 왜 하는가

지금 최종 변환(`still_cine_transform`)은 **재료 0 에 자유 최대**다. 받는 것은
853자 문안과 소스 스틸 한 장뿐이고, 씬 원문도 `camera_direction` 도
`lighting_mood` 도 하나도 안 받는다. 그러면서 이렇게 말한다.

    you may change the camera angle, height, distance or foreground layering
    Use **motivated** practical light with soft falloff

「동기 있는 실제광」을 쓰라면서 **무엇이 그 빛을 동기 짓는지 안 알려준다.**
이 씬의 광원이 천장 형광등 하나인지 가로등인지 모르는 채로. 그러니 지어낸다.

사용자 판정(카메라 절 A/B 실측): **「B(카메라 절 없음)가 훨씬 좋다, 그런데
구도가 안 좋다」** — 연출 산문이 그림 품질을 망치는데 구도는 그것이 잡고
있었다는 뜻이다.

→ **구도는 이미지로, 연출 산문은 앞단에서 걷고, 최종 i2i 에 재료를 준다.**

## ① 층마다 무엇을 얹는가

    콘티/스케치   배치·기하만 (마네킹·마커)          ← 의도대로 작동 중
    bgfirst/roll  장소 외형을 조사 사진으로 얹는다    ← 막혀 있음 (별건: 목표1)
    최종 i2i      빛감·앵글을 재료 받아 얹는다        ← 재료 0  (이 문서)

★설계 의도는 이미 코드에 적혀 있다 — `shot_conti_light_step.py:66`
「배경·인물 외형은 **뒤 단계에서 i2i 로 얹는다**」. 뒤 두 층이 자기 몫을
안 하고 있을 뿐이다.

## ② ★Grok 은 손대지 않는다 — 공통 transport 는 잘못된 추상화

Codex 지적을 수용한다. 근거가 **돈**이다.

`GrokImageClient` 는 retry loop 안에서 **매 HTTP 시도 직전에** 예산을
예약한다(`grok_image_client.py:184-190`). 동기 왕복이라 그것이 맞다.

그런데 **fal/reve 는 queue 다 — submit 1회가 유료 작업 1건이고 poll 은 새
호출이 아니다.** Grok 의 「timeout 이면 새 요청」을 복제하면 fal 이 이미
접수·과금한 job 을 로컬 timeout 뒤 **다시 submit** 한다. $0.25 짜리를.

    Grok 파일·호출 경로     무변경
    Reve                    transport 를 독립 구현
    공유하는 것             `GeminiImageClient` 의 context projection 까지만
                            (`set_context` · `_ctx` · `_trace_step` · `_log_ctx`)

코드 공유 대신 **conformance 시험**으로 계약을 고정한다 — 두 client 모두
「submit 직전 reserve 1회 · terminal DB 로그 1회 · Opik 1회 · 성공 시 capture
1회 · 실제 effective prompt 기록」.

## ③ ★Reve 는 queue 상태기다 (동기 왕복 금지)

**`sync_mode=true` 는 쓰지 않는다.** 결과가 data URI 로 오는 대신 **request
history 에 남지 않아**, 크래시나 응답 유실 뒤 `request_id` 로 회수할 수 없다.

    1. submit 성공 즉시  request_id + fingerprint + provider + endpoint 를
                         **pending record 로 durable 저장**
    2. 이후 timeout      「poll 중단」일 뿐 **새 submit 근거가 아니다**
    3. 재진입            같은 request_id 를 poll / result
    4. terminal success  결과 URL 즉시 다운로드 → PNG 정규화 → atomic write
                         → applied 갱신
    5. submit 응답 유실   접수 여부가 모호하면 **자동 재제출 금지** —
                         fail-closed 후 운영자 확인

★현행 cine 의 「파일 durable 후 record」는 $0.03 동기 Grok 에서 1회 재지출을
감수한 계약이다(`cine_transform.py:37-39`). $0.25 async 에는 **pending
request_id 를 먼저 남기는** 계약이 맞다. 이 pending 은 실제 지출 시도이므로
JIT 가 지출로 읽어도 거짓이 아니다.

입력 이미지는 **base64 data URI** 로 보낸다 — 업로드가 필요 없다.
플랫폼의 숨은 재시도·동등 endpoint fallback 은 끈다
(`x-fal-no-retry: 1` · `x-app-fal-disable-fallback: 1`). 한 submit 이 한
지출이어야 provider 지문과 실제 실행이 일치한다.

## ④ ★Prepared request — 지문이 실제 발송문을 봐야 한다

`labeled_references` 는 **라벨 텍스트**로 참조를 가리키는데 reve 는 **frame
인덱스**(`<frame>N</frame>`, 0-based)다. 이 변환을 `generate_image()` 안에서
몰래 하면 **지문이 실제 나간 문장을 못 본다.**

    의미 역할(무엇을 왜 붙이는가)   호출부
    provider 문법(어떻게 적는가)     adapter

    prepare_request(base_prompt, references, options) -> PreparedImageRequest
      references = (role: enum, display_label, bytes) 의 **고정 순서**
      Prepared  = provider_id · logical_endpoint · model · effective_prompt
                  · ordered(role, label, ref_sha) · output 영향 옵션
                  · request_contract_sha256

    resolve_or_run_cine_transform:
      prepare → 지문/legacy dual-read → 미재사용이면 send_prepared(prepared)
      send 는 **재조립하지 않고** Prepared 를 그대로 보낸다

★`final_prompt_sha256` 만으로는 부족하다 — Grok 은 라벨을 prompt 본문이
아니라 **별도 content text** 로 붙인다(`grok_image_client.py:106-122`).
지문은 `provider | logical_endpoint | model | effective_prompt |
ordered(role,label,ref_sha) | output 영향 옵션` 의 canonical hash 여야 한다.
raw HTTP JSON·base64 전체를 해시할 필요는 없다.

로그·Opik·capture 에는 base prompt 가 아니라 **`Prepared.effective_prompt` 와
role 순서**를 남긴다.

## ⑤ ★기존 완주본을 다시 사지 않는다 — 무쓰기 dual-read

현행 지문은 `contract_version | stem_content_hash | model | sel_bytes`
(`cine_transform.py:88-96`). provider·endpoint 가 없다. 그냥 추가하면
**완주 판의 변환이 전량 재실행**된다.

    fingerprint_version = 2   (provider · logical_endpoint · model
                               · effective_prompt · source_sha · role별 ref_sha)

    dual-read:
      prior 가 legacy 이고  v1 fp 가 맞고  현재 선택이 **정확히 legacy**
      (Grok / openrouter-chat-completions / 같은 model) 일 때만 기존 파일 재사용.
      ★이 갈래에서는 records 를 **1비트도 쓰지 않는다** (거짓 지출 방지).

    진짜 재생성이 일어난 샷만 v2 record 를 쓴다.

    ★소급 동등 처리 금지 — Reve 전환 · edit↔remix · prompt 의미 변경 ·
     ref 집합 변경은 산출 계약이 **실제로 다르다**. 재생성이 맞다.

비용 억제: **새/미생성 cine 부터 Reve 기본**, legacy 완주본은 추론된 Grok 에
pin, `still_cine_migrate_existing=false` 기본 + 에피소드/샷 allowlist canary.
전 완주본을 Reve 산출로 요구하면 그 비용 자체는 없앨 수 없고 배치·상한만
둘 수 있다.

endpoint 는 변동하는 host URL 이 아니라 **논리 ID** 로 적는다 —
`openrouter/chat-completions` · `reve/2.1/edit` · `reve/2.1/remix`.

## ⑥ ★검열·재시도·과금 (fal 공식 계약)

    detail[].type == content_policy_violation   HTTP 422 · non-retryable
    detail[].type == no_media_generated         HTTP 422 · non-retryable
                                                · moderation 과 **다른 실패**

판단은 msg substring 이 아니라 **exact type**. 재시도 가부는
`X-Fal-Needs-Retry` 헤더 우선. **type 이 없으면 「unknown 4xx terminal」로
기록하고 moderation 으로 추정하지 않는다** (일부 endpoint 가 아직 이 구조로
이행 중이다).

★**「거부면 무료」가 아니다.** fal 공식 FAQ 상 5xx 는 무료지만 422 같은
client error 는 **GPU 가 이미 돌았으면 과금될 수 있다.** 그래서
`content_policy_violation` 은 **potentially billed** 로 취급한다.

→ Grok 의 give-up=2(같은 지문을 두 번 보냄)를 그대로 쓰지 않는다.
**공식 non-retryable policy rejection 1회에서 즉시 `declined` 로 확정**한다.
`no_media_generated` 는 moderation 셈에 섞지 말고 별도 terminal type 으로.

재시도 표:

| 상황 | 처리 |
|---|---|
| request_id 확보 후 | 새 submit **금지** · 같은 job poll |
| 422 content_policy / no_media | 재시도 **금지** |
| 5xx terminal | 공식상 무료 · retry 헤더가 허용할 때만 새 submit |
| timeout / connection | request_id 있으면 poll · **접수 불명이면 자동 재submit 금지** |
| 예산 reserve | **오직 새 submit 직전** — poll·result·download 에는 하지 않음 |

첫 **자연 발생** canary 실패에서 보존할 것: HTTP status ·
`X-Fal-Needs-Retry` / `X-Fal-Error-Type` 헤더 · JSON `detail[].type` ·
fal `request_id` · endpoint · provider · latency ·
**billing-events API 에서 같은 request_id 의 실제 cost 행**.

★일부러 검열 입력을 만들어 $0.25 를 태우지 않는다. 자연 실패가 없으면
parser 단위시험까지만 하고 **「Reve 실제 거부 과금은 미측정」으로 남긴다.**

## ⑦ 비용

    Grok  약 $0.03 / image   (현행 · `cine_transform.py:39` 주석)
    Reve      $0.25 / image   (fal 공식 안내) — **8.3배**

기본값 승격 전 **6샷 canary + 예산 상한**. 81샷 판이면 변환만 $20 대 $2.4.

## ⑧ 재료로 무엇을 넘기는가 (목표2-B)

★조명은 카메라와 성격이 다르다.

| 축 | 성격 | 누가 정하나 |
|---|---|---|
| 카메라 각도·높이·거리 | 연출 재량 | cine — **재료를 주면** |
| **광원의 존재·종류·색** | **세계 사실** | 전 단계 hard lock — 바꾸면 안 됨 |
| 빛의 대비·falloff·분위기 | 연출 재량 | cine |

`shot_staging` 이 「세계 사실 근거 경계」 절을 길게 들여 *「광원의 색·종류는
씬 원문에 근거가 있을 때만」* 이라고 막아 놨는데, cine 의 `relight` 자유가
그것을 통째로 무시한다. 원문 광원이 무엇인지 안 알려주니까.

지킬 의미 축 넷은 v47 이 실측으로 확인한 것 — **자리 · 거리와 잘림 ·
화면 배치 · 시각 관계**(등급낱말 8/12→1/12, 안전 축 손실 0).

## ⑨ ★두 주입점을 갈라야 한다 (Codex C, 실물 확인됨)

camera/light 는 roll base 뿐 아니라 **bgfirst Step1 에도** 간다
(`still_recipe_service.py:3980-3983, 3994-4007` · `still_recipe.py:1894-1899`).
실물 S2sh1 bgfirst 프롬프트에 `CAMERA & FRAME` 과 `LIGHTING & MOOD` 가 둘 다
있다. 그리고 다음 마네킹→사람 단계는 「KEEP EXACTLY background and camera
framing」이다(`stage_head_mannequin.md:11-15`).

**즉 Step1 픽셀에 이미 앵글과 조명 룩이 구워진다. roll 산문만 걷으면
반쪽이다.**

## ⑩ 남은 BLOCK — 구현 전 정할 것

1. **post-cine 검증** — 현재 cine 결과는 무검증으로 primary 가 된다
   (`still_recipe_service.py:4524-4552`). 재료를 줘 자유를 좁히는 것과 별개로
   **최종 픽셀을 판정하는 단계가 0** 이라는 사실은 남는다. 불변축(인물·장소·
   소품·순간·**광원 사실**) + intent 준수 VLM + 실패 시 원본 fallback 또는
   1회 재시도. → **A/B 결과를 보고 값어치를 정한다.**
2. **judge 권위** — pre-cine judge 가 base prompt 의 framing·camera 를 2순위로
   본다(`judge_still.md:80-90`). roll 에서 산문을 빼면 심판 기준도 약해진다.
   원문을 roll 에 되돌리지 말고 **generator 엔 layout image + 최소 hard
   framing, judge 엔 별도 compact composition rubric**.
   `broll_composition_variation.md:1` 도 함께 개정.
3. **연속 샷 HOW** — cine 산출은 다음 샷 prev anchor 가 **아니다**
   (`cine_transform.py:3-6`). 최종 단계가 샷마다 독립적으로 앵글·빛을 정하면
   **시퀀스가 튄다.** sequence camera/light plan 을 주거나 previous cine 을
   soft continuity ref 로 쓰는 별도 설계 필요.
4. **remix 에 canon 을 줄 것인가** — source + layout + canon 이면 모델 앞에
   **세 geometry 권위**가 동시에 선다(소스 구도 · layout 구도 · canon 사진
   자체의 카메라). 최종 단계라도 역할 분리가 자동으로 생기지는 않는다.
   위험 = canon viewpoint 로 끌림 · 인물/소품 이동 · 마네킹·마커 재등장.
   → 4-arm 비교: **E**(edit, source) · **R-L**(remix, source+layout) ·
   **R-C**(remix, source+canon) · **R-LC**(셋 다). 최소 E vs R-LC.
   측정은 「사람 수·정체성·복장·소품·순간·광원 사실」 / 「angle·height·crop·
   subject position」 / 「canon 재료·노면·간판 충실도」 / 「마네킹·마커·글자
   누출」 / 「canon viewpoint 누출」로 **나눠서** 잰다.

## ⑪ 관련

- 카메라 권위 실측 = `backend/tools/prompt_measure/build_camera_authority_gallery.py`
- 카메라 절 A/B = `ab_camera_clause.py` (사용자 판정: B 가 훨씬 좋다·구도는 나쁘다)
- 되돌리기 = `docs/prompt-optimization/ROLLBACK.md`
- fal 공식: 모델 스키마 `fal.ai/models/reve/2.1/{edit,remix}/api` ·
  오류 유형 `fal.ai/docs/documentation/model-apis/errors` ·
  실패 과금 `.../faq` · request 별 청구 `fal.ai/docs/platform-apis/v1/models/billing-events`
