
    -ǚj                    F   d Z ddlmZ ddlZddlZddlZddlZddlmZmZ ddl	m
Z
 ddlmZmZmZmZmZ ddlmZ ddlmZ dd	lmZ dd
lmZ ddlmZ ddlmZ ddlmZ  ddl!m"Z"m#Z#m$Z$m%Z%m&Z&m'Z'm(Z( ddl)m*Z* ddl+m,Z, ddl-m.Z. dgZ/ ej`                  e1      Z2 G d d      Z3y)u  Scene persistence service — scene_image_service에서 분리된 DB 저장 전용.

W5 F22 Phase B.9.1~B.19 (2026-04-22): scene_image_service.generate_images()의
ImageAsset 저장/복원 + WorldGuide 해소 로직을 점진 이관.

## 공개 API

| 메서드 | 역할 |
|--------|------|
| `save_scene_variations(var_results_list, scene_lineage)` | N variation ImageAsset bulk 저장 + commit |
| `set_primary_asset(asset_id)` | 단일 asset을 is_primary=1로 업데이트 + commit |
| `save_single_scene_asset(scene_result, lineage)` | 단일 scene asset 저장 + auto_set_primary + commit |
| `save_fal_angle_asset(...)` | fal.ai angle-edited asset 저장 + commit |
| `build_resume_state(stills, already_done_stills, entity_lookup)` | resume 모드에서 기존 scene asset들을 읽어 scene_paths/scene_paths_by_id/scene_results/location_history 4-map 복원 |
| `resolve_world_guide(...)` | WorldGuide 해소 (hash 재사용 또는 재생성) + project style 주입 |
| `load_episode_entity_dicts(episode_id)` | 에피소드에 연결된 EntityCanon을 dict 리스트로 로드 |
| `load_episode_still_dicts(episode_id)` | 에피소드의 선택된 SceneStill을 dict 리스트 + ORM 리스트 tuple로 로드 |
| `validate_episode_ready(episode_id)` | pipeline_gate + scene_count>0 검증 (AppError raise) |
| `fetch_still_for_generation(still_id)` | still 조회 + pipeline_gate + gemini_key 검증 (single scene 생성용) |
| `mark_asset_as_original(asset_id)` | 지정 asset의 variant_type='original' 마킹 + flush |

각 save/update 메서드는 self._db.commit()을 포함한다 (호출자 commit 불필요).
build_resume_state는 read-only. resolve_world_guide는 재생성 시 self._db.flush()만 수행
(commit은 상위 호출자 책임 — 기존 generate_images 흐름 보존).
    )annotationsN)datetimetimezone)Path)AnyDictListOptionalTuple)Session)settings)AppError)to_relative_image_path)annotate_generated_asset)t)	key_count)EntityCanonEntityEpisodeLink
ImageAsset
LLMCallLogProjectSettings
SceneStill
WorldGuide)OpenAIClient)WorldGuideGenerator)auto_set_primaryScenePersistenceServicec                  ~   e Zd ZdZddZ	 	 	 	 	 	 ddZ	 	 	 	 	 	 	 	 ddZ	 	 d	 	 	 	 	 	 	 	 	 ddZ	 	 	 	 ddZddZ		 	 	 	 	 	 d dZ
	 	 	 	 	 	 d d	Zd!d
Zd"dZd#dZd$dZd%dZ	 	 	 	 	 	 d&dZ	 d'	 	 	 	 	 	 	 	 	 d(dZ	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 d)dZ	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 d*dZd#dZd!dZd+dZd,dZ	 	 	 	 d-dZy).r   u>   씬 이미지 persistence 전담 서비스 (W5 F22 Phase B.9).c                     || _         || _        y N)_db_project_id)selfdb
project_ids      \/Users/manta/Documents/Projects/TheRoad-I1/backend/app/services/scene_persistence_service.py__init__z ScenePersistenceService.__init__A   s    %    c           
        g }g }|D ]c  }t        di d|d   d| j                  d|d   d|d   d|d   d|d   dt        |d         d|d   d	|d	   d
|d
   d|d   d|d   d|d   d|j                  d      d|j                  d      d|j                  dd      d|j                  d      d|d   d|d   d|d   d|d   ddd|d   }| j                  j                  |       | j                  ||j                  d      d      \  }}}	|r||_        t        |dd|xs d|	       |j                  |d          |j                  t        |d                f | j                  j                          ||fS ) a}  Save N variation ImageAssets for a scene (bulk insert + commit).

        Each var_result must include the keys populated by the scene generation
        pipeline (id, asset_type, entity_id, still_id, episode_id, file_path,
        prompt_used, generation_model, width, height, status, review_notes,
        created_at) plus optional (validation_score, validation_result,
        variant_type, theme_label, sanitization_strategy, sanitization_note).

        `scene_lineage` must provide prompt_type, code_version,
        prompt_file_version, reference_image_ids.

        Returns (saved_asset_ids, saved_paths) in input order.
        idr%   
