image_asset으로 영속화.
capture_generated_image/capture_artifact) → file-backed spool + worker로 전파되는 GenerationContext/queue → step finally에서 독립 non-fatal 세션으로 DB flush. 최종물은 기존 _register_image_assets 유지(intermediate만 sink). 캔버스가 소비.
reference_image_ids 의미 오염 금지 — 입력 lineage는 신규 input_image_ids.| 파일 | 책임 | 신규/수정 |
|---|---|---|
backend/alembic/versions/<rev>_image_asset_generation_cols.py | add-column 마이그(+인덱스), idempotent inspector | 신규 |
backend/app/models/project.py (ImageAsset) | 신규 컬럼 5개 필드 | 수정 |
backend/app/services/image_capture/context.py | GenerationContext + contextvar + scope contextmanager + worker bind | 신규 |
backend/app/services/image_capture/spool.py | file-backed spool(바이트→temp 파일) | 신규 |
backend/app/services/image_capture/queue.py | CaptureQueue(append/flush): DB insert + spool→final rename, 독립 세션, diagnostics | 신규 |
backend/app/services/image_capture/sink.py | capture_generated_image/capture_artifact (enqueue only, no-scope counter) | 신규 |
backend/tests/services/image_capture/test_*.py | 결정론 단위테스트 | 신규 |
# context.py
@dataclass(frozen=True)
class GenerationContext:
project_id: str
episode_id: str | None
stage: str # step name
queue: "CaptureQueue" # 이 scope의 flush 큐
still_id: str | None = None
scene_index: int | None = None
shot_index: int | None = None
entity_id: str | None = None
current_context() -> GenerationContext | None
@contextmanager
def generation_context(project_id, episode_id, stage, **kw) -> Iterator[CaptureQueue]
# contextvar set + CaptureQueue 생성, with-블록 종료 시 finally flush(독립 세션)
def bind_context(ctx: GenerationContext) # worker thread 명시 전파용 (set/reset)
# sink.py (enqueue only — DB 모름)
def capture_generated_image(png_bytes: bytes, *, role: str,
input_image_ids: list[str] | None = None, candidate_index: int | None = None,
generation_call_id: str | None = None, prompt: str | None = None,
pipeline_metadata: dict | None = None) -> None
def capture_artifact(png_bytes: bytes, *, role: str, **kw) -> None # 비모델 PIL/결정론
# queue.py
class CaptureQueue:
def append(self, spool_path: str, meta: dict) -> None
def flush(self) -> int # 독립 세션으로 ImageAsset insert + rename, 반환=insert 수
CAPTURE_DIAG: dict # {"skipped_no_context": int, "skipped_no_flush_scope": int}
Files: Create migration, Modify app/models/project.py, (정합 확인) app/services/pipeline_graph_service.py.
ImageAsset.input_image_ids(Text JSON), stage(Text), pipeline_role(Text), is_intermediate(Boolean default false), generation_call_id(Text), candidate_index(Integer), pipeline_metadata_json(Text). 인덱스 3개.Column(..., nullable=True)(is_intermediate는 default=False, nullable=False). reference_image_ids 주석/의미 불변.alembic revision -m "image_asset generation cols" → op.add_column 7개 + op.create_index (project_id,episode_id,is_intermediate)/generation_call_id/(project_id,episode_id,pipeline_role). idempotent: inspector = sa.inspect(conn); existing = {c['name'] for c in inspector.get_columns('image_asset')} 가드. downgrade=drop.cd backend && .venv/bin/alembic upgrade head → 에러 없음. psql \d image_asset로 컬럼/인덱스 확인._asset_to_node/쿼리에 영향 없음만 확인 → pytest tests/services/test_pipeline_graph_service.py -q 21 green 유지._migrations fail-fast 통과(schema drift 0).feat(image-capture): image_asset 생성 영속화 컬럼 + 마이그.Files: Create image_capture/context.py, tests/services/image_capture/test_context.py.
current_context() 기본 None. with generation_context("p","e","stage") as q: 안에서 current_context().stage=="stage", q is current_context().queue. 블록 밖 다시 None(reset). bind_context(ctx)로 다른 스레드 함수에서 동일 ctx 보임(thread target에 명시 전파).pytest tests/services/image_capture/test_context.py -q → ImportError.ContextVar[GenerationContext|None], generation_context contextmanager(set token → try/yield queue → finally queue.flush() + reset token), bind_context(set/reset helper). flush() 실패도 non-fatal(로그만).Files: Create image_capture/spool.py, tests/.../test_spool.py.
p = write_spool(b"PNGDATA", project_id, stage) → 파일 존재 + 바이트 일치 + 경로가 spool 디렉터리 하위. 두 번 호출 시 경로 충돌 없음(uuid).projects_root/.capture_spool/<project>/<uuid>.png에 Path.write_bytes. 디렉터리 mkdir(parents, exist_ok). 반환=절대경로.Files: Create image_capture/queue.py, tests/.../test_queue.py(PG theroad_test).
q=CaptureQueue(ctx); spool 파일 1개 append(meta: role, input_image_ids, ...) → q.flush()==1; DB에 ImageAsset(is_intermediate=True, pipeline_role=role, input_image_ids=json, file_path=final) 1행 + spool→final rename됨 + spool 파일 사라짐. flush 두 번째 호출=0(큐 비움). DB 에러 시 flush가 raise 안 함(독립 세션, non-fatal) + diag 증가.append=리스트 적재. flush=독립 SessionLocal() 열고 각 항목 ImageAsset insert + spool→episodes/<eid>/images/generated/<stage>/<uuid>.png rename, commit, close. 전체 try/except(실패=rollback+diag, raise 안 함). 큐 클리어.Files: Create image_capture/sink.py, tests/.../test_sink.py.
capture_generated_image(b"x", role="r") 호출 → spool/큐 변화 0 + CAPTURE_DIAG["skipped_no_context"]++(default-capture-off=중복 0 보장). (2) with generation_context(...) as q: 안에서 호출 → spool 1 + q에 append 1(meta role/input_image_ids 보존). (3) capture_artifact도 동일 경로. (4) ★dedup 테스트: scope가 열려도 호출 자체가 explicit capture라야 저장(최종물 경로는 scope 안 열거나 호출 안 함 → 0행).with generation_context("p","e","pose_guide") as q: capture_generated_image(png, role="pose_guide", input_image_ids=[bg,base]) → 블록 종료 finally flush → DB에 intermediate 1행, input_image_ids 보존, 파일 존재.pytest tests/services/image_capture -q + 기존 회귀(graph service 21, image steps) green.| 분류 | 함수 (file:line, decode 지점) | capture API / role(안) | budget label(불변) |
|---|---|---|---|
| gpt-image-2 | background_render.render_one_background :123→126 (3분기 edit_multi/edit_single/generate) | capture_generated_image · background_render | background_render.{edit_multi,edit_single,generate} |
| gpt-image-2 | background_chain_render.render_node_image :270→273 (3분기) | · background_chain_node/chain_bg | background_chain_render.{edit_multi,edit_single,generate} |
| gpt-image-2 | floor_plan_render.render_one_floor_plan :103→106 (3분기, ↑3개와 동형) | · floor_plan | floor_plan_render.{edit_multi,edit_single,generate} |
| gpt-image-2 ★수렴 | location_floor_plan.generate_floor_plan_image :192 (png_bytes return, write 안 함)→ outdoor Stage A/B/C·registered_pose_guide gpt-edit·floor_plan_light_sidecar 전부 이 함수 통과 | · floor_plan/role은 caller scope 결정 | location_floor_plan.{generate,edit} |
| gpt-image-2 | space_set_bg_provider.image_generate :160→168 / .image_edit :185→193 (단발, quality·n 미전달) | · space_set_bg_t2i/space_set_bg_i2i | space_set_bg.{image_generate,image_edit} |
| Gemini T2I ★수렴 | GeminiImageClient.generate_image :292 return base64.b64decode(b64), elapsed_ms→ scene/reference/ref_image_pipeline/scene_image_pipeline/zoom-fill 전부 수렴 | capture_generated_image · neutral(scope role 우선) | gemini_image_client.urlopen (capture 무관) |
| Gemini i2i REST ★수렴 | gemini_i2i_editor._gemini_generate_content :196→ edit_angle/edit_color/edit_combined 수렴 | · i2i_edit | n/a (순수 urllib) |
| fal | fal_angle_helpers.apply_fal_angle :233 (data = dl.read() 결과 다운로드) | capture_generated_image · fal_angle | fal_angle_helpers.fal_run |
| 비모델 PIL (capture_artifact) | file:line (bytes 지점) | role(안) / 비고 |
|---|---|---|
| registered pose underlay | registered_pose_guide_service.make_registration_underlay :101-102 (buf.getvalue) | registered_pose_underlay — caller(:285)에서 캡처해 bg_key lineage 확보 |
| zoom continuity crop | zoom_continuity_render_service.generate_continuity_crop_png :330-331 (crop_bytes) | zoom_continuity_crop — source still asset id in-scope |
| space_set marking | space_set_bg.mark_fp :372 (디스크 only → caller :335 에서 marked.read_bytes()) | space_set_marked_fp |
| ★dwelling zone 주석 FP 감사 신규발견(설계 누락) | dwelling_zone_map_step._annotate_fp :300 (디스크 only → BytesIO 또는 read_bytes) | dwelling_zone_annotated_fp — verification-only |
| outdoor birdseye LEGACY 미호출 | outdoor_site_layout_provider._render_clean_birdseye_png :610-611 | outdoor_birdseye_setmap — production 호출 0(no-op), 보강용 1줄(최하 우선순위) |
★반드시 제외(이중 캡처·범위 밖): png_metadata.embed_png_metadata :42(이미 캡처된 이미지 in-place 재저장) · 모든 consumer write_bytes 사이트(scene/reference/ref_image_pipeline/scene_image_pipeline/outdoor_step/coordinator — producer 수렴점에서 이미 잡힘) · thumbnail/export JPEG(파생물). 판단보류(모델 INPUT): gemini_i2i_editor._create_camera_diagram :96(i2i 입력 다이어그램 — 출력 아님).
images.edit/generate 가 리팩토링 전과 정확히 같은 kwargs(model/image/prompt/size/quality/n)로 호출되는지 assert. 실이미지 canary = retry/sanitizer 동작·산출물 타당성 육안(테스트통과≠검증).**call_kwargs pass-through Codex 합의. 감사결과: 4개 사이트(background_render/background_chain/floor_plan_render/location_floor_plan)는 images.{edit,generate}(model,image?,prompt,size,quality,n=1) 로 동형, space_set 만 이형(quality·n 미전달). §10.5 의 고정 size/quality 인자면 space_set 에 quality 강제주입=비-identical → wrapper 는 call_kwargs dict 를 그대로 pass-through. 권장 시그니처 call_gpt_image_bytes(openai_client, *, mode, prompt, ref_paths=None, call_kwargs, capture_role=None, capture_metadata=None) — ref_paths 개수로 generate/edit_single/edit_multi 내부 분기 재현(파일핸들 수명·image=단일핸들 vs 리스트 보존). budget reserve 는 사이트 기존 위치/label 유지(wrapper 가 reserve 까지 먹으면 label drift·double-count 위험 — Codex).location_floor_plan 처럼 site validation 있는 경우 대비: wrapper 는 decode 후 capture 직전에 len(png) >= min_capture_bytes(default 1024, site override 가능)만 확인하고, 작으면 capture 만 skip+diag, bytes 는 그대로 반환. 즉 capture 가드가 사이트 성공/실패 semantics 를 절대 안 바꿈. "거부 후보까지 전부"는 Phase C 에서 candidate 생성 구간 scope 개방의 문제 — Phase B wrapper 가 손상/too-small 까지 저장할 필요 없음.input_image_ids=None + role/stage/prompt/model/budget_source/call method/ref_count 메타 중심. gemini 는 폐기된 log_llm_call 반환값=call_id 회수해 generation_call_id 채움. gpt/fal 은 call_id 없으면 None(후속 structured logging 통합)._create_camera_diagram 도 capture_artifact(role='angle_camera_diagram'). UI noisy 하면 필터로 접음. 제외는 "이미 캡처된 바이트의 저장/파생 표시물"로 한정: png_metadata in-place 재저장 / thumbnail·export / producer 이후 consumer write_bytes. FAL 은 result_url 다운로드 bytes 가 output(resize input bytes 는 비대상).call_gpt_image_bytes 결정론 TDDFiles: Create app/modules/llm/gpt_image_primitive.py(또는 image_capture/gpt_primitive.py), tests/.../test_gpt_image_primitive.py.
call_gpt_image_bytes(client, mode, prompt, ref_paths=[2개], call_kwargs={size,quality,'n':1}, capture_role) → client.images.edit 1회 호출. ★file handle 객체 동일성 비교는 어려우므로 method name + prompt/model/size/quality/n + image 인자 개수 + 파일명/bytes 순서를 assert(Codex). ref_paths=[1개]→image=단일핸들(리스트 아님). ref_paths=[]→images.generate(image 인자 없음). 반환=decode된 bytes. scope 열림 시 capture enqueue 1, 닫힘 시 0. len(png)<min_capture_bytes(1024)→capture skip+diag 이지만 bytes 는 그대로 반환(사이트 semantics 불변).resp=client.images.edit|generate(model=model, image?=..., prompt=prompt, **call_kwargs), b64=resp.data[0].b64_json, img=base64.b64decode(b64), min-size 통과 시 capture_generated_image(img, role=capture_role, prompt=prompt, generation_call_id=None, pipeline_metadata={budget_source,...}), return img(+필요시 b64/raw resp). budget reserve·out_path write·검증·retry 는 호출 안 함(사이트 유지). ImageCallBudgetExceeded 등은 wrapper가 삼키지 않고 전파.Files: Modify background_render.py·background_chain_render.py·floor_plan_render.py·location_floor_plan.py·space_set_bg_provider.py. 사이트별 test_*_callargs.py.
openai_client.images.{edit,generate}(...) → b64 decode 블록을 png=call_gpt_image_bytes(..., call_kwargs=<그 사이트가 현재 넘기는 정확한 dict>, budget_source=<분기 라벨>, capture_role=<표 role>) 로 교체. reserve_current_call(분기별 라벨)·out_path.write_bytes·empty/too-small 검증·info dict 반환·retry/sanitizer 루프는 그대로. space_set 는 call_kwargs 에 quality·n 미포함(현 호출 보존), decode→png_bytes 후 SpaceSetBgProviderError empty 의미 보존.images.edit/generate 가 치환 전과 동일 kwargs로 호출됨 assert(특히 space_set 에 quality 미주입 / edit_single 이 단일 핸들 / edit_multi 가 리스트). budget reserve 라벨 문자열 불변 assert.generate_image 계측 (★수렴점) mock TDD + 중복0Files: Modify gemini_image_client.py(:277-292), test_gemini_image_capture.py.
if b64: 블록, 기존 log_llm_call/_tracer.log 뒤)에서 call_id = log_llm_call(...) 반환 회수(현 폐기됨, signature 확인), img = base64.b64decode(b64) 1회 바인딩, capture_generated_image(img, role=<neutral/scope>, input_image_ids=ref_image_ids 라벨, prompt=prompt, generation_call_id=call_id, pipeline_metadata=self._ctx 부분집합), return img, elapsed_ms. reserve/budget·log·tracer 인자·순서 불변.image_asset 행 0(default-off). scope 열림에서만 1행.gemini_i2i_editor 직접 REST 계측 mock TDD_gemini_generate_content :196 img = base64.b64decode(b64); capture_generated_image(img, role='i2i_edit', prompt=prompt, input_image_ids=None, pipeline_metadata={model,aspect_ratio}); return img. 단일점이 edit_angle/color/combined 4경로 모두 커버 → 호출측 중복계측 불필요. retry/safety 가드·return 흐름 불변._create_camera_diagram :94-96 png=buf.getvalue() 직후 capture_artifact(png, role='angle_camera_diagram', pipeline_metadata={horizontal_deg,vertical_deg,zoom}); return png. underlay/marking 과 같은 "모델 입력" 클래스지만 "전부" 원칙상 포함, UI 필터로 접음.apply_fal_angle 계측 mock TDDfal_angle_helpers.py :233 data = dl.read() 직후 capture_generated_image(data, role='fal_angle', prompt=None, pipeline_metadata={h,v,z}), return 흐름 불변. budget reserve 라벨 fal_angle_helpers.fal_run 불변.FAL_AI_ENABLED=true 필요).capture_artifact 결정론 TDDbuild_registered_pose_guide :285 make_registration_underlay 직후 capture_artifact(underlay_png, role='registered_pose_underlay', pipeline_metadata={group_id,bg_key,variant})(except 밖).generate_continuity_crop_png :331 crop_bytes 직후 capture_artifact(crop_bytes, role='zoom_continuity_crop', pipeline_metadata={group_id,source,scene_index,shot_index,bbox}).space_set_bg_step.py :335 core.mark_fp(...) 직후 capture_artifact(Path(marked).read_bytes(), role='space_set_marked_fp', pipeline_metadata={group_id,room,num}). mark_fp 내부 무수정._annotate_fp :300 fp.save(out_path) 직후 capture_artifact(Path(out_path).read_bytes(), role='dwelling_zone_annotated_fp', pipeline_metadata={verification_only:true,...})(또는 BytesIO 경유).openai_client.images.{generate,edit} 직접 호출이 wrapper 밖에 0 임을 잠금 — background_render/background_chain_render/floor_plan_render/location_floor_plan/space_set_bg_provider 전체 치환 보증(미래 누락·재도입 차단).png_metadata.embed_png_metadata / thumbnail·export / consumer write_bytes 사이트에 capture 호출 0(이중캡처 0 증명).CAPTURE_DIAG['skipped_no_context']↑ + image_asset 신규 intermediate 0(production 바이트·행 무변).pytest tests/services/image_capture + 신규 callargs + 기존 image step 회귀 green.| 사이트 | producer / 기존 capture | scope 위치(스레드) | 입력 lineage 현실 |
|---|---|---|---|
| zoom continuity | generate_continuity_crop_png(zoom_continuity_render_service.py:252) — crop=capture_artifact('zoom_continuity_crop') :336 / fill=gemini :380(최종 scene asset) | 함수 내부 crop 섹션만(:320-340). 단건=메인 / batch=worker — 둘 다 함수 안에서 열림 | source still primary scene asset UUID 해석 가능(단건=_load_source_primary_bytes 쿼리 / batch=메인스레드 사전 맵) |
| registered pose guide | build_registered_pose_guide(:225) — underlay=capture_artifact('registered_pose_underlay') :106 / edit=generate_floor_plan_image(location_floor_plan:139, ★default role floor_plan_image 오라벨) | attach_registered_pose_guide_ref 감싸기 — single coordinator:832 / batch :1426(worker 본문 내 직접 open) | bg plate UUID 는 worker DB 금지 + bg_map 무 UUID → v1 metadata(bg_id) defer |
| outdoor 3-stage | aerial_base(:471 T2I)→shot_blocking(:490 I2I)→camera_sketch(:622 I2I) 전부 generate_floor_plan_image 수렴, ★전부 default role floor_plan_image | _generate_shared_model_guides(outdoor_site_layout_step) base_fn:686 / guide_fn:720 — 메인 스레드 | A→B→C 단계 chaining UUID 불가(flush 시 UUID 부여, capture None 반환) → v1 group_id metadata |
file_path 역조회 인프라가 코드 전반 0건(전부 정방향 resolve_image_path) + ref 가 임시파일/checkpoint png 라 image_asset 행 자체 없음 + ★ 글자/substring 의미파싱 금지 규칙 정합. 따라서 구조 ID(entity_id/bg_id/still_id)→asset UUID 구조 조인만 사용. zoom 은 still_id→primary scene asset(이미 build_lineage_fields 패턴), pose/outdoor 는 v1 에서 metadata(bg_id/group_id)만.None), UUID 는 queue.flush 시점(uuid4)에야 부여 → 같은 실행 중 A 의 UUID 를 B 가 모름. 옵션 ⓐ capture API 를 "pre-gen UUID 반환"으로 확장(chaining 완전 lineage, 큰 변경) 옵션 ⓑ pipeline_metadata.group_id+stage 로 사후 그룹 엣지(캔버스가 이미 group_id 사용, 작은 변경). 제안=ⓑ v1, ⓐ 는 Phase D 후속. Codex 의견 요청.ImageAsset 에 scene_index 컬럼 없음(shot_index 만 있음). still-less 중간물(aerial base/blocking)도 캔버스가 씬으로 묶으려면 scene_index 필요 → queue.flush 가 ctx.scene_index 를 pipeline_metadata_json["scene_index"] 로 auto-fold(결정론 TDD). still-있는 중간물은 still_id 로 그룹.capture_generated_image 가 최종물을 intermediate 로 중복. 해법=scope 를 함수 내부 crop 섹션(:320-340)만 감싸고 fill 은 scope 밖(ctx None → no-op). 중복 0 테스트 필수.generate_floor_plan_image default role floor_plan_image 로 오라벨됨 → call_gpt_image_bytes·generate_floor_plan_image 에 optional capture_input_image_ids 추가(capture_role 은 기존) + 호출자가 정확 role 전달. default None → 다른 모든 floor_plan/bg/space_set 호출 byte-identical(mock call-args TDD).contextvars 자동전파 X, DB 세션 금지 — coordinator.py:1021 S23sh4). scope 를 worker 본문 안에서 직접 open/close(같은 thread set/reset → bind_context 불필요). 입력 UUID 는 메인 스레드에서 사전 해석 후 worker 로 주입. queue.flush 는 독립 SessionLocal() 이라 thread-safe.immobilized_registered_pose_guide_enabled(default False), outdoor_composition_guide_shared_model_enabled(default False). OFF 면 attach/guide 자체가 no-op → scope 열어도 capture 0(byte-identical 보장 = 안전).6포인트 방향 전부 동의. 아래 보강을 plan 에 fold(graph 가 나중에 애매해지지 않게):
input_image_ids 에는 진짜 image_asset UUID 만. label/path/bg_id/group_id 같은 구조키는 pipeline_metadata 에만. None=lineage 미해결/미제공, []=입력 없음을 앎(Phase D 가 덜 헷갈림). unresolved 는 빈 문자열/경로 억지주입 금지 → pipeline_metadata.unresolved_inputs=[...] 진단만.group_id, producer_stage(aerial_base|shot_blocking|camera_sketch), parent_stage(=derived_from_stage), shot_key/shot_index, layout_hash, base/blocking/camera_prompt_hash, 가능시 run-local guide_generation_id + retry/candidate tie-breaker(candidate_index or prompt hash). Phase D 가 structural_inferred edge 로 읽음.pm = dict(meta.pipeline_metadata or {}) 후 ctx 값(scene_index 등)을 키가 없을 때만 채우거나 context_* namespace. 사이트 명시 metadata 를 flush 가 덮어쓰면 디버깅 어려움. still-less intermediate(aerial base)는 metadata 가 유일 grouping key 라 필수.bind_context 로 한 queue 를 여러 worker 공유 금지(CaptureQueue thread-safe 아님). worker 마다 독립 scope + 독립 SessionLocal flush(Phase A 정합·non-fatal). 입력 UUID 는 메인스레드 사전 resolve 후 immutable 로 worker 주입.input_image_ids 에 UUID-looking 실제 asset id 만(구조키 누출 0). ⓑ zoom: crop row 1 생성 + fill Gemini capture 0 명시 잠금. ⓒ outdoor: DB row 3(base/blocking/sketch) + input_image_ids=None/known UUID only + metadata 로 group/stage/parent_stage 복원.| 파일 | 변경 | 신규/수정 |
|---|---|---|
image_capture/queue.py | flush 가 ctx.scene_index → pipeline_metadata_json["scene_index"] auto-fold | 수정 |
llm/gpt_image_primitive.py · pipeline/location_floor_plan.py | optional capture_input_image_ids threading(default None=byte-identical) | 수정 |
zoom_continuity_render_service.py | still_id/source_asset_id param + 내부 crop scope + crop capture 에 input_image_ids | 수정 |
scene_image_service.py · scene_generation_coordinator.py | source primary asset id 해석(단건 쿼리/batch 사전 맵) + zoom/pose scope 호출 배선 | 수정 |
core/steps/visual_continuity_anchor_step.py · registered_pose_guide_service.py | guide scope + capture_role='registered_pose_guide' threading | 수정 |
core/steps/outdoor_site_layout_step.py · pipeline/outdoor_site_layout_provider.py | 3-stage scope + stage별 role(aerial_base/shot_blocking/camera_sketch) | 수정 |
tests/services/image_capture/test_phase_c_*.py | 결정론 단위테스트(scope/fold/role/dedup/resolve) | 신규 |
flush 에서 ctx.scene_index is not None 이면 pipeline_metadata(없으면 {})에 "scene_index" 병합 후 pipeline_metadata_json 에 저장. 테스트: scope scene_index=12 + capture → row 의 pipeline_metadata_json 에 scene_index:12. scene_index None 이면 키 부재(기존 동작 보존).call_gpt_image_bytes(..., capture_input_image_ids: list[str]|None=None) → capture_generated_image(input_image_ids=capture_input_image_ids, ...). generate_floor_plan_image 도 동일 optional 인자 받아 thread. mock call-args TDD: default None 일 때 images.{edit,generate} kwargs + capture 인자가 치환 전과 동일(byte-identical), 명시 전달 시 input_image_ids 가 capture 까지 도달. 기존 5사이트 회귀 green.feat(image-capture): Phase C 토대 — scene_index fold + input_image_ids threading._load_source_primary_bytes(:200) 를 (bytes, asset_id) 반환으로 확장(이미 asset row 조회 중 — asset.id 추가만). batch=메인스레드 _persist_primary_and_track(selected_id) 에서 still_id→primary_asset_id 맵 구축(scene_paths_by_index_by_id 평행) → worker 주입(worker DB 금지 준수).generate_continuity_crop_png 에 still_id/source_asset_id param 추가. crop 생성+capture 섹션(:320-340)을 with generation_context(project_id, episode_id, stage='zoom_continuity', still_id, scene_index, shot_index) 로 감싸고 fill(:342+)은 with 밖. crop 의 capture_artifact 에 input_image_ids=[source_asset_id](None 이면 생략) + pipeline_metadata={bbox,group_id,source}.image_asset 추가 0(중복 0, 최종물 무변) — gemini client mock 으로 ctx None 확인. (c) source_asset_id None fallback 비차단.attach_registered_pose_guide_ref 호출(single coordinator:832 / batch :1426)을 with generation_context(project_id, episode_id, stage='registered_pose_guide', still_id=still_data.get('id'), scene_index, shot_index) 로 감쌈. ★batch 는 worker 본문 내라 그 thread 에서 직접 열림(bind_context 불필요).build_registered_pose_guide 가 generate_floor_plan_image 에 capture_role='registered_pose_guide' 전달(현재 미전달 → default floor_plan_image 오라벨). underlay(make_registration_underlay:106 capture_artifact)는 scope 안에서 자동 영속화.pipeline_metadata={bg_id, group_id, variant} 만(UUID 는 Phase D 메인스레드 resolve). 부분 lineage 허용._generate_shared_model_guides(메인 스레드) 에서 base_fn(:686)=stage='outdoor_site_layout', scene_index/shot_index=None(group 공통), guide_fn(:720)=scene_index=g_si, shot_index=g_shi. still_id=None(scene_still 이전 단계)._t2i_image/_edit_image_with_ref) + generate_floor_plan_image 에 capture_role 전달 → aerial_base / shot_blocking / camera_sketch 구분(default floor_plan_image 오염 교정).pipeline_metadata={group_id, location_id, stage} 로 사후 그룹 엣지(Decision #2 ⓑ). scene_index 는 C0 fold 로 metadata.is_intermediate=True 만 신규.open 금지): source→crop 엣지, pose/outdoor 중간 노드 group 표시.pipeline_role SOT 색/그룹, is_intermediate 필터/접기, 아웃룩 색·엣지, virtual→실이미지.