Jellyfish는 FastAPI(백엔드) + React(프론트) + Celery(비동기 작업)로 만든 웹 애플리케이션이다. 사용자가 프로젝트를 만들고 챕터 대본을 붙여넣으면, 여러 LLM 에이전트가 순서대로 돌면서 대본을 샷으로 쪼개고, 등장인물과 장소와 소품을 뽑아내고, 서로 충돌하는 표기를 병합하고, 각 샷의 첫 프레임·끝 프레임 이미지 생성용 프롬프트를 써준다.
그리고 그 결과를 버리지 않고 데이터베이스에 자산으로 남긴다. 3화에서 등장한 "붉은 코트를 입은 여주인공"은 7화에서도 같은 캐릭터 레코드를 참조한다. 이게 이 프로젝트의 존재 이유 전부다.
영화 촬영장에는 스크립터(script supervisor)라는 직책이 있다. 배우가 어제 왼손에 들었던 잔을 오늘도 왼손에 들고 있는지, 3번 씬에서 찢어진 소매가 5번 씬에도 찢어져 있는지를 집요하게 기록하고 확인하는 사람이다. 관객은 그 존재를 눈치채지 못하지만, 없으면 영화가 무너진다.
Jellyfish는 AI 영상 제작판 스크립터다. 이미지 생성 모델은 매번 새로 그리기 때문에 같은 인물을 두 번 그리면 다른 얼굴이 나온다. 그래서 "이 캐릭터는 이 참조 이미지, 이 설명, 이 의상 타임라인을 쓴다"를 DB에 못 박아두고 매번 그걸 물려준다.
샷 1: 낮, 골목, 여자가 뛴다 식의 촬영 단위로 변환한다. Jellyfish의 첫 번째 에이전트가 하는 일이 바로 이것이다.Runway, Pika, Kling, Sora 같은 도구는 이미 한 컷은 훌륭하게 만든다. 그런데 60편짜리 드라마를 만들려고 하면 문제가 완전히 달라진다.
| 어려움 | 단일 생성기(Runway·Pika 등) | Jellyfish의 접근 |
|---|---|---|
| 같은 인물 유지 | 매번 프롬프트를 사람이 복붙. 40편쯤 가면 관리 불능 | Character 테이블 + CharacterImage 참조 이미지를 DB에 저장, 자동 주입 |
| 대본 → 샷 변환 | 사람이 수동으로 쪼갬 | ScriptDividerAgent가 자동 분할 후 shots 테이블에 upsert |
| 샷 간 연결 | 없음. 각 컷이 독립 | 프레임 프롬프트 에이전트가 앞/뒤 샷 정보(continuity_guidance)를 받아 연속성 반영 |
| 표기 흔들림 | "주인공/여자/그녀"가 다 다른 인물로 처리됨 | EntityMergerAgent가 별칭(aliases)을 병합해 전역 ID 부여 |
| 긴 작업 추적 | 탭 닫으면 끝 | GenerationTask 테이블 + Celery. 진행률·취소·재조회 가능 |
대부분의 AI 툴은 결과물을 일회용으로 다룬다. 만들고, 저장하고, 끝. Jellyfish는 반대로 간다 — AI가 뽑아낸 캐릭터·소품·장면을 프로젝트 전체가 재사용하는 영속 데이터로 만든다.
이게 왜 중요하냐면, 이 구조에서는 AI가 좋아질수록 자산의 가치가 올라가는 게 아니라, 자산이 쌓일수록 AI 출력의 품질이 올라가기 때문이다. 데이터 플라이휠이 프로젝트 안에서 돈다.
Jellyfish는 특정 AI 회사에 묶여 있지 않다. 텍스트 LLM은 전부 OpenAI 호환 프로토콜로 통일했고(langchain_openai.ChatOpenAI 하나만 씀), 이미지·영상은 core/contracts/에 벤더 중립 계약을 정의한 뒤 OpenAI와 Volcengine(중국 바이트댄스 계열) 어댑터를 각각 구현했다.
더 중요한 건 Provider와 Model이 코드가 아니라 DB 테이블이라는 점이다. 관리자 화면에서 API 키와 base_url을 넣으면 새 공급자가 등록된다. 자체 호스팅 LLM을 꽂아도 되고, 로컬 vLLM을 붙여도 된다.
별 5,510개에 포크 952개인데도 업데이트가 정체된 상태다. 실무 도입을 검토한다면 유지보수 리스크를 감안해야 한다. 다만 공부 목적으로는 오히려 안정적이다 — 구조가 바뀌지 않으니 읽은 내용이 유효하다.
또 하나: pyproject.toml에 langgraph가 의존성으로 들어 있지만, 실제 코드에서 StateGraph 같은 그래프 구성은 발견되지 않았다. 파이프라인은 그래프가 아니라 Celery 태스크 단위로 순차 호출되는 독립 에이전트들이다. README나 의존성 목록만 보고 "LangGraph 프로젝트"라고 판단하면 틀린다.
이 저장소를 "영상 프로젝트"로만 보면 관심 범위가 좁아진다. "외부 API에 오래 걸리는 작업을 위임하고, 그 결과를 신뢰할 수 없는 상태로 받아서, 사람 검수를 거쳐 정식 데이터로 만드는 시스템"으로 읽으면 — 이건 결제 정산, 문서 OCR 파이프라인, 데이터 라벨링 툴에 그대로 적용되는 패턴이다.
backend/pyproject.toml을 그대로 읽어 각 의존성이 코드 어디서 실제로 쓰이는지까지 정리했다. 의존성 목록만 보면 알 수 없는 부분이 많다.
| 패키지 | 버전 | 실제 사용처 |
|---|---|---|
| fastapi | ≥0.115 | app/main.py 엔트리, api/v1/routes/* 전체 |
| uvicorn[standard] | — | ASGI 서버. Dockerfile의 실행 명령 |
| celery[redis] | ≥5.4 | core/celery_app.py (브로커=Redis), tasks/execute_task.py |
| langchain / langchain-core | ≥0.3 | chains/agents/base.py — create_agent + ToolStrategy 구조화 출력 |
| langgraph | ≥0.2 | 그래프 구성 코드 미발견. create_agent의 내부 의존이거나 확장 예약용으로 추정 |
| langchain-openai | (dev) | services/llm/{resolver,runtime}.py — 텍스트 LLM 전부 ChatOpenAI로 통일 |
| httpx | ≥0.27 | core/integrations/{openai,volcengine}/*.py — 이미지·영상은 SDK 안 쓰고 raw HTTP |
| sqlalchemy[asyncio] | ≥2.0 | models/*.py ORM 전체. 비동기 세션 |
| aiosqlite | — | 로컬 개발 기본 DB (sqlite+aiosqlite:///./jellyfish.db) |
| aiomysql | — | 운영 배포 (mysql+aiomysql://...) |
| boto3 | — | core/storage.py — S3 호환 스토리지. anyio로 스레드풀 위임해 이벤트루프 블로킹 방지 |
| pydantic / pydantic-settings | ≥2.0 | 스키마 검증 전체, app/config.py 설정 로딩 |
| cryptography | — | API 키 등 민감정보 처리용으로 추정 (직접 사용처 미확인) |
| jinja2 | — | 프롬프트 템플릿 렌더링 |
pip + venv + pip-tools를 하나로 합친 도구로, uv sync --frozen은 uv.lock에 잠긴 버전 그대로 설치한다. Jellyfish의 Dockerfile이 이걸 쓴다.| 항목 | 선택 | 비고 |
|---|---|---|
| 빌드 | Vite 5 + TypeScript 5.2 | tsc && vite build |
| UI | antd ^5.10 | 관리자형 화면. 커스텀 디자인 시스템 없음 |
| 상태관리 | zustand ^5.0.11 | src/store/useAppStore.ts 단일 스토어 |
| 라우팅 | react-router-dom ^6.30 | — |
| i18n | i18next + react-i18next | locales/{en-US,zh-CN} |
| HTTP | axios + openapi-typescript-codegen | 아래 참고 — 이게 핵심 |
| 드래그앤드롭 | react-beautiful-dnd | 샷 순서 편집용 |
| 모킹 | msw | 개발·테스트용 Mock Service Worker |
pnpm run openapi:update가 curl http://127.0.0.1:8000/openapi.json으로 FastAPI의 OpenAPI 스펙을 받아와 front/src/services/generated/에 TypeScript 클라이언트를 통째로 생성한다.
그리고 저장소의 AGENTS.md가 규칙으로 못 박았다 — "API를 바꾸면 반드시 이 커맨드를 실행할 것. 프론트는 수기 서비스 래퍼 대신 generated client만 쓸 것." 백엔드 스키마와 프론트 타입이 어긋날 수 없는 구조다.
boto3 기반이라 실제 AWS S3나 MinIO로 바꿔 꽂아도 그대로 동작한다.Python 생태계 표준인 Alembic 대신, backend/sql/001-*.sql ~ 008-*.sql 순번 SQL 파일을 mysql-init-sql 컨테이너가 순서대로 적용하고, init_db.py가 SQLAlchemy create_all로 테이블을 만든다.
장점은 단순함이고, 단점은 다운그레이드(롤백)가 없고 이미 적용된 마이그레이션 추적 테이블이 없다는 것이다. 개인·소규모 프로젝트에서는 흔한 선택이지만, 팀 규모가 커지면 반드시 문제가 되는 지점이라 "왜 Alembic을 쓰는가"를 역으로 이해하기 좋은 반례다.
| 워크플로 | 역할 |
|---|---|
| ghcr-images.yml | main/태그 push 시 backend·front 이미지를 GHCR에 빌드·푸시. QEMU + Buildx 멀티플랫폼, GHA 캐시 |
| backend-pylint.yml | 백엔드 린트 |
| commit-messages.yml | 커밋 메시지 규칙 검사 — [docs], [feat] 같은 프리픽스 강제 |
| deploy-site.yml | site/ 문서 사이트 배포 |
| issues-stale.yml | 오래된 이슈 자동 정리 |
| tag-release.yml | 릴리스 태깅 |
| 종류 | 구현 | 지원 공급자 |
|---|---|---|
| 텍스트 LLM | langchain_openai.ChatOpenAI 하나로 통일 | OpenAI 호환이면 무엇이든. 내장 등록: OpenAI, 알리바바 百炼(DashScope 호환 엔드포인트) |
| 이미지 | core/integrations/*/images.py, httpx 직접 호출 | OpenAI /images/generations·/images/edits (예: gpt-image-1.5) / Volcengine 火山方舟 (예: doubao-seedream-*) |
| 영상 | core/integrations/*/video.py, 생성 후 폴링 | OpenAI /videos (Sora 계열 추정) / Volcengine /contents/generations/tasks (Seedance 계열 추정) |
이미지·영상 어댑터는 둘 다 "생성 요청 → task_id 반환 → GET으로 폴링"이라는 동일한 패턴을 쓰고, core/contracts/image_generation.py의 ImageGenerationInput/ImageGenerationResult라는 공통 계약으로 추상화된다. 새 공급자를 추가할 때 계약만 지키면 나머지 시스템은 손댈 필요가 없다.
가장 대표적인 흐름 — "대본을 샷으로 쪼개줘"를 따라가 보자.
보통은 작업 종류마다 Celery 태스크를 만든다 — divide_script, generate_image, merge_entities... 그러면 태스크가 늘어날 때마다 Celery 라우팅 설정을 건드려야 한다.
Jellyfish는 Celery 태스크를 task.execute 하나만 등록하고, 인자로 task_id만 넘긴다. 워커는 DB에서 task_kind를 읽어 task_registry에서 실행기를 동적으로 찾는다. 새 작업을 추가할 때 실행기 클래스 하나만 등록하면 끝이다.
Registry + Strategy 패턴의 아주 깔끔한 실전 적용 사례다.
backend/app/chains/agents/ 안의 에이전트를 전부 정리했다. 각각 입력과 출력(Pydantic 모델)이 명확하게 정의돼 있다.
| 에이전트 | 하는 일 | 출력 |
|---|---|---|
| AgentBase | 공통 베이스 — 프롬프트 렌더링 + LLM 호출 + JSON 복구 파싱 | (추상) |
| ScriptDividerAgent | 전체 대본 → 샷 단위 분할 | ScriptDivisionResult (shots[]: index/start_line/shot_name/time_of_day) |
| ElementExtractorAgent | 분할 결과 → 캐릭터·장면·소품·의상·대사 전체 초안 | StudioScriptExtractionDraft |
| EntityMergerAgent | 여러 샷의 추출 결과를 교차 병합, 전역 ID 부여, 충돌 식별 | EntityMergeResult (merged_library, conflicts[]) |
| VariantAnalyzerAgent | 의상·외형 변이 타임라인 분석 | VariantAnalysisResult |
| ConsistencyCheckerAgent | 원문에서 "같은 인물이 다른 정체성으로 뒤섞이는" 혼동 탐지 (동명이인, 대명사 오귀속) | ScriptConsistencyCheckResult (issues[]) |
| ScriptOptimizerAgent | 발견된 일관성 이슈만 겨냥해 대본 수정 | ScriptOptimizationResult |
| ScriptSimplifierAgent | 스토리 유지하며 분량 압축 | ScriptSimplificationResult |
| CharacterPortraitAnalysisAgent | 캐릭터 초상 설명 → 구조화 스펙 | *AnalysisResult |
| CostumeInfoAnalysisAgent | 의상 설명 → 구조화 스펙 | 동일 |
| PropInfoAnalysisAgent | 소품 설명 → 구조화 스펙 | 동일 |
| SceneInfoAnalysisAgent | 장면 설명 → 구조화 스펙 | 동일 |
| ShotFirstFrame / LastFrame / KeyFramePromptAgent | 샷 메타 30여 필드 → 첫/끝/키 프레임 이미지 생성 프롬프트 작성 | ShotFramePromptResult(prompt) |
첫 프레임과 끝 프레임은 그냥 "처음"과 "끝"이 아니다. 영상 생성 모델은 두 프레임 사이를 보간하기 때문에, 첫 프레임이 이미 동작을 완료한 상태로 그려지면 영상에 움직임이 없어진다.
shot_frame_prompt_agents.py는 이걸 프롬프트에 명시적으로 박아뒀다:
# chains/agents/shot_frame_prompt_agents.py
_FRAME_FOCUS = {
"首帧": "...只表现触发瞬间或最初反应,
不要直接写成后续完成动作、最终姿态或情绪爆发完成态。",
# 첫 프레임: 촉발 순간이나 최초 반응만 표현.
# 완료된 동작·최종 자세·감정 폭발 완료태로 쓰지 말 것.
"尾帧": "...强调动作收束、人物结束姿态、视线落点或情绪余韵。",
# 끝 프레임: 동작의 수렴, 인물의 종료 자세, 시선의 착지점, 감정의 여운.
"关键帧": "...不必平均描述整个过程。",
# 키 프레임: 전 과정을 균등하게 서술할 필요 없음.
}
이건 프롬프트 엔지니어링이 아니라 영상 문법에 대한 도메인 지식이 코드에 녹아든 사례다. 이런 게 오픈소스를 읽을 때 진짜 얻는 것이다.
chains/agents/base.py는 이 프로젝트에서 가장 배울 게 많은 파일이다. LLM에게 구조화된 출력을 요구했는데 이상한 게 왔을 때 포기하지 않는 5단계 폴백 체인을 구현했다.
# chains/agents/base.py — 마지막 방어선
call_kwargs = _parse_python_call_kwargs(python_like)
if call_kwargs is not None:
return call_kwargs
구조화 출력(structured output) 기능이 있다고 해서 100% 신뢰할 수 없다. 모델 버전이 바뀌거나, 프롬프트가 길거나, 토큰이 잘리면 형식이 무너진다.
Jellyfish의 접근은 "실패를 예외가 아니라 정상 경로의 일부로 설계"하는 것이다. 이건 LLM을 쓰는 모든 프로덕션 시스템에 그대로 필요한 태도다.
"AI 일관성 관리"라고 하면 보통 임베딩 유사도나 벡터DB를 떠올린다. Jellyfish는 그렇지 않다. services/studio/entity_existence.py의 check_names_existence는 SQL ILIKE '%q%' — 그냥 대소문자 무시 부분일치다.
퍼지 매칭도, 임베딩도 없다. 대신 LLM이 병합 단계에서 이름 사전을 만들고, 이후 모든 샷이 그 사전의 정확한 문자열만 참조하도록 프롬프트로 강제한다.
그럼 어떻게 일관성이 유지되는가? 세 가지 장치의 조합이다.
# schemas/skills/script_processing.py — EntityEntry
id : str # 전역 고유 ID
name : str # 정식 이름 (이후 모든 참조가 이걸 씀)
type : str # character / scene / prop / costume
aliases : list[str] # "그녀", "여주인공", "민서" 전부 여기로
normalized_name : str
confidence : float # LLM의 확신도
first_appearance : EvidenceSpan # 대본 어디서 처음 나왔는지 (증거)
appearances : list[...]
variants : list[...] # 의상·외형 변이
first_appearance가 증거(evidence)를 담는다는 점이 좋다. AI가 "이 인물이 있다"고 주장할 때 대본 몇 번째 줄이 근거인지를 함께 내놓게 만든 것이다. 사람이 검수할 때 이게 결정적이다.
AI 출력을 곧바로 정식 테이블에 쓰지 않고, 중간 상태 테이블을 하나 두고 사람이 승격시키는 구조. 오탐(같은 이름 다른 인물, 지문을 인물로 착각 등)이 프로덕션 데이터를 오염시키는 걸 원천 차단한다.
이건 영상과 아무 상관 없이 모든 AI 데이터 파이프라인에 그대로 쓸 수 있는 패턴이다. 문서 OCR, 이력서 파싱, 인보이스 추출 — 전부 같은 문제를 가진다.
EntityMergerAgent의 입력을 보면 특이한 필드가 있다.
all_extractions_json # 모든 샷의 추출 결과
historical_library_json # 기존에 확정된 자산 라이브러리
script_division_json # 샷 분할 정보
previous_merge_json # ← 직전 병합 결과
conflict_resolutions_json # ← 사람이 내린 충돌 판정
마지막 두 개가 핵심이다. 병합이 충돌을 냈을 때 사람이 "A와 B는 같은 인물이다"라고 판정을 내려주면, 그 판정을 프롬프트에 넣어 다시 돌린다. Celery의 자동 재시도가 아니라 사람이 개입한 뒤의 수동 재시도다 — 그리고 이 도메인에서는 그게 맞다.
저장소 루트의 AGENTS.md(약 8KB짜리 내부 규약 문서)는 상태의 의미를 세 가지로 명확히 분리하라고 못 박았고, 코드가 실제로 그걸 지킨다.
| 상태 | 어디에 | 의미 |
|---|---|---|
| 정보 확인 상태 | Shot.status (pending / ready) | "이 샷의 정보 추출이 끝났는가". generating 값은 의도적으로 폐기됨 |
| 런타임 태스크 상태 | GenerationTask.status | "지금 무슨 작업이 돌고 있는가". 진행률·취소 포함 |
| 영상 준비도 | shot_video_readiness.py | "지금 영상 생성을 시작해도 되는가". DB에 저장 안 하고 매번 재계산 |
상태 필드 하나에 모든 걸 욱여넣으면 반드시 터진다. Shot.status = "generating"으로 두면, 서버가 죽었을 때 그 샷은 영원히 generating에 갇힌다. 아무도 그걸 되돌려주지 않기 때문이다.
Jellyfish는 영속 상태(shot)는 사실만 담고, 휘발성 상태(task)는 별도 테이블에, 파생 상태(readiness)는 저장하지 않고 계산한다. 상태 관리 설계의 교과서적 분리다.
# core/celery_app.py
@worker_process_init.connect
def _reset_async_db_runtime(**_: object) -> None:
reset_db_runtime()
fork()해서 워커를 만든다. 그런데 부모 프로세스가 이미 만든 asyncio 이벤트루프와 DB 커넥션 풀은 fork 후 자식에서 깨진다. 여러 자식이 같은 소켓을 공유하면서 응답이 뒤섞이거나 데드락이 난다. 매우 흔하면서 디버깅이 지옥인 버그다.해결책은 worker_process_init 시그널로 자식 프로세스가 시작될 때마다 DB 런타임을 새로 만드는 것이다. 세 줄짜리 코드지만, 이걸 몰라서 며칠을 태우는 사람이 많다.
추가로 task_ignore_result=True 설정이 눈에 띈다. Celery의 result backend를 아예 안 쓴다는 뜻이다 — 상태의 진실원(single source of truth)은 오직 GenerationTask 테이블 하나다. 상태가 두 군데 저장돼 어긋나는 문제를 원천 제거한 선택이다.
1시간차 — AGENTS.md → models/task.py → tasks/execute_task.py → services/worker/task_registry.py. 태스크 시스템의 뼈대가 보인다.
2시간차 — chains/agents/base.py 정독. 그다음 script_divider_agent.py 하나만 읽으면 나머지 12개는 같은 패턴이다.
3시간차 — schemas/skills/script_processing.py의 EntityEntry → models/studio_assets.py → services/studio/shot_extracted_candidates.py. 일관성 관리의 데이터 흐름이 보인다.
4시간차 — core/contracts/ + core/integrations/ 비교. 추상화 계약이 어떻게 벤더 차이를 흡수하는지.
어디: chains/agents/base.py
구조화 출력 → 마크다운 펜스 제거 → 따옴표 정규화 → ast.literal_eval → 파이썬 함수호출 파싱. 5단계 폴백. LLM을 프로덕션에 쓰는 사람이라면 이 파일 하나만 읽어도 본전이다.
가져갈 것: "실패를 예외가 아니라 정상 경로의 일부로 설계한다"는 사고방식.
어디: tasks/execute_task.py, services/worker/task_registry.py, core/tasks/registry.py
같은 패턴이 두 곳에서 재사용된다 — 태스크 실행기 레지스트리(task_kind → executor)와 벤더 어댑터 레지스트리(task_kind + provider_key → adapter). 새 기능을 추가할 때 기존 코드를 수정하지 않고 등록만 하면 되는 구조를 어떻게 만드는지 보여준다.
어디: core/contracts/image_generation.py vs core/integrations/{openai,volcengine}/images.py
두 벤더의 API는 엔드포인트도, 파라미터명도, 응답 형태도 다르다. 그런데 시스템의 나머지 부분은 ImageGenerationInput/Result만 안다. 인터페이스를 어느 수준에서 잘라야 하는지에 대한 좋은 실례다.
어디: AGENTS.md + models/studio_shots.py + services/studio/shot_video_readiness.py
영속 상태 / 휘발성 상태 / 파생 상태를 나눈 것. 특히 파생 상태를 DB에 저장하지 않고 매번 계산하는 판단이 좋다. 캐시하면 빨라지지만 반드시 어긋난다.
가져갈 것: 상태 필드를 추가하기 전에 "이건 사실인가, 진행 중인 일인가, 다른 것으로부터 계산되는가"를 먼저 묻는 습관.
어디: ShotExtractedCandidate 모델 + services/studio/shot_extracted_candidates.py
AI 결과를 담는 후보 테이블과 확정 자산 테이블을 물리적으로 분리한 것. 이 하나가 "AI 툴"과 "AI를 쓰는 프로덕션 시스템"의 차이다.
어디: front/package.json의 openapi:update + AGENTS.md 규칙
FastAPI가 자동 생성한 OpenAPI 스펙 → TypeScript 클라이언트 자동 생성. 백엔드 타입을 바꾸면 프론트가 컴파일 에러로 알려준다. 규약을 문서에만 쓰지 않고 도구로 강제한 점이 핵심이다.
어디: core/celery_app.py의 worker_process_init 핸들러, task_ignore_result=True
fork 후 이벤트루프 재구축, result backend 미사용으로 상태 진실원 단일화. 둘 다 겪어본 사람만 아는 함정이다.
어디: AGENTS.md
단순 코딩 컨벤션이 아니라 문서 카테고리 분류, 릴리스 노트 필수 섹션, 상태의 의미론까지 정의했다. AI 코딩 에이전트와 협업하는 저장소에서 규약 자체가 산출물이 되는 최근 흐름을 잘 보여준다.
base.py의 파서만 떼어내 나만의 라이브러리로 만들고 테스트 작성백엔드에 로컬 추론 코드가 한 줄도 없다. 이미지·영상 생성은 전부 외부 API 호출이다. 즉 일반 노트북이나 소형 VM에서 전체 스택이 돈다. 대신 API 사용료가 든다.
| 항목 | 요구사항 |
|---|---|
| Python | 3.11 이상 (Docker는 3.12-slim) |
| Node | 20 이상 (프론트 빌드) |
| DB | MySQL 9.0 (운영) / SQLite (로컬 개발 기본값) |
| 메시지 브로커 | Redis 7 — Celery 브로커 전용, result backend 미사용 |
| 오브젝트 스토리지 | RustFS (기본) — S3·MinIO로 교체 가능 |
| GPU | 불필요 |
| API 키 | 최소 텍스트 LLM 1개(OpenAI 호환). 이미지·영상 쓰려면 OpenAI 또는 Volcengine 키 추가 |
| 포트 | 7788(프론트), 8000(API), 3306(MySQL), 6379(Redis), 9000/9001(RustFS) |
| 컨테이너 | 역할 | 수명 |
|---|---|---|
| mysql | MySQL 9.0 | 상시 |
| redis | Celery 브로커 | 상시 |
| rustfs | S3 호환 스토리지 + 콘솔 | 상시 |
| backend-init-db | init_db.py로 테이블 생성 | 1회성 |
| mysql-init-sql | sql/001~008 순서 적용 | 1회성 |
| backend | FastAPI (:8000) | 상시 |
| celery-worker | 동일 이미지, worker 모드 | 상시 |
| front | nginx (:7788) | 상시 |
# 백엔드
cd backend
uv sync
uvicorn app.main:app --reload
# Celery 워커 (별도 터미널)
celery -A app.core.celery_app worker -l info
# 프론트 (별도 터미널)
cd front
pnpm install
pnpm dev
# API 바꿨으면 반드시 — AGENTS.md 규칙
pnpm run openapi:update
영상 생성 API는 호출당 단가가 높다. 60편짜리 드라마의 모든 샷에 영상 생성을 돌리면 수백~수천 건의 API 호출이 발생한다. 실습할 때는 챕터 1개, 샷 2~3개로 제한하고, 공급자 대시보드에서 사용량 한도를 먼저 걸어두는 걸 권한다.
텍스트 처리(분할·추출·병합)까지만 돌려도 이 프로젝트의 학습 가치는 대부분 얻을 수 있다.
chains/agents/base.py에서 _extract_json_from_text, _repair_json_like, _quote_unquoted_object_keys, _parse_python_call_kwargs를 추출해 독립 모듈로 만든다.
그다음이 진짜 과제다 — 일부러 망가진 LLM 응답 20종(마크다운 펜스, trailing comma, 유니코드 따옴표, 키에 따옴표 없음, 중간에 잘린 JSON, 설명문이 앞에 붙은 경우…)을 만들어 pytest로 각 단계가 어디서 구제하는지 검증한다.
배우는 것: 폴백 체인의 각 단계가 실제로 어떤 실패를 잡는지 몸으로 이해하게 된다.
SQLite + 로컬 Redis로 백엔드를 띄우고, 이미지·영상 없이 대본 → 분할 → 추출 → 병합까지만 돌려본다. LLM은 저렴한 모델(또는 Ollama 같은 OpenAI 호환 로컬 서버)을 Provider로 등록한다.
짧은 한국어 대본(A4 1장, 등장인물 3명, 장면 4개)을 직접 써서 넣어보고, GenerationTask 테이블을 직접 SELECT해서 각 단계 결과 JSON을 눈으로 확인한다.
확인할 것: 한국어 대본에서 별칭 병합(EntityMergerAgent)이 얼마나 잘 되는가? 중국어 프롬프트로 작성된 에이전트가 한국어를 어떻게 다루는가?
core/contracts/image_generation.py의 계약을 읽고, 새 공급자(예: Stability AI, Replicate, 또는 로컬 ComfyUI HTTP API) 어댑터를 core/integrations/에 추가한다. register_task_adapter로 등록만 하면 기존 코드는 손대지 않아야 한다.
성공 기준: 기존 파일을 하나도 수정하지 않고 새 공급자가 동작하면 성공. 수정이 필요했다면 그 지점이 곧 추상화가 새는 곳이다 — 왜 샜는지 분석하는 게 이 과제의 진짜 목적이다.
영상과 전혀 무관한 도메인을 하나 골라 같은 구조를 만든다. 추천: 이력서 PDF에서 경력·기술스택 추출 또는 영수증 이미지에서 항목 추출.
구현할 것:
① 원본 테이블 + ExtractedCandidate 테이블 (pending/linked/ignored)
② LLM 추출 시 first_appearance 같은 증거 필드 포함
③ 후보를 확정 엔티티로 승격시키는 서비스 함수
④ 같은 대상이 여러 번 추출됐을 때의 병합(별칭 관리)
배우는 것: Jellyfish의 설계가 영상 도메인 특수적인지, 일반적인 패턴인지 직접 검증하게 된다. 필자 생각엔 후자다.
현재 프론트는 태스크 상태를 폴링한다. 그런데 core/task_manager/strategies.py에는 이미 StreamingDeliveryStrategy가 AsyncPollingDeliveryStrategy와 나란히 준비돼 있다.
이 확장점을 실제로 살려서, 태스크 진행률을 SSE(Server-Sent Events)로 밀어주도록 만든다. 백엔드는 StreamingResponse, 프론트는 EventSource.
어려운 지점: Celery 워커는 API 서버와 다른 프로세스다. 워커의 진행률 변화를 API 프로세스가 어떻게 알 것인가? (힌트: Redis Pub/Sub. 이미 Redis가 떠 있다.)
배우는 것: 설계자가 남겨둔 확장점(인터페이스)이 실제로 확장 가능한지 검증하는 경험. 대부분의 "확장 가능한 설계"는 막상 확장하려면 안 된다.
목표: async def 라우트에서 비동기 세션을 안전하게 쓰는 법. 세션 수명 관리(dependency injection), N+1 문제, selectinload.
Jellyfish에서 볼 곳: core/db.py, api/v1/routes/studio.py
산출물: 프로젝트→챕터→샷 3계층 CRUD API를 직접 처음부터 작성
목표: 브로커/워커/result backend의 역할 구분, prefork vs gevent, 태스크 취소와 타임아웃, 진행률 보고.
Jellyfish에서 볼 곳: core/celery_app.py, tasks/execute_task.py
산출물: 30초 걸리는 가짜 작업을 만들고 진행률 폴링 + 취소 기능까지 구현
함정 체험: 일부러 worker_process_init 핸들러 없이 비동기 DB를 써보고 무슨 일이 일어나는지 관찰
목표: Pydantic 모델을 LLM 출력 스키마로 쓰는 법, function calling / JSON mode / tool strategy의 차이, 그리고 전부 실패했을 때의 대응.
Jellyfish에서 볼 곳: chains/agents/base.py (이 주차의 핵심)
산출물: 과제 1(파서 라이브러리화) 완료
목표: "같은 것을 가리키는 여러 표현"을 어떻게 데이터로 다룰 것인가. 별칭 테이블, 정규화, 병합·분할(merge/split) 연산, 감사 로그.
Jellyfish에서 볼 곳: schemas/skills/script_processing.py의 EntityEntry, models/studio_assets.py
확장 학습: Entity Resolution(개체 해소)이라는 정식 분야가 있다. Jellyfish는 LLM으로 이걸 우회했지만, 전통적 방법(레벤슈타인 거리, blocking, Fellegi-Sunter 모델)도 함께 보면 왜 LLM 방식을 택했는지 이해된다.
산출물: 과제 4(다른 도메인 이식) 착수
목표: 느리고 불안정한 외부 API를 다루는 법. 폴링 백오프, 타임아웃 계층화, 멱등성(idempotency) 키, 부분 실패 처리, 비용 추적.
Jellyfish에서 볼 곳: core/integrations/*/video.py의 폴링 루프, core/contracts/
비판적으로 볼 것: Jellyfish에는 지수 백오프나 서킷 브레이커가 명시적으로 없다. 있다면 어디에 넣어야 할까? 이 질문에 답할 수 있으면 이 주차는 성공이다.
산출물: 과제 3(새 공급자 어댑터) 완료
목표: OpenAPI 스펙을 단일 진실원으로 삼는 개발 흐름. 코드 생성, 타입 안정성, CI에서 스펙 변경 감지.
Jellyfish에서 볼 곳: front/package.json의 openapi:update, AGENTS.md
산출물: 1주차에 만든 CRUD API에 대해 TypeScript 클라이언트를 자동 생성하고, CI에서 "생성물이 최신인지" 검사하는 워크플로까지 작성
이 로드맵을 다 돌면 "AI 영상 만드는 법"은 거의 안 배운다. 대신 느리고 불확실한 외부 시스템을 붙여서, 신뢰할 수 없는 출력을 받아, 사람 검수를 거쳐 정식 데이터로 만드는 백엔드를 설계할 수 있게 된다.
그게 지금 대부분의 "AI 제품"이 실제로 하는 일이다.
| 용어 | 뜻 |
|---|---|
| 숏드라마 (短剧) | 1~2분짜리 세로형 짧은 드라마. 한 작품이 60~100편. 편수가 많아 일관성 관리가 핵심 난제. |
| 분경 (分镜) | 대본을 카메라 단위(샷)로 쪼개는 작업. 스토리보드 분할. |
| Shot / ShotDetail | 촬영 단위. chapter_id + index로 유니크. 상세 정보는 1:1 테이블에 분리. |
| ShotFrameImage | 샷의 첫/끝/키 프레임 이미지. frame_type에 유니크 제약. |
| EntityEntry | 캐릭터·장면·소품·의상을 표현하는 스키마. aliases, first_appearance(증거), variants를 포함. |
| ShotExtractedCandidate | AI가 뽑은 후보. pending/linked/ignored 상태. 확정 자산 테이블과 물리적으로 분리. |
| GenerationTask | 모든 비동기 작업의 공용 테이블. task_kind가 실행기를 결정. 상태의 단일 진실원. |
| task_kind | 작업 종류 문자열. Celery 태스크를 늘리지 않고 이 값으로 실행기를 동적 조회. |
| task registry | task_kind → 실행기 클래스 매핑. 새 작업 추가 시 등록만 하면 됨. |
| video readiness | "지금 영상 생성을 시작해도 되는가"의 파생 상태. DB에 저장하지 않고 매번 재계산. |
| DeliveryStrategy | 결과 전달 방식 추상화. AsyncPolling(현재 사용) / Streaming(준비만 됨). |
| ToolStrategy | LangChain에서 툴 호출 형태로 구조화 출력을 강제하는 전략. |
| JSON 복구 파서 | LLM의 깨진 출력을 5단계로 구제하는 폴백 체인 (base.py). |
| prefork fork 문제 | Celery가 fork()한 뒤 부모의 asyncio 이벤트루프·DB 풀이 자식에서 깨지는 현상. worker_process_init으로 대응. |
| task_ignore_result | Celery result backend를 쓰지 않는 설정. 상태를 DB 한 곳에만 두기 위한 선택. |
| Provider / Model | AI 공급자와 모델. 코드가 아니라 DB 테이블이라 관리 화면에서 추가 가능. |
| ModelSettings | 단일 행 테이블. 전역 기본 텍스트/이미지/영상 모델을 지정. |
| contract (계약) | ImageGenerationInput/Result 같은 벤더 중립 입출력 정의. 어댑터가 이걸 구현. |
| RustFS | Rust로 만든 S3 호환 오브젝트 스토리지. MinIO 대안. |
| uv | Rust로 만든 초고속 Python 패키지 관리자. uv sync --frozen으로 락파일 그대로 설치. |
| openapi-typescript-codegen | OpenAPI 스펙에서 TS 클라이언트를 생성하는 도구. 프론트는 이 생성물만 사용(규약). |
| window.__ENV 주입 | nginx 컨테이너 시작 시 환경변수를 JS로 만들어 주입하는 패턴. 이미지 재빌드 없이 설정 변경. |
| AGENTS.md | 저장소 루트의 내부 규약 문서. 코딩·문서·상태 의미론까지 정의. 스키마가 실제로 이걸 따름. |
| Human-in-the-loop 게이팅 | AI 출력을 중간 상태로 받고, 사람이 승인해야 정식 데이터가 되는 구조. |
| Entity Resolution | "같은 것을 가리키는 여러 표현"을 하나로 묶는 문제. Jellyfish는 LLM 병합으로 접근. |
backend/app/chains/agents/base.py — JSON 복구 파서 (최고의 학습 자료)backend/app/tasks/execute_task.py — Celery 유일 진입점backend/app/core/celery_app.py — fork 대응 + result backend 미사용backend/app/schemas/skills/script_processing.py — EntityEntry 정의backend/app/services/studio/shot_video_readiness.py — 파생 상태 재계산backend/app/core/contracts/image_generation.py — 벤더 중립 계약backend/app/chains/agents/shot_frame_prompt_agents.py — 영상 문법이 녹아든 프롬프트worker_process_init의 정확한 시점ToolStrategy의 원리