asset_type	entity_idstill_id
episode_id	file_pathprompt_usedgeneration_modelwidthheightstatusreview_notesvalidation_scorevalidation_resultvariant_typebasetheme_labelprompt_typecode_versionprompt_file_versionreference_image_ids
is_primaryr   
created_atscene_image_genscene_stillscene_imageNpipeline_rolestageinput_image_idspipeline_metadata )r   r"   r   getr!   add_build_lineage_annotationgeneration_call_idr   appendr   commit)
r#   var_results_listscene_lineagesaved_asset_idssaved_pathsvrasset
_input_ids_gen_call_id_metas
             r&   save_scene_variationsz-ScenePersistenceService.save_scene_variationsE   s3   $ &("$"B d8++ l+ [/	
 J l+ 1KA }- "$$6!7 k (| (|  / "$(:!; #%&&)<"=   VVNF;!" FF=1#$ *-8%& +>:'( %22G$H)* %22G$H+, -. l+/E2 HHLL /3.L.LBFF<(*;/+Je +7($]-!+!3t"'
 ""2d8,tB{O45[ #\ 	++r(   c                (   t        |j                  d      xs g       }|j                  d      xs g }g }|xs |D ]  }|s||vs|j                  |        t        |j                  d      xs g       }t        |j                  d      xs g       }	| j                  |	|      \  }
}}|
D ]  }||vs|j                  |        |j	                  |       | j                  ||      \  }}}|D ]  }||vs|j                  |        |j	                  |       d|v r|j                  d      }n#| j                  |j                  d      ||      }d||d	}|j                  d
      r|d
   |d
<   |j                  d      r|d   |d<   |||fS )u  P0: var_result/scene_result 에서 실제 첨부 UUID lineage annotation 구성.

        배치(save_scene_variations)/단건(save_single_scene_asset) 공유 — 동일 정책.
        actual_attached_image_ids 우선(없으면 legacy pose_guide_asset_ids) + unresolved
        중 registered_pose_guide post-hoc resolve(구조키) 병합 + generation_call_id.
        Returns (input_image_ids, generation_call_id, pipeline_metadata).
        actual_attached_image_idspose_guide_asset_idsactual_attached_refsunresolved_attached_refsrM   r-   )operation_typeinput_image_ids_actual_attached)reference_lineage_sourcer]   r^   prev_frame_chainshot_run_uid)listrJ   rN   #_resolve_registered_guide_asset_idsextend_resolve_prev_frame_asset_ids_resolve_generation_call_id)r#   sourcer.   r_   _actual_ids_fallback_idsrV   _gid_actual_refs_unresolved_r_ids_r_refs
_remaining_rid_p_ids_p_refs_pidrW   metas                      r&   rL   z1ScenePersistenceService._build_lineage_annotation   s    6::&ABHbI

#9:@b "
 1M1DJ.!!$' 2 FJJ'=>D"E6::&@AGRH&*&N&N'
# D:%!!$'  	G$ '+&H&H
'
# D:%!!$'  	G$  6)!::&:;L;;

:&
- < L
 )J$0(2	
 ::()'-.@'AD#$ ::n%#).#9D <--r(   c           
     
   |sy	 | j                   j                  t        j                        j	                  t        j
                  | j                  k(  t        j                  |k(  t        j                  dk(  t        j                  j                  d| d            }|rt|j	                  t        j                  j                  d| d            j                  t        j                  j                               j                         }|r|d   S dS |j                  t        j                  j                               j                         }|r|d   S dS # t        $ r!}t         j#                  d||       Y d}~yd}~ww xY w)u  P0 best-effort: 이 still 의 image_gen llm_call_log 감사 링크 회수.

        구조키(metadata_json 의 still_id UUID) 로만 매칭 — 라벨/프롬프트 파싱 아님.
        한 still 에 variation 별 call 이 여럿이면 최신 success 1개(감사 수준 링크,
        variation 단위 정합은 v1 미보장·문서화). still_id 부재 시 None.
        operation_type: 배치="scene_image_gen", 단건="single_scene_image_gen".

        multiroll_tag(Codex 8e70d4c0 HIGH-2 재리뷰): selected roll/fix 의
        exact 호출 태그 — 지정 시 exact 매칭만 허용하고 miss 는 None
        (broad still_id fallback 은 패자 branch/다른 롤을 거짓 링크할 수
        있어 금지). legacy fallback 은 multiroll_tag 미지정 호출에만.
        Nsuccessz%"still_id": ""%z%"multiroll_tag": "r   u.   generation_call_id resolve 실패 still=%s: %s)r!   queryr   r*   filterr%   r"   r_   r4   metadata_jsonlikeorder_byr@   descfirst	Exceptionloggerwarning)	r#   r-   r.   r_   multiroll_tagr9   exactqexcs	            r&   rh   z3ScenePersistenceService._resolve_generation_call_id   sG   " 	z}}-))T-=-==--?%%2,,11N8*B2OP	  KK"00551-CE Xj3388:;UW  $)uQx2d2j3388:;AACA1Q4&$& 	NNKXWZ[	s+   DE E AE E 	F!E==Fc                   |sy	 | j                   j                  t        j                  t        j                  t        j
                        j                  t        j                  |k(        j                         }|syt        j                  |d   xs d      }|j                  d      }|syt        |      |d   |d   dS # t        $ r!}t        j                  d||       Y d}~yd}~ww xY w)	u  (Codex safety-ladder BLOCK-2) fallback 성공 자산의 provenance SOT.

        해당 호출의 metadata_json 에 safety_ladder(비 primary 단계명)가
        있을 때만 {stage, model_name, user_prompt} 를 돌려준다 — 사다리
        미발화·플래그 OFF 호출은 키 자체가 없어 None(기존 경로
        byte-identical). 소비자는 이것으로 자산의 generation_model·
        prompt_used 를 실제 성공 호출과 정렬한다(설정 플래그 추정 금지).
        N   {}safety_ladderr      )rF   
model_nameuser_promptu2   safety_ladder provenance 조회 실패 call=%s: %s)r!   rz   r   r   r   r|   r{   r*   r   jsonloadsrJ   strr   r   r   )r#   call_idrowrv   rF   r   s         r&   safety_ladder_call_provenancez5ScenePersistenceService.safety_ladder_call_provenance   s     	)):+A+A,,. 
01  ::c!fn-DHH_-EU!!f"1v 
  	NND 		s$   A7C =/C -C 	C,C''C,c                d   |sy	 | j                   j                  t        j                        j	                  t        j
                  |k(        j                         }|r%|d   r t        |d         j                         xs dS dS # t        $ r!}t        j                  d||       Y d}~yd}~ww xY w)u  그 호출이 **실제로 쓴 모델** — 없으면 None (2026-08-26 감사 0-B).

        ★설정값으로 내려가지 않는다. 제공자를 모를 때 설정값을 적으면 빈 칸
         보다 **더 확정적인 거짓 기록**이 된다(Codex BLOCK-3). 변환 자산의
         `generation_model` 은 이 호출 기록이 SOT 이고, backfill 스크립트도
         같은 값을 쓴다.
        Nr   u'   호출 모델 조회 실패 call=%s: %s)r!   rz   r   r   r{   r*   r   r   stripr   r   r   )r#   r   r   r   s       r&   call_model_namez'ScenePersistenceService.call_model_name$  s     
	z445
01 
 58CFCAK%%'/4LL 	NN97CI	s   A=B B 	B/B**B/c           
        g }g }g }|D ]  }t        |t              r|j                  d      dk7  r|j                  |       :|j                  d      }|j                  d      }|j                  d      }	d}
d}	 | j                  j                  t        j                        j                  t        j                  | j                  k(  t        j                  |k(  t        j                  dk(  t        j                  j                  d            }|r|r|	r|j                  t        j                  j!                  d	| d
      t        j                  j!                  d| d
      t        j                  j!                  d|	 d
            j#                         }t%        |      dk(  r|d   d   }
|
|r|r|j                  t        j                  j!                  d	| d
      t        j                  j!                  d| d
            j#                         }t%        |      dk(  r	|d   d   }
nt%        |      dkD  rd}|
rP|
|vr|j                  |
       |j                  |
|j                  d      xs d|j                  dd      dd       t        |      }|rd|d<   |j                  |        |||fS # t&        $ r3}t(        j+                  d||       |j                  |       Y d}~d}~ww xY w)u  P0 (2026-07-01, Codex 합의): unresolved 중 registered_pose_guide 를
        구조키(group_id+bg_key+guide_hash)로 post-hoc resolve.

        attach_registered_pose_guide_ref 는 worker-thread(DB접근0) 라 asset_id 를
        못 실어 unresolved 로 떨어진다. 여기서 저장 시점(DB session 有)에 라벨없이
        구조키로 실제 registered_pose_guide ImageAsset UUID 를 붙인다.

        우선순위: (group_id,bg_key,guide_hash) exact → 1개면 resolve /
        fallback (group_id,bg_key) → 정확히 1개면 resolve, 2+ 면 ambiguous(unresolved
        유지 + reason) / still_id fallback 은 그룹캐시 cache_hit 시 still 이 다를 수
        있어 여기선 안 씀(coordinator 가 이미 shot 매핑). resolve 실패는 그대로 unresolved.

        Returns: (resolved_ids, resolved_refs[resolved_from_unresolved 마커],
                  remaining_unresolved).
        rE   registered_pose_guidegroup_idbg_key
guide_hashNFTz%"group_id": "ry   z%"bg_key": "z%"guide_hash": "r   r   u1   registered_pose_guide resolve 실패 group=%s: %sroleimmobilized_pose_guidelabel )asset_idr   r   resolved_from_unresolved$registered_pose_guide_ambiguous_pairreason)
isinstancedictrJ   rN   r!   rz   r   r*   r{   r%   r"   r.   rE   is_intermediateis_pipeline_metadata_jsonr}   alllenr   r   r   )r#   
unresolvedr.   resolved_idsresolved_refs	remainingugidr   ghrid	ambiguousr9   rowsr   _us                   r&   re   z;ScenePersistenceService._resolve_registered_guide_asset_ids:  s   $ #%.0*,	Aa&!%%*@D[*[  #%%
#CUU8_F|$B!%CIxx~~jmm4;;))T-=-==))Z7,,0GG..2248	 6b;;"99>>PSuTV?WX"99>>fXUW?XY"99>>AQRTQUUW?XY ce	 
 4yA~"1gaj;36;;"99>>PSuTV?WX"99>>fXUW?XY ce  4yA~"1gajTQ$(	
 l* '',$$ #EE&ME-EUU7B/04	&  !W#IBxL  $e f ]I55%  RTWY\]  #s   ;GJ<<	K8(K33K8c           	        g }g }g }|D ]  }t        |t              r|j                  d      dvr|j                  |       9|j                  d      }d}|r	 | j                  j                  t        j                        j                  t        j                  | j                  k(  t        j                  |k(  t        j                  |k(  t        j                  dk(  t        j                  dk(        j                  t        j                   j#                               j%                         }	|	r|	d   nd}|r`||vr|j                  |       |j                  ||j                  d	      xs d
|j                  dd      |j                  d      dd       t        |      }|sd|d<   n	d|vrd|d<   |j                  |        |||fS # t&        $ r3}
t(        j+                  d||
       |j                  |       Y d}
~
d}
~
ww xY w)u  A5 (2026-07-02): unresolved 중 immobilized_prev_frame 을 구조키
        ``source_still_id`` 로 post-hoc resolve (registered_pose_guide 패턴).

        attach_immobilized_prev_frame_ref 는 worker-thread(DB 접근 0) 라 anchor
        프레임의 asset UUID 를 못 싣는다 — 저장 시점에 anchor still 의 **scene
        primary** ImageAsset UUID 를 라벨 파싱 없이 붙인다. primary 마킹이 아직
        없거나(동일 batch 내 예외 경로) 조회 실패면 그대로 unresolved 유지
        (비치명 — bytes ref 는 이미 첨부됨, lineage 만 부분).

        Returns: (resolved_ids, resolved_refs[resolved_from_unresolved 마커],
                  remaining_unresolved).
        rE   )immobilized_prev_framescene_prev_framesource_still_idNscener   r   u9   immobilized_prev_frame resolve 실패 source_still=%s: %sr   previous_shot_same_roomr   r   T)r   r   r   rE   r   "prev_frame_source_still_id_missingr   prev_frame_primary_not_found)r   r   rJ   rN   r!   rz   r   r*   r{   r%   r"   r.   r-   r+   r?   r~   r@   r   r   r   r   r   )r#   r   r.   r   r   r   r   src_sidr   r   r   r   s               r&   rg   z5ScenePersistenceService._resolve_prev_frame_asset_ids  s    #%.0*,	A a&!%%*@ IB +B  #ee-.G!%Cz}}5&11T5E5EE&11Z?&//7:&11W<&11Q6 "*"7"7"<"<">?  %(#a&TC l* '',$$ #EE&MF-FUU7B/%&UU?%;04&  !W#GBxLR'#ABxL  $] ^ ]I55/ ! NNS& $$Q's   CG	H(HHc                    | j                   j                  t              j                  t        j                  |k(        j                  ddi       | j                   j                          y)z;Mark a single ImageAsset as is_primary=1 (commit included).r?   r   N)r!   rz   r   r{   r*   updaterO   )r#   r   s     r&   set_primary_assetz)ScenePersistenceService.set_primary_asset  sF    z"))MMX%	

&,"
#r(   c                   t               }| j                  j                  t        j                        j                  t        j                  | j                  k(  t        j                  |k(  t        j                  dk(        j                         j                         }|D ]  }|d   }|s| j                  j                  t              j                  t        j                  | j                  k(  t        j                  |k(  t        j                  dk(  t        j                  dk(        j                         }|st        |j                        j!                         s|j#                  |        |j%                         D ]n  }|j&                  j)                  di       j)                  |i       }	|	j)                  dd      }
|
sDt        |
      j!                         s^|j#                  |       p |S )u  Scan DB + checkpoint to determine already-completed still_ids (resume mode).

        W5 F22 Phase B.15: generate_images의 resume 스캔 루프 이관.
        하나의 still은 다음 중 하나를 만족하면 "완료"로 간주한다:
          1) DB에 해당 still의 scene primary ImageAsset이 있고 file_path가
             디스크에 실제 존재
          2) scene_cp.get_completed_ids()에 등록되어 있고 primary_path가
             디스크에 존재

        scene_cp은 ImageCheckpointManager 인터페이스 (`get_completed_ids()`
        + `_data["completed"][cp_id]["primary_path"]` 접근)로 쓰인다.
        r   r   r   	completedprimary_pathr   )setr!   rz   r   r-   r{   r%   r"   r.   r+   distinctr   r?   r   r   r/   existsrK   get_completed_ids_datarJ   )r#   r.   scene_cpdoneexisting_stillsrsidprimarycp_idcp_datacp_paths              r&   scan_completed_scene_stillsz3ScenePersistenceService.scan_completed_scene_stills  s    E HHNN:../V%%)9)99%%3%%0
 XZSU 	 !AA$ChhnnZ077%%)9)99##s*%%0%%*	
 eg  4 1 1299; ! //1Enn((b9==eRHGkk."5G4=//1	 2 r(   c                    t        d      )u,  Hard-fenced: 씬 이미지 일괄 DELETE 절대 금지.

        절대 규칙 (feedback_never_delete_images): 이미지 step 재실행 시
        기존 이미지 절대 삭제 금지 — 이전/새 이미지 비교 필수. 대안:
          - mode='resume' + force: UPDATE is_primary=0 으로 row 보존,
            새 row 가 is_primary=1. PNG 파일도 그대로 유지.
        Orphan 정리는 별도 메서드 ``delete_orphan_scene_assets`` 사용
        (scene_still 부모 row 가 사라진 경우만 — 분석 단계 재실행 시).
        zdelete_existing_scene_assets is hard-fenced (feedback_never_delete_images). Image step re-run MUST preserve existing rows via is_primary=0 pattern. For analysis-step orphan cleanup, use delete_orphan_scene_assets().)RuntimeError)r#   r.   s     r&   delete_existing_scene_assetsz4ScenePersistenceService.delete_existing_scene_assets  s     R
 	
r(   c           	     r   ddl m}  |t        j                        j	                  t        j
                  |k(        }| j                  j                  t              j                  t        j                  | j                  k(  t        j
                  |k(  t        j                  dk(  t        j                  j                  d      t        j                  j                  |             }|j!                  d      }| j                  j#                          |r"t$        j'                  d|| j                  |       |S )u  Orphan 정리: scene_still 부모 row 가 없는 image_asset 만 삭제.

        분석 step 재실행 (scene_save/shot_extract/entity_merge 등) 으로
        scene_still 의 still_id 가 reshuffle 되어 image_asset.still_id 가
        존재하지 않는 still 을 가리키게 된 경우 호출. 부모 still 이 살아
        있는 image_asset 은 절대 건드리지 않음.

        Returns: 삭제된 orphan row 수.
        r   selectr   Nfetchsynchronize_sessionzJdelete_orphan_scene_assets: removed %d orphan rows (project=%s episode=%s))
sqlalchemyr   r   r*   wherer.   r!   rz   r   r{   r%   r"   r+   r-   isnotin_deleterO   r   info)r#   r.   r   valid_still_idsorphan_qcounts         r&   delete_orphan_scene_assetsz2ScenePersistenceService.delete_orphan_scene_assets  s     	& /55!!Z/
 88>>*-44!!T%5%55!!Z/!!W,%%d+  $$_55
 G<KK\t'' r(   c                x   ddl m} ddlm}  ||j                        j                  |j                  | j                  k(        }| j                  j                  t              j                  t        j                  | j                  k(  t        j                  j                  ddg      t        j                  j                  d      t        j                  j                  |             }|j!                  d      }| j                  j#                          |r!t$        j'                  d	|| j                         |S )
u  Orphan 정리: entity_canon 부모 row 가 없는 reference/composite 자산 삭제.

        Entity 분석 step 재실행 (entity_extract/entity_merge 등) 으로 entity_id
        가 reshuffle 되어 image_asset.entity_id 가 존재하지 않는 entity 를
        가리키는 경우 호출. 부모 entity 가 살아있는 image_asset 은 보존.

        프로젝트 범위 (episode 무관) — entity 는 프로젝트 전체에서 공유.

        Returns: 삭제된 orphan row 수.
        r   r   )r   	reference	compositeNr   r   z@delete_orphan_entity_assets: removed %d orphan rows (project=%s))r   r   app.models.projectr   r*   r   r%   r"   r!   rz   r   r{   r+   r   r,   r   r   rO   r   r   )r#   r   r   valid_entity_idsr   r   s         r&   delete_orphan_entity_assetsz3ScenePersistenceService.delete_orphan_entity_assets+  s     	&2!+..177""d&6&66
 88>>*-44!!T%5%55!!%%{K&@A  &&t,!!%%&677	
 G<KKRt'' r(   c           	        t        di d|d   d| j                  d|d   d|d   d|d   d|d   dt        |d         d|d   d	|d	   d
|d
   d|d   d|d   d|d   d|j                  d      d|j                  d      d|j                  d      d|d   d|d   d|d   d|d   d|d   }| j                  j                  |       | j                  ||j                  d      d      \  }}}|r||_        t        |dd|xs d|       t        | j                  | j                  |       | j                  j                          |S )a  Save a single scene ImageAsset (used by generate_single_scene_image).

        scene_result fields: id, asset_type, entity_id, still_id, episode_id,
        file_path, prompt_used, generation_model, width, height, status,
        review_notes, created_at, plus optional (sanitization_strategy,
        sanitization_note, original_prompt).
        lineage fields: prompt_type, code_version, prompt_file_version,
        reference_image_ids.

        `auto_set_primary` promotes this asset to is_primary=1 if no other
        primary exists for the same still. Commit is included.
        Returns the saved ImageAsset so caller can call image_to_dict().
        r*   r%   r+   r,   r-   r.   r/   r0   r1   r2   r3   r4   r5   sanitization_strategyoriginal_promptsanitization_noter;   r<   r=   r>   r@   single_scene_image_genrB   rC   NrD   rI   )r   r"   r   rJ   r!   rK   rL   rM   r   r   rO   )r#   scene_resultlineagerU   rV   rW   rX   s          r&   save_single_scene_assetz/ScenePersistenceService.save_single_scene_assetJ  s   $  
D!
''
 $L1
 #;/	

 "*-
 $L1
 -\+-FG
 %]3
 **<=
 w'
  )
  )
 &n5
 #/"2"23J"K
 ),,->?
  +../BC!
"  .#
$ !0%
& !((= >'
( !((= >)
* $L1+
. 	U +/*H*H,**<8:R+
'
L% '3E$ m'/4#	

 	4#3#3U;r(   Nc                X   i }i }i }i }|s||||dS t        |      D ]  \  }	}
|
j                  d      }||vr| j                  j                  t              j                  t        j                  | j                  k(  t        j                  |k(  t        j                  dk(  t        j                  dk(        j                  t        j                  j                               j                         }|s| j                  j                  t              j                  t        j                  | j                  k(  t        j                  |k(  t        j                  dk(        j                  t        j                  j                               j                         }|sddlm}  ||j"                        }|r|j%                         s|||	<   |||<   |j&                  |j"                  |j(                  xs d|j*                  |j,                  xs dd||	<   	 t/        j0                  |
j                  d	d
            }d}|D ]l  }t5        |t6              s|j                  d      xs |j                  dd      }||v s>||   j                  d      dk(  sV|j9                         |
f||<   d}n |
j                  d      }|r|st5        |t:              s|j                  |      }|s|j9                         |
f||<    ||||dS # t.        j2                  $ r g }Y w xY w)u;  Restore per-still resume state from existing scene ImageAssets.

        W5 F22 Phase B.13 (2026-04-22): scene_image_service.generate_images의
        resume 복원 루프를 이관. 각 already_done still에 대해 primary asset
        (없으면 최신) 조회 + 파일 존재 확인 후 path map들과 location
        history map을 populate한다.
        2026-04-27: sd_shot_path_map 반환 제거 (downstream 미사용).

        Returns a dict with 4 populated maps:
          - scene_paths_by_index: {scene_index → Path}
          - scene_paths_by_index_by_id: {still_id → Path}
          - scene_results_by_index: {scene_index → dict(id/file_path/...)}
          - location_scene_history: {location_id → (bytes, still_data)}

        already_done_stills가 비어 있으면 4개 빈 dict를 반환.
        )scene_paths_by_indexscene_paths_by_index_by_idscene_results_by_indexlocation_scene_historyr*   r   r   r   )resolve_image_pathr   )r*   r/   r0   r4   r5   visible_entities_json[]Fr,   entity_typelocationTscene_index)	enumeraterJ   r!   rz   r   r{   r%   r"   r-   r+   r?   r~   r@   r   r   app.core.file_pathsr   r/   r   r*   r0   r4   r5   r   r   JSONDecodeErrorr   r   
read_bytesint)r#   stillsalready_done_stillsentity_lookupfallback_location_uuid_by_scener   r   r   r   si
still_datar-   existing_assetr   fpvis_ids_loc_recordedveid_fb_si_fb_us                        r&   build_resume_statez*ScenePersistenceService.build_resume_state  s   . 1368"<>13"(<.H*@*@	  (/NB
!~~d+H22 z*))T-=-==''83))W4))Q.	 *//4467  "HHNN:.V"--1A1AA"++x7"--8
 Xj3388:;UW  " ?#N$<$<=BRYY[') $35&x0$''+55-99?R(// . ; ; Ar*"2&**Z^^4KT%RS "Ma&%%+?{B)?Cm+c0B0F0F}0UYc0c79}}
6S.s3(,   ^^M2F!&E"63/7;;FC57]]_j4Q*51I 0N %9*D&<&<	
 	
) '' s   %LL)(L)c                   t        || j                  d||t        |      |dddd||      }| j                  j	                  |       | j                  j                          y)zSave a fal.ai angle-edited scene asset (single insert + commit).

        asset_type/generation_model/variant_type/status/is_primary are fixed
        to the fal.ai angle pipeline contract.
        r   z+fal-ai/qwen-image-edit-2511-multiple-angles	generatedr   	angle_fal)r*   r%   r+   r-   r.   r/   r0   r1   r4   r?   r8   source_image_idr@   N)r   r"   r   r!   rK   rO   )	r#   r   r/   r-   r.   r0   r  r@   rU   s	            r&   save_fal_angle_assetz,ScenePersistenceService.save_fal_angle_asset  s`      ''!,Y7#J$+!
 	Ur(   c           
        |j                   xs d}t        j                  |dd  dt        |       dt        |       j	                               j                         }	d}
|dk7  r| j                  j                  t              j                  t        j                  | j                  k(  t        j                  |k(        j                  t        j                  j                               j!                         }
|
r0|
j"                  |	k(  r!t%        j&                  |
j(                        }nvt+               }t-        |      }|j/                  dd|	      5 }|j1                  t        |j                         t        |      t        |      d
       |j3                  |j                   ||j4                  xs d||      }|j7                  dt        |j9                  dd            i       ddd       t        t;        t=        j>                               | j                  |t%        j@                  d      |	tC        jD                  tF        jH                        jK                               }| j                  jM                  |       | j                  jO                          | j                  j                  tP              j                  tP        j                  | j                  k(        j!                         }|rj|jR                  r^t%        j&                  |jR                        }|j9                  di       }tU        |tV              r|j9                  d      r||d<   |S ||d<   |S # 1 sw Y   ~xY w)u  Resolve world guide: hash-based reuse or regenerate + save.

        W5 F22 Phase B.19 (2026-04-22): scene_image_service.generate_images의
        WorldGuide 블록을 이관. 처리:
          1. fulltext[:500] + len(entities) + len(stills) 해시 계산
          2. mode != "full"이면 기존 WorldGuide 조회, source_hash 일치 시 재사용
          3. 불일치/없음/force 이면 OpenAIClient + WorldGuideGenerator로 재생성 후 DB 저장 (flush만)
          4. ProjectSettings.style_rules_json이 있으면 world_guide에 주입
             - WorldGuide의 style_rules.must_maintain이 있으면 project_style로 추가
             - 없으면 style_rules 자리에 그대로 삽입

        commit은 하지 않음 (caller의 후속 DB 작업과 함께 flush).
        r   Ni  :full)
llm_clientimage_generationworld_guide_generator)r.   )fulltext_charsentitiesr  episode)fulltextlanguagesource_filer!  r  world_setting_summary_lenworld_setting_summaryF)ensure_ascii)r*   r%   r.   
guide_jsonsource_hashr@   style_rulesmust_maintainproject_style),r#  hashlibmd5r   encode	hexdigestr!   rz   r   r{   r%   r"   r.   r~   r@   r   r   r*  r   r   r)  r   r   start_operation	set_inputgeneratesource_filename
set_outputrJ   r   uuiduuid4dumpsr   nowr   utc	isoformatrK   flushr   style_rules_jsonr   r   )r#   r.   r"  r!  r  mode
provenancer$  r#  wg_hashexisting_wgworld_guideopenai_clientwg_genop	wg_recordproj_settings
proj_styleexisting_srs                      r&   resolve_world_guidez+ScenePersistenceService.resolve_world_guide  s   . ##)r++~aHaF}=DDF

)+ 	 6>z*))T-=-==))Z7 *//4467  ;22g=**[%;%;<K(NM(MBF++"$;
 , &)'*:*:&; #H!&k 
 %oo$--% ' 7 7 D9%! .  /#(?D2 * #tzz|$++%::kF##<<5??AI HHLL#HHNN HHNN?+VO..$2B2BBCUW 	
 ];;M$B$BCJ%//-<K+t,1Q/9O,  .8M*[ s   BM''M1c                   ddl m}  || j                  | j                  |       | j                  j	                  t
              j                  t
        j                  | j                  k(  t
        j                  |k(  t
        j                  dk(  t
        j                  dk\  t
        j                  dk7        j                         }|dk(  rt        ddd      y	)
uo  pipeline_gate 검사 + 선택된 씬 1개 이상 존재 검증.

        W5 F22 Phase B.22.11 (2026-04-23): scene_image_service.generate_images의
        pipeline_gate + scene_count 검증 블록(~18 LOC)을 이관.

        처리:
          1. app.core.pipeline_gate.check_scene_images_ready (lazy import)
          2. SceneStill count (project+episode+is_selected+still_index>=0+status!=stale)
          3. scene_count == 0이면 image.no_scenes AppError (status 400)

        예외 메시지는 원본 리터럴 '씬이 없습니다. 분석을 먼저 완료하세요.' 보존
        (i18n 미적용 블록).
        r   check_scene_images_readyTstalezimage.no_scenesu6   씬이 없습니다. 분석을 먼저 완료하세요.  codemessagestatus_codeN)app.core.pipeline_gaterN  r!   r"   rz   r   r{   r%   r.   is_selectedstill_indexr4   r   r   )r#   r.   rN  scene_counts       r&   validate_episode_readyz.ScenePersistenceService.validate_episode_readys  s     	D 4+;+;ZHhhnnZ077!!T%5%55!!Z/""d*""a'(
 %' 	 !&P  r(   c                    | j                   j                  t              j                  t        j                  |k(        j                         }|r"d|_        | j                   j                          yy)u   ImageAsset.variant_type='original'로 마킹 + flush.

        W5 F22 Phase B.24.3 (2026-04-23): scene_image_service.generate_scene_with_variations의
        original asset 마킹 블록(~9 LOC)을 이관.

        처리:
          1. ImageAsset.id == asset_id 조회 (project_id 필터 없음 — 원본 유지)
          2. 있으면 variant_type='original' + self._db.flush()
          3. 없으면 no-op (원본도 동일)

        commit은 호출자 책임 (원본은 자체 commit 없이 flush만 — 이후 caller가 commit).
        originalN)r!   rz   r   r{   r*   r   r8   r=  )r#   r   rU   s      r&   mark_asset_as_originalz.ScenePersistenceService.mark_asset_as_original  sS     HHNN:&VJMMX-.UW 	
 !+EHHNN r(   c                   | j                   j                  t              j                  t        j                  |k(  t        j
                  | j                  k(        j                         }|r.ddlm	}  || j                   | j                  |j                         t        j                  s$t               dk(  rt        dt        d      d      | j                   j                  t              j                  t        j                  |k(  t        j
                  | j                  k(        j                         }|st        dt        d      d      |S )u  single scene 생성을 위한 still 조회 + 파이프라인 준비성 검증.

        W5 F22 Phase B.23.1 (2026-04-23): scene_image_service.generate_single_scene_image의
        입력 검증 블록(~28 LOC)을 이관.

        처리 순서 (리터럴 보존):
          1. still_check 쿼리 (project_id + still_id)
          2. still_check가 있으면 check_scene_images_ready 호출 (pipeline_gate)
          3. gemini_api_key 또는 key_pool.count가 없으면 image.gemini_key_missing AppError
          4. 본 still 쿼리 재실행 (원본 중복 유지 — literal lift)
          5. still 없으면 still.not_found AppError (status 404)

        원본 중복 쿼리 패턴은 변경하지 않음 (검증 경로와 사용 경로가 별개로 실행).
        r   rM  zimage.gemini_key_missingrP  rQ  zstill.not_foundi  )r!   rz   r   r{   r*   r%   r"   r   rU  rN  r.   r   gemini_api_keygemini_key_countr   r   )r#   r-   still_checkrN  stills        r&   fetch_still_for_generationz2ScenePersistenceService.fetch_still_for_generation  s     HHNN:&VJMMX-z/D/DHXHX/XYUW 	
 G$TXXt/?/?AWAWX&&+;+=+B/45  HHNN:&VJMMX-z/D/DHXHX/XYUW 	
 &+, 
 r(   c                   ddl m}  || j                  | j                  |      }|rY| j                  j	                  t
              j                  t
        j                  j                  |            j                         ng }|D cg c]b  }|j                  |j                  |j                  xs d|j                  |j                  xs d|j                  xs d|j                  xs ddd c}S c c}w )u  에피소드에 연결된 EntityCanon을 dict 리스트로 로드.

        W5 F22 Phase B.22.10 (2026-04-23): scene_image_service.generate_images의
        entity ORM → dict 변환 블록(~28 LOC)을 이관.

        처리:
          1. EntityEpisodeLink 조회 (project_id + episode_id)
          2. canon_ids 수집. 빈 리스트면 entities_orm=[] (2차 쿼리 스킵)
          3. EntityCanon.id.in_(canon_ids) 로드
          4. dict 변환: id/name/short_id/entity_type/description/stable_traits/t2i_prompt
             - short_id/description/t2i_prompt: None → "" fallback
             - stable_traits: None → "{}" fallback
        r   )active_episode_canon_idsr   r   )r*   nameshort_idr   descriptionstable_traits
t2i_prompt)app.core.entity_identityrd  r!   r"   rz   r   r{   r*   r   r   re  rf  r   rg  rh  ri  )r#   r.   rd  	canon_idsentities_ormes         r&   load_episode_entity_dictsz1ScenePersistenceService.load_episode_entity_dicts  s     	F,HHd&&
4	  HHNN;'VKNN&&y12SU	 	  "
 " ddJJ," }} }}2!"!8Dll0b "
 	
 
s   A'C0c                   | j                   j                  t              j                  t        j                  | j
                  k(  t        j                  |k(  t        j                  dk(  t        j                  dk\  t        j                  dk7        j                  t        j                        j                         }|D cg c]  }|j                  |j                  |j                  |j                  |j                  xs d|j                   xs d|j"                  xs d|j$                  xs d|j&                  xs d|j(                  xs d|j*                  d }}||fS c c}w )u&  에피소드의 선택된 SceneStill을 dict + ORM tuple로 로드.

        W5 F22 Phase B.22.10 (2026-04-23): scene_image_service.generate_images의
        still ORM → dict 변환 블록(~29 LOC)을 이관.

        필터:
          - project_id + episode_id
          - is_selected == True (shot-more: 선택된 샷만)
          - still_index >= 0
          - status != "stale"
        정렬: still_index ASC

        dict 변환 키:
          id / still_index / scene_index / shot_index /
          screenplay_scene_heading / beat_title / still_frame_prompt /
          camera_json / lighting_json / visible_entities_json /
          dependent_scene_id
        None fallback:
          - screenplay_scene_heading / beat_title / still_frame_prompt: ""
          - camera_json / lighting_json: "{}"
          - visible_entities_json: "[]"
          - dependent_scene_id: None 그대로

        Returns:
          (stills_dict_list, stills_orm_list) —
          populate_t2i_prompts가 ORM 리스트 입력을 요구하므로 tuple로 반환.
        Tr   rO  r   r   r   )r*   rW  r   
shot_indexscreenplay_scene_heading
beat_titlestill_frame_promptcamera_jsonlighting_jsonr   dependent_scene_id)r!   rz   r   r{   r%   r"   r.   rV  rW  r4   r~   r   r*   r   rp  rq  rr  rs  rt  ru  r   rv  )r#   r.   
stills_ormsr  s        r&   load_episode_still_dictsz0ScenePersistenceService.load_episode_still_dicts  s5   > HHNN:&V%%)9)99%%3&&$.&&!+!!W, Xj,,-SU 	4  
   dd }} }}ll,-,F,F,L"ll0b&'&:&:&@b }}4!"!8D)*)@)@)HD&'&:&:   	 
  z!!!
s   BE")r$   
OrmSessionr%   r   returnNone)rP   List[Dict[str, Any]]rQ   Dict[str, Any]r{  zTuple[List[str], List[Path]])ri   r~  r.   r   r_   r   r{  z/Tuple[List[str], Optional[str], Dict[str, Any]])rA   r   )
r-   r   r.   r   r_   r   r   r   r{  z
str | None)r   r   r{  zOptional[Dict[str, Any]])r   r   r{  zOptional[str])r   r}  r.   r   r{  z<Tuple[List[str], List[Dict[str, Any]], List[Dict[str, Any]]])r   r   r{  r|  )r.   r   r   r   r{  r   )r.   r   r{  r|  )r.   r   r{  r  )r{  r  )r   r~  r   r~  r{  r   r    )
r  r}  r  r   r  zDict[str, Dict[str, Any]]r	  zOptional[Dict[int, str]]r{  r~  )r   r   r/   r   r-   r   r.   r   r0   r   r  r   r@   r   r{  r|  )r.   r   r"  r   r!  r}  r  r}  r?  r   r@  r   r$  r   r{  r~  )r-   r   r{  r   )r.   r   r{  r}  )r.   r   r{  z-Tuple[List[Dict[str, Any]], List[SceneStill]])__name__
__module____qualname____doc__r'   rY   rL   rh   r   r   re   rg   r   r   r   r   r   r   r  r  rK  rY  r\  rb  rn  ry  rI   r(   r&   r   r   >   sR   H&C,.C, &C, 
&	C,JE.$E.25E.GJE.	8E.R 0++),++ + 
	+Z$$	!$L,H6.H6<?H6	EH6TA6.A6<?A6	EA6F-^
 <>8$8  8 
	8~ EIo
$o
 !o
 1	o

 *Bo
 
o
b    	 
           
 DZZ Z '	Z
 %Z Z Z Z 
Zx>,*X$
L:":"	6:"r(   )4r  
__future__r   r.  r   loggingr7  r   r   pathlibr   typingr   r   r	   r
   r   sqlalchemy.ormr   rz  app.core.configr   app.core.errorsr   r  r   #app.services.image_capture.annotater   app.i18n.loaderr   app.modules.llm.gemini_key_poolr   r_  r   r   r   r   r   r   r   r   app.modules.llm.openai_clientr   !app.modules.world_guide_generatorr   "app.services.image_service_helpersr   __all__	getLoggerr  r   r   rI   r(   r&   <module>r     s|   2 #     '  3 3 0 $ $ 6 H  I   7 A ?$
%			8	$v" v"r(   