feat(generator): 카메라 모션을 media.py 로 통합하고 스토리보드가 컷별로 선택하도록 확장

This commit is contained in:
김성경 2026-08-11 15:01:23 +09:00
parent 434d0ca9c0
commit e1dca36d21
31 changed files with 485 additions and 295 deletions

View File

@ -32,7 +32,7 @@ async def lifespan(app: FastAPI):
await init_dashboard_table()
# 썰박스 스키마는 앱이 만들지 않는다 — **DB 변경은 전부 수동**이 방침이다.
# 배포 전에 docs/manual_ddl/2026-07-30-ssulbox.sql 을 직접 실행할 것.
# 배포 전에 docs/database-schema/migration_2026-07-30_ssulbox.sql 을 직접 실행할 것.
# (자동 마이그레이션 ensure_ssulbox_schema() 는 2026-07-30 제거)
if ssulbox_settings.SSULBOX_ENABLED:
# 고아 잡 스윕 — 이전 프로세스가 죽으며 남긴 queued/running 을 환불·정리.

View File

@ -64,7 +64,7 @@ class SocialUpload(Base):
__tablename__ = "social_upload"
__table_args__ = (
# ADO2 영상과 썰박스 콘텐츠를 함께 담는다(2026-07-30 병합,
# docs/manual_ddl/2026-07-30-social-upload-merge.sql).
# docs/database-schema/migration_2026-07-30_social_upload_merge.sql).
# 대상은 video_id / content_id 중 **정확히 하나**만 채워진다.
CheckConstraint(
"(video_id IS NULL) <> (content_id IS NULL)",

View File

@ -13,7 +13,7 @@ mysql_engine/charset/collate 명시, 컬럼마다 comment.
`lyric`/`song` 은 상태 전이를 겪으면서도 `created_at` 만 갖고, `social_upload` 만 예외다.
주의: Alembic 이 없고 **DB 변경은 전부 수동**이 방침이다. 이 파일을 고쳐도 앱은
어떤 DDL 도 실행하지 않는다 — 대응 SQL 을 docs/manual_ddl/ 에 추가하고 배포 전에
어떤 DDL 도 실행하지 않는다 — 대응 SQL 을 docs/database-schema/ 에 추가하고 배포 전에
직접 실행해야 운영 DB 에 반영된다. (자동 마이그레이션은 2026-07-30 제거)
"""
@ -247,7 +247,7 @@ class SsulContent(Base):
# SNS 업로드 모델도 여기 없다.
# castad `social_upload` 에 병합됐다(2026-07-30, docs/manual_ddl/
# 2026-07-30-social-upload-merge.sql) — 그쪽 행은 ADO2 면 video_id, 썰박스면
# castad `social_upload` 에 병합됐다(2026-07-30, docs/database-schema/
# migration_2026-07-30_social_upload_merge.sql) — 그쪽 행은 ADO2 면 video_id, 썰박스면
# content_id 가 채워지고 CHECK 로 하나만 강제한다. 덕분에 dashboard 통계가
# 썰박스 업로드를 무수정으로 집계한다(SocialUpload 만 읽고 video_id 는 안 본다).

View File

@ -81,6 +81,17 @@ class SsulTaskStatus(BaseModel):
step: int = Field(..., ge=0, le=4, description="완료한 단계 수 (0=준비, 4=합성 완료)")
error: Optional[str] = None
video_url: Optional[str] = Field(None, description="완료 시에만 채워진다")
# 완료 화면의 파일명·표시에 쓴다(ADO2 가 업장명으로 파일명을 만드는 것과 동일).
#
# **`status='done'` 이면 채워져 있다.** 확보 경로가 3중이라서다:
# ① 자동완성으로 고른 경우 생성 요청에 실려 온다
# ② 아니면 워커가 place 상세를 크롤링해 채운다(`set_place_info`)
# ③ 그래도 비어 있으면 엔진 로그의 `■ 가게:` 마커로 finalize 시 채운다
# 다만 생성 **중**(queued/running)에는 아직 비어 있을 수 있다 — 행은 요청 즉시
# 만들어지기 때문이다(크레딧 선차감). 진행 화면에서 쓰려면 그 점을 감안할 것.
store_name: str = Field(
"", description="대상 업장명. 완료(done) 시점에는 항상 채워져 있다"
)
created_at: datetime
model_config = {"from_attributes": True}

View File

@ -2,7 +2,7 @@
-- Migration: 크레딧 충전 요청 / 거래 이력 테이블 추가
-- Date: 2026-04-29
-- Description: 백오피스 크레딧 워크플로우 도입
-- 선행 조건: migration_2026_04_29_add_admin_table.sql 먼저 실행
-- 선행 조건: migration_2026-04-29_add_admin_table.sql 먼저 실행
-- ============================================================
CREATE TABLE IF NOT EXISTS credit_charge_request (

View File

@ -0,0 +1,30 @@
-- 실행 순서: migration_2026-07-30_ssulbox.sql 의 §1(ssul_content 생성) 이후.
-- 대상: MySQL 8.0.16+ (CHECK 가 실제로 강제되는 버전. 운영은 8.4)
-- 1. content_id 추가 + FK.
-- upload_seq 채번 인덱스 (content_id, social_account_id, upload_seq) 는
-- 선행 컬럼이 content_id 라 FK 인덱스 요건도 겸한다(단독 인덱스 불필요 —
-- video_reaction 때는 유니크가 user_uuid 선행이라 단독이 필요했던 것과 다르다).
ALTER TABLE `social_upload`
ADD COLUMN `content_id` BIGINT NULL
COMMENT '썰박스 콘텐츠 id (ADO2 업로드면 NULL)' AFTER `video_id`,
ADD CONSTRAINT `fk_social_upload_content`
FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) ON DELETE CASCADE,
ADD INDEX `idx_social_upload_content_seq`
(`content_id`, `social_account_id`, `upload_seq`);
-- 2. video_id 를 nullable 로. 기존 행은 전부 ADO2 업로드라 값이 있어 영향 없다.
ALTER TABLE `social_upload`
MODIFY COLUMN `video_id` INT NULL COMMENT 'ADO2 영상 id (썰박스 업로드면 NULL)';
-- 3. "정확히 하나만 채워짐" 강제. 이게 없으면 둘 다 NULL 이거나 둘 다 채워진
-- 행이 조용히 생긴다.
ALTER TABLE `social_upload`
ADD CONSTRAINT `ck_social_upload_one_target`
CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL));
-- 4. 과도기 테이블 제거 (로컬 등 이미 만든 환경만 해당. 운영은 애초에 안 만든다).
-- ⚠️ 행이 남아 있으면 social_upload 로 이관한 뒤에 지울 것.
DROP TABLE IF EXISTS `ssul_social_upload`;

View File

@ -1,21 +1,9 @@
-- =============================================================================
-- 썰박스 통합 DDL (수동 실행 전용)
--
-- 자동 마이그레이션(구 app/ssulbox/migration.py)을 제거하면서 그 내용 전체를
-- 여기로 옮겼다. **앱은 어떤 DDL 도 실행하지 않는다** — 배포 전에 이 파일을
-- 운영 DB 에 직접 실행할 것.
--
-- 기준: 2026-07-30 로컬 DB 의 SHOW CREATE TABLE (모델 app/ssulbox/models.py,
-- app/comment/models.py, app/video/models.py 와 일치 확인).
-- 대상: MySQL 8.0.16+ (CHECK 제약이 실제로 강제되는 버전. 운영은 8.4)
--
-- 실행 순서가 중요하다:
-- §1 신규 테이블 → §2 기존 테이블 확장(§1 의 ssul_content 를 FK 로 참조)
--
-- MySQL 은 ADD COLUMN IF NOT EXISTS 를 지원하지 않는다. 일부만 적용된 DB 라면
-- 이미 적용된 문은 개별적으로 건너뛸 것 (각 문이 독립적으로 실행 가능하다).
-- =============================================================================
-- 실행 순서: 이 파일 먼저 → migration_2026-07-30_social_upload_merge.sql
-- (§1 의 ssul_content 를 FK 로 참조하므로 순서를 바꾸면 실패한다)
-- 대상: MySQL 8.0.16+ (CHECK 가 실제로 강제되는 버전. 운영은 8.4)
-- 참고: MySQL 은 ADD COLUMN IF NOT EXISTS 를 지원하지 않는다. 일부만 적용된 DB 라면
-- 이미 적용된 문은 개별적으로 건너뛸 것(각 문이 독립적으로 실행 가능하다).
-- =============================================================================
-- §1. 신규 테이블
@ -101,16 +89,3 @@ ALTER TABLE `video_reaction`
ADD CONSTRAINT `ck_video_reaction_one_target`
CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL));
-- =============================================================================
-- 검증 쿼리 (실행 후 확인용)
-- =============================================================================
-- SELECT column_name, column_type, is_nullable FROM information_schema.columns
-- WHERE table_schema = DATABASE() AND table_name IN ('comment','video_reaction')
-- AND column_name IN ('video_id','content_id');
-- SELECT constraint_name FROM information_schema.table_constraints
-- WHERE table_schema = DATABASE() AND constraint_type = 'CHECK'
-- AND table_name IN ('comment','video_reaction');
-- SHOW INDEX FROM credit_transaction WHERE Key_name = 'uq_credit_job';
-- SELECT COUNT(*) FROM information_schema.columns
-- WHERE table_schema = DATABASE() AND table_name = 'user' AND column_name = 'bio';

View File

@ -1,59 +0,0 @@
-- =============================================================================
-- social_upload 병합 DDL (수동 실행 전용)
--
-- `ssul_social_upload` 를 없애고 castad `social_upload` 가 ADO2 영상과 썰박스
-- 콘텐츠의 업로드 기록을 함께 담는다 — comment / video_reaction 병합과 같은 패턴:
-- 대상은 `video_id` 또는 `content_id` 중 **정확히 하나**만 채워지며 CHECK 로 강제.
--
-- 병합의 실익:
-- - dashboard 통계(`app/dashboard/`)가 SocialUpload 만 읽는데 video_id 를 보지
-- 않으므로, 병합하면 썰박스 업로드도 **자동으로 통계에 잡힌다.**
-- - 업로드 서비스·워커·모달을 테이블 분기 없이 재사용한다.
--
-- 전제(2026-07-30 확인): ssul_social_upload 0행, social_upload 1행(video_id 채워져
-- 있어 CHECK 를 이미 만족). 이관할 데이터가 없다.
--
-- 실행 순서: 2026-07-30-ssulbox.sql 의 §1(ssul_content 생성) 이후.
-- 대상: MySQL 8.0.16+ (CHECK 가 실제로 강제되는 버전. 운영은 8.4)
-- =============================================================================
-- 1. content_id 추가 + FK.
-- upload_seq 채번 인덱스 (content_id, social_account_id, upload_seq) 는
-- 선행 컬럼이 content_id 라 FK 인덱스 요건도 겸한다(단독 인덱스 불필요 —
-- video_reaction 때는 유니크가 user_uuid 선행이라 단독이 필요했던 것과 다르다).
ALTER TABLE `social_upload`
ADD COLUMN `content_id` BIGINT NULL
COMMENT '썰박스 콘텐츠 id (ADO2 업로드면 NULL)' AFTER `video_id`,
ADD CONSTRAINT `fk_social_upload_content`
FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) ON DELETE CASCADE,
ADD INDEX `idx_social_upload_content_seq`
(`content_id`, `social_account_id`, `upload_seq`);
-- 2. video_id 를 nullable 로. 기존 행은 전부 ADO2 업로드라 값이 있어 영향 없다.
ALTER TABLE `social_upload`
MODIFY COLUMN `video_id` INT NULL COMMENT 'ADO2 영상 id (썰박스 업로드면 NULL)';
-- 3. "정확히 하나만 채워짐" 강제. 이게 없으면 둘 다 NULL 이거나 둘 다 채워진
-- 행이 조용히 생긴다.
ALTER TABLE `social_upload`
ADD CONSTRAINT `ck_social_upload_one_target`
CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL));
-- 4. 과도기 테이블 제거 (로컬 등 이미 만든 환경만 해당. 운영은 애초에 안 만든다).
-- ⚠️ 행이 남아 있으면 social_upload 로 이관한 뒤에 지울 것.
-- (2026-07-30 확인 시점에는 0행이라 이관이 필요 없었다)
DROP TABLE IF EXISTS `ssul_social_upload`;
-- =============================================================================
-- 검증 쿼리 (실행 후 확인용)
-- =============================================================================
-- SELECT column_name, column_type, is_nullable FROM information_schema.columns
-- WHERE table_schema = DATABASE() AND table_name = 'social_upload'
-- AND column_name IN ('video_id','content_id');
-- SELECT constraint_name FROM information_schema.table_constraints
-- WHERE table_schema = DATABASE() AND table_name = 'social_upload'
-- AND constraint_type = 'CHECK';
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE() AND table_name = 'ssul_social_upload'; -- 0 기대

View File

@ -1,96 +0,0 @@
# `ssul_upload` 프롬프트 (스프레드시트 시트용)
썰박스 콘텐츠의 SNS 업로드 제목·설명·태그를 만드는 프롬프트다.
ADO2 의 `yt_upload` 와 **별도 시트**를 쓴다 — 그쪽은 업장 마케팅 분석 기반 광고
영상 SEO 지만, 썰박스는 병맛 역사 썰툰이라 톤이 완전히 다르다.
## 시트 작성 방법
`PROMPT_SPREADSHEET` 스프레드시트에 **`ssul_upload`** 시트를 만들고:
| 셀 | 값 |
|---|---|
| **B2** | 모델명 (`yt_upload` 과 동일하게 두면 된다. 2026-07-31 기준 `gpt-5.4-mini-2026-03-17`) |
| **B3** | 아래 "프롬프트 본문" 전체 |
## 입력 변수 (3개뿐)
| 변수 | 예시 | 비어 있을 수 있는가 |
|---|---|---|
| `{store_name}` | `골목냉면` | **예** — place URL 을 붙여넣고 크롤링이 실패한 경우 |
| `{region}` | `서울시` | **예** — 위와 동일 |
| `{scenario_name}` | `조선왕` | 아니오 (항상 채워짐) |
## 설계 시 지킨 제약
**1. 메뉴·리뷰 정보가 없다.**
generator 는 네이버 브리핑·리뷰를 크롤링해 풍부한 소재를 갖지만, 이 프롬프트의
입력은 위 3개뿐이다. 그래서 엔진 캡션 형식의 "🔥 대표 메뉴·포인트" 줄은 **뺐다** —
없는 정보를 지어내면 실제 가게와 다른 허위 홍보가 된다.
**2. 길이 제한이 실재한다.**
`SocialUploadRequest.title` 이 `max_length=100` 이라 100자를 넘기면 업로드 요청이
422 로 거부된다. 설명은 5000자, 태그 입력란은 500자다. 프롬프트에 명시했다.
**3. 업장명이 없을 수 있다.**
그 경우 가게 언급 없이 시나리오만으로 쓰도록 지시했다. `'{store_name}'` 처럼
빈 값이 그대로 노출되면 안 된다.
---
## 프롬프트 본문 (B3 에 붙여넣기)
```
너는 "병맛 역사 썰툰" 쇼츠의 SNS 업로드 문구를 쓰는 카피라이터다.
아래 정보로 유튜브 쇼츠 업로드용 제목·설명·해시태그를 만들어라.
[콘텐츠 정보]
- 시나리오: {scenario_name}
- 가게 이름: {store_name}
- 지역: {region}
[시나리오별 톤 가이드]
- 조선왕: 조선 27대 왕들의 실화 썰. 대표 인물 세종·태종·연산군 등. 태그 앞에 #조선왕 #역사썰
- 삼국지: 위·촉·오 영웅들의 야사. 대표 인물 제갈량·조조·관우 등. 태그 앞에 #삼국지 #역사썰
- 그리스·로마 신화: 올림포스 신·영웅들의 전설. 대표 인물 제우스·헤라클레스 등. 태그 앞에 #그리스로마 #신화썰
- 오디세이: 오디세우스 10년 귀향 대모험. 대표 인물 오디세우스·포세이돈 등. 태그 앞에 #오디세이 #오디세우스
[title — 쇼츠 제목]
- **100자 이내** (반드시 지킬 것. 초과하면 업로드가 거부된다)
- 짧고 자극적인 후킹 문구. 예: "세종대왕이 사실 이 집 단골이었다는 썰"
- 해시태그(#)를 넣지 말 것. 태그는 keywords 에만 넣는다.
- 가게 이름이 있으면 자연스럽게 녹여라. 없으면 시나리오만으로 써라.
[description — 업로드 설명]
- 5000자 이내. 이모지를 적극 사용하고 줄바꿈으로 구분한다.
- 형식:
1) 첫 줄: 후킹 카피 한 줄
2) 본문 1~2줄: {scenario_name} 썰과 가게를 잇는 재밌는 소개 (이모지 곁들여)
3) 셀링 한 줄: 그 시대 인물이 반할 법한 식으로 가게를 띄우는 한 줄
4) 📍 가게명 (지역) ← 가게 이름이나 지역을 모르면 이 줄을 통째로 생략
5) 👉 방문 유도 한 줄
6) 마지막 줄: 해시태그 나열 (시나리오 태그를 앞에, #shorts #AIO2O 로 끝)
- **모르는 정보는 절대 지어내지 마라.** 메뉴·영업시간·가격·전화번호처럼
주어지지 않은 정보는 언급하지 않는다. 가게 이름이 비어 있으면 가게 얘기를
빼고 시나리오 콘텐츠 소개로만 채운다.
[keywords — 해시태그용 키워드 배열]
- 8~12개. 각 항목은 **# 없이** 순수 키워드 문자열로 넣어라 (예: "조선왕", "역사썰").
- 구성: 시나리오 태그 2개 → 가게명·지역(있을 때만) → 병맛/역사 관련 → shorts, AIO2O
- 전체를 쉼표로 이었을 때 500자를 넘기지 마라.
```
---
## 확인 방법
시트를 만든 뒤:
```bash
# 앱 재기동 없이도 되지만, 프롬프트는 lru_cache 라 캐시가 남아 있으면 재기동 필요
docker restart castad-app
```
프론트에서 썰박스 콘텐츠의 **업로드 버튼**을 누르면 제목·설명·태그가 자동으로
채워진다. 실패하면 조용히 빈 칸으로 열리므로(사용자 흐름을 막지 않는다),
동작 확인은 백엔드 로그의 `[SEO_SERVICE]` 라인을 본다.

View File

@ -203,6 +203,17 @@ SB_SYSTEM = """\
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
→ 그래야 컷마다 눈 표정이 다양해진다.
- camera: 그 장면에 어울리는 카메라 움직임을 아래 중 하나로 고른다. 장면의 '감정'에 맞춰라.
reveal : 상황 전체가 드러남 (첫 컷 기본)
punch_in : 반전·폭로·핵심 한 방 (강하다. 영상당 1~2번만)
slow_in : 몰입이 쌓이는 서술, 마지막 CTA
slow_out : 상황이 점점 보이는 서술
pan_left / pan_right : 이동·전개·시간 흐름
tilt_up : 올려다봄 — 권위·거대함·감탄
tilt_down : 내려다봄 — 추락·좌절·한심함
shake : 충격·당황·사고 (강하다. 영상당 1번이면 충분)
hold : 정적인 대사·여운. 강한 컷 앞뒤에 넣으면 대비가 산다
★ 같은 값을 3연속으로 쓰지 마라. 강한 효과(punch_in/shake)를 연달아 쓰면 산만해진다.
[전역 설정]
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의. 한국사 인물을 '귀여운 통통한 고양이'로 의인화.
@ -238,8 +249,18 @@ SB_SCHEMA = {
"narration": {"type": "string"},
"caption": {"type": "string"},
"image_prompt": {"type": "string"},
# 값은 media.CAMERA_KINDS 와 반드시 일치해야 한다.
# 모르는 값이 오면 렌더가 조용히 기본 순환으로 떨어진다.
"camera": {
"type": "string",
"enum": [
"reveal", "punch_in", "slow_in", "slow_out",
"pan_left", "pan_right", "tilt_up", "tilt_down",
"shake", "hold",
],
},
},
"required": ["narration", "caption", "image_prompt"],
"required": ["narration", "caption", "image_prompt", "camera"],
},
},
},

View File

@ -575,30 +575,6 @@ _wrap_lines = MU._wrap_lines
mix_bgm = MU.mix_bgm
def _scene_motion(idx: int, n: int, frames: int) -> str:
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
"""
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f = max(frames, 1)
if idx == 1: # 훅: 리빌(살짝 당김→전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
return ("zoompan=z='1.06':"
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
kind = idx % 4
if kind == 0: # 줌아웃(끝에 전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if kind == 1: # 줌인(시작에 전체)
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
@ -619,6 +595,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
tmp.mkdir(exist_ok=True)
segs = []
total = 0.0
# 카메라 순환 시작점 — 콘텐츠마다 다르되 같은 콘텐츠면 항상 같다.
# (media.motion_seed 주석 참조)
cam_seed = MU.motion_seed("".join(str(s.get("narration", "")) for s in scenes))
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
@ -635,7 +614,7 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
band_h = int(H * BAND_RATIO)
img_h = H - band_h
bw, bh = int(W * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
motion = _scene_motion(idx, len(scenes), frames)
motion = MU.scene_motion(sc, idx, len(scenes), frames, cam_seed)
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.

View File

@ -205,6 +205,17 @@ SB_SYSTEM = """\
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
→ 그래야 컷마다 눈 표정이 다양해진다.
- camera: 그 장면에 어울리는 카메라 움직임을 아래 중 하나로 고른다. 장면의 '감정'에 맞춰라.
reveal : 상황 전체가 드러남 (첫 컷 기본)
punch_in : 반전·폭로·핵심 한 방 (강하다. 영상당 1~2번만)
slow_in : 몰입이 쌓이는 서술, 마지막 CTA
slow_out : 상황이 점점 보이는 서술
pan_left / pan_right : 이동·전개·시간 흐름
tilt_up : 올려다봄 — 권위·거대함·감탄
tilt_down : 내려다봄 — 추락·좌절·한심함
shake : 충격·당황·사고 (강하다. 영상당 1번이면 충분)
hold : 정적인 대사·여운. 강한 컷 앞뒤에 넣으면 대비가 산다
★ 같은 값을 3연속으로 쓰지 마라. 강한 효과(punch_in/shake)를 연달아 쓰면 산만해진다.
[전역 설정]
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의.
@ -252,8 +263,18 @@ SB_SCHEMA = {
"narration": {"type": "string"},
"caption": {"type": "string"},
"image_prompt": {"type": "string"},
# 값은 media.CAMERA_KINDS 와 반드시 일치해야 한다.
# 모르는 값이 오면 렌더가 조용히 기본 순환으로 떨어진다.
"camera": {
"type": "string",
"enum": [
"reveal", "punch_in", "slow_in", "slow_out",
"pan_left", "pan_right", "tilt_up", "tilt_down",
"shake", "hold",
],
},
},
"required": ["narration", "caption", "image_prompt"],
"required": ["narration", "caption", "image_prompt", "camera"],
},
},
},

View File

@ -584,30 +584,6 @@ _wrap_lines = MU._wrap_lines
mix_bgm = MU.mix_bgm
def _scene_motion(idx: int, n: int, frames: int) -> str:
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
"""
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f = max(frames, 1)
if idx == 1: # 훅: 리빌(살짝 당김→전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
return ("zoompan=z='1.06':"
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
kind = idx % 4
if kind == 0: # 줌아웃(끝에 전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if kind == 1: # 줌인(시작에 전체)
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
@ -628,6 +604,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
tmp.mkdir(exist_ok=True)
segs = []
total = 0.0
# 카메라 순환 시작점 — 콘텐츠마다 다르되 같은 콘텐츠면 항상 같다.
# (media.motion_seed 주석 참조)
cam_seed = MU.motion_seed("".join(str(s.get("narration", "")) for s in scenes))
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
@ -644,7 +623,7 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
band_h = int(H * BAND_RATIO)
img_h = H - band_h
bw, bh = int(W * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
motion = _scene_motion(idx, len(scenes), frames)
motion = MU.scene_motion(sc, idx, len(scenes), frames, cam_seed)
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.

View File

@ -0,0 +1,19 @@
번호,인물,문서제목,썰후보여부,키워드,URL
1,오디세우스,오디세우스,Y,아가멤논이 아들을 데려갈 거라는 협박 때문에 참전하게 된 영화판 명분,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
2,폴리페모스,폴리페모스,Y,말도 걸지 않고 잠든 사이에 눈을 찔러버리는 영화판 각색,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
3,라이스트리고네스,라이스트리고네스,Y,갑옷을 두른 성인 거인족 병사들로 다시 그려진 영화판 설정,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
4,오디세우스,오디세우스,Y,출발할 땐 12척이었던 함대가 3척으로 줄어든 영화판 설정,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
5,키르케,키르케,Y,오디세우스와 정분 나는 장면이 통째로 삭제된 영화판 각색,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
6,키르케,키르케,Y,헤르메스의 도움 없이 키르케를 상대해야 하는 영화판 설정,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
7,세이렌,세이렌,Y,가장 간절히 바라던 걸 보여준 뒤 후회하게 만드는 심리전으로 재해석된 영화판 세이렌,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
8,스킬라,스킬라,Y,거대한 따개비처럼 바위에 붙어 꽈배기 입으로 선원을 삼키는 영화판 스킬라,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
9,안티노오스,안티노오스,Y,전멸 대신 무릎 꿇고 항복하는 구혼자가 여럿 나오는 영화판 결말,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
10,안티노오스,안티노오스,Y,원작에서 가장 먼저 죽던 것과 달리 영화에선 가장 마지막에 죽는 안티노오스,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
11,오디세우스,오디세우스,Y,등에 창까지 꽂히며 직접 부상당하는 영화판 구혼자 전투,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
12,오디세우스,오디세우스,Y,트로이 문명을 무너뜨리고 무고한 시민들을 죽게 만든 죄책감에 시달리는 영화판 오디세우스,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
13,오디세우스,오디세우스,Y,신들이 직접 등장하지 않고 언급만 되는 영화판 연출,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
14,오디세우스,오디세우스,Y,침략자 아닌 문명 붕괴의 상징으로 재해석된 영화판 바다 민족 설정,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
15,칼립소,칼립소,Y,로터스 나무 꽃을 계속 먹여 곁에 붙잡아 두는 영화판 칼립소,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
16,오디세우스,오디세우스,Y,트로이 목마를 위해 희생됐던 시논을 저승에서 다시 마주치는 영화판 장면,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
17,오디세우스,오디세우스,Y,왕좌를 되찾는 대신 아들 텔레마코스에게 왕관을 물려주는 영화판 결말,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
18,텔레마코스,텔레마코스,Y,아버지 행방을 찾아 트로이에서 돌아온 스파르타 왕 메넬라오스를 직접 찾아가는 영화판 텔레마코스,https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
1 번호 인물 문서제목 썰후보여부 키워드 URL
2 1 오디세우스 오디세우스 Y 아가멤논이 아들을 데려갈 거라는 협박 때문에 참전하게 된 영화판 명분 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
3 2 폴리페모스 폴리페모스 Y 말도 걸지 않고 잠든 사이에 눈을 찔러버리는 영화판 각색 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
4 3 라이스트리고네스 라이스트리고네스 Y 갑옷을 두른 성인 거인족 병사들로 다시 그려진 영화판 설정 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
5 4 오디세우스 오디세우스 Y 출발할 땐 12척이었던 함대가 3척으로 줄어든 영화판 설정 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
6 5 키르케 키르케 Y 오디세우스와 정분 나는 장면이 통째로 삭제된 영화판 각색 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
7 6 키르케 키르케 Y 헤르메스의 도움 없이 키르케를 상대해야 하는 영화판 설정 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
8 7 세이렌 세이렌 Y 가장 간절히 바라던 걸 보여준 뒤 후회하게 만드는 심리전으로 재해석된 영화판 세이렌 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
9 8 스킬라 스킬라 Y 거대한 따개비처럼 바위에 붙어 꽈배기 입으로 선원을 삼키는 영화판 스킬라 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
10 9 안티노오스 안티노오스 Y 전멸 대신 무릎 꿇고 항복하는 구혼자가 여럿 나오는 영화판 결말 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
11 10 안티노오스 안티노오스 Y 원작에서 가장 먼저 죽던 것과 달리 영화에선 가장 마지막에 죽는 안티노오스 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
12 11 오디세우스 오디세우스 Y 등에 창까지 꽂히며 직접 부상당하는 영화판 구혼자 전투 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
13 12 오디세우스 오디세우스 Y 트로이 문명을 무너뜨리고 무고한 시민들을 죽게 만든 죄책감에 시달리는 영화판 오디세우스 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
14 13 오디세우스 오디세우스 Y 신들이 직접 등장하지 않고 언급만 되는 영화판 연출 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
15 14 오디세우스 오디세우스 Y 침략자 아닌 문명 붕괴의 상징으로 재해석된 영화판 바다 민족 설정 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
16 15 칼립소 칼립소 Y 로터스 나무 꽃을 계속 먹여 곁에 붙잡아 두는 영화판 칼립소 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
17 16 오디세우스 오디세우스 Y 트로이 목마를 위해 희생됐던 시논을 저승에서 다시 마주치는 영화판 장면 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
18 17 오디세우스 오디세우스 Y 왕좌를 되찾는 대신 아들 텔레마코스에게 왕관을 물려주는 영화판 결말 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구
19 18 텔레마코스 텔레마코스 Y 아버지 행방을 찾아 트로이에서 돌아온 스파르타 왕 메넬라오스를 직접 찾아가는 영화판 텔레마코스 https://namu.wiki/w/오디세이(영화)/줄거리 및 탐구

View File

@ -0,0 +1,125 @@
1. 발단
영화는 In a time of Apparent Magic이라는 문구와 함께 시작된다. 자막에서는 '마법이 존재하던 시대'라고 간단하게 번역되었지만, Apparent가 '분명한'이라는 뜻과 '~처럼 보이는'이라는 뜻을 둘 다 가지고 있다는 것을 생각하면 "마법이 분명하게 존재하던 시대에" 나 "마법이 존재하던 것처럼 보이던 시대에" 등 여러 방향으로 해석이 가능한 부분이다.
이후 이타카 궁의 연회장에서 음유시인(트래비스 스캇)이 지팡이로 바닥을 치며 시를 읊고 흥을 돋구는 장면으로 첫 시퀀스가 진행된다. 화면이 전환되고 나무로 된 거대한 말 앞에서 이타카 병사 시논(엘리엇 페이지)이 트로이 병사가 던진 창에 맞으며 제압된다. 그리고는 흰 갑옷을 입은[1] 트로이 병사가 시논의 목에 칼을 겨누고 이 목마가 무엇이냐 물어보고, 이것은 아테나에게 바치는 제물이라 답하자 한 병사가 무시하며 그냥 죽이려고 했지만 다른 트로이 병사가 제지하며 아테나는 양국 모두 섬기는 신이니 가져가 제물로 삼겠다며 목마를 자신들의 성으로 가져가게 한다.
음유시인은 이타카의 왕 오디세우스의 트로이 전쟁 영웅담에 대해 노래하나 중문 너머에서 이를 듣던 페넬로페는 그 노래는 듣기 싫다며 중단시킨다. 한편 안티노오스(로버트 패틴슨)을 비롯한 구혼자들은 트로이 전쟁이 끝났다는 소식 후 8년이 지났음에도 아직 어디에 있는지 알려지지 않은 오디세우스 왕의 왕비 페넬로페(앤 해서웨이)와 결혼하여 권력과 부를 얻을 생각을 하고 있었다.
현 시점에서 조금 전, 오디세우스의 아들 텔레마코스(톰 홀랜드)는 오디세우스의 심복이었으나 눈이 멀어 현재는 돼지를 치고 있는 에우마이오스, 그리고 부관 멘토르에게 검술을 지도받던 도중 오디세우스의 사냥개 '아르고스' [2]에 대한 이야기를 듣는다. 아르고스는 오디세우스가 이타카를 떠나기 전 사냥개로 키우던 개[3]였고 무려 20년 동안[4] 오디세우스를 기다리던 충직한 사냥개였다.[5]
오디세우스는 사냥을 나가면 활을 쏘기 전 반드시 활 시위를 한번 튕겨 소리를 내 사냥감에게 알리는 버릇이 있었는데, 왜 굳이 사냥감에게 그걸 알려주냐는 부하들의 물음에 공정한 싸움이어야 한다는 생각 때문에 경고를 주는 것이라고 이야기한다. 당시 사냥에 함께 나가던 에우마이오스는 공정한 것이 좋지만 그런 정직함이 언젠간 발목을 잡을 것이라고 경고하고 그 말처럼 사냥감이던 멧돼지가 화살 한 발로 죽지 않고 오디세우스에게 달려드는 바람에 오른발목 피부가 찢어지는 상처를 입는다.[6]
그 후 텔레마코스는 어머니 페넬로페와도 대화를 나눈다. 후계가 있어야 한다는 원로들의 압박에 대한 얘기에 텔레마코스는 자신이 왕위를 이으면 된다고 하지만 페넬로페는 넌 아직 어리다며, 오디세우스가 남기고 간 활을 주고 시위를 걸어보라 한다. 텔레마코스는 시도해보지만 실패하고 활의 시위를 거는 것은 오직 오디세우스만 가능하기 때문에 불공평하지 않냐고 묻는다. 페넬로페는 계속 왕위를 텔레마코스에게 물려주는 걸 거절하자 텔레마코스는 왕좌가 탐나는거냐며 비난한다. 물론 페넬로페는 왕좌를 탐낸 게 아니고 오직 오디세우스만 기다린 것이었지만, 출정 이후 20년이란 세월이 흐르는 동안 오디세우스는 감감무소식인데다 원로에 구혼자들까지 압박이 들어오자 하나뿐인 아들과도 점차 갈등이 잦아지기 시작한 것.
한편 오디세우스가 이타카를 떠나기 전의 회상이 나온다. 오디세우스는 연회에서 활에 시위를 걸고 특유의 사냥 전 사냥감에게 미리 경고를 하는 활 시위를 당겨 소리를 내는 퍼포먼스를 보여준 후 12개의 교차로 세워진 도끼 사이로[7] 화살을 날려 보내는 묘기를 보여주는 걸 즐겼다.[8] 밤이 되고 잠자리에 들기 전 페넬로페는 왜 전쟁을 하는거냐고 묻는데 오디세우스는 트로이 전쟁에서 헬레네의 납치는 명목상의 이유일뿐, 사실은 아가멤논이 노리는 것은 트로이의 무역항로이며, 또한 자신이 참전하지 않는다면 아들을 데려갈 것이고 아가멤논은 심지어 전쟁에서 승리하기 위해 자신의 딸[9]조차 제물[10]을 삼을 정도로 피도 눈물도 없이 승리만 생각하는 인물이라고 말한다. 이에 페넬로페가 전쟁에 참여하지 않고 서쪽 멀리 도망치면 안 되냐고 하자 그러면 아가멤논의 성격상 이타카를 상대로 군사를 일으킬 것이므로 그럴 수는 없다고 얘기하며 만약 자신이 돌아오지 않는다면 이타카를 위해 다른 사람과 결혼해 달라는 부탁을 한다. 페넬로페는 이를 눈물 흘리며 거절하고 반드시 돌아와 달라는 부탁과 함께 아테나 여신을 본뜬 덩굴 장식의 금 브로치를 건네주며 누구나 알아볼 수 있게 항상 차고 다녀 달라고 한다. 또한 오디세우스는 함께 트로이 전쟁에 출전할 군대를 추첨으로 뽑게 했는데, 작중 초반 죽은 시논은 사실 군대 추첨에서 면제를 뽑아 군 면제 대상이었으나 징집 표를 뽑은 안티노오스가 시논의 면제 표를 뺏고 자신의 징집 표를 바꿔치기 했고 풍족하지 못한 시논의 가족을 지원해 주는 대신 군대에 가달라는 부탁을 하며 추첨 표적을 바꿔치려는 것을 오디세우스의 묵인 아래[11][12] 시논은 트로이의 전장으로 향하게 된다.
그날 밤 여느 때처럼 구혼자들 때문에 궁정은 연회를 열고 있었다. 나이가 든 사냥개 아르고스가 구혼자들이 모여 있는 식탁 옆에 누워있는 것을 구혼자들은 발로 툭툭 건드리기 시작했고 이를 본 에우마이오스는 제발 개를 그냥 두라며 간청한다, 그러자 안하무인이던 구혼자들과 안티노오스는 에우마이오스를 괴롭히기 시작한다.[13] 이를 두고 볼 수 없던 텔레마코스가 개입하나 안티노오스의 친우인 폴리부스에게 제압당하고 역으로 그를 정당하게 공격할 구실을 제공하고 만다. 이때 페넬로페가 나서 자신의 아들을 그냥 두라며 호소하고 그녀의 눈치를 살핀 안티노오스의 중재로 폴리부스와 구혼자들은 물러선다. 이후 자존심이 훼손당하고 인내심이 한계에 달한 텔레마코스는 일전에 한 걸인으로부터 스파르타에 가면 오디세우스에 대한 정보를 얻을 수 있을 거라는 조언을 듣고 메넬라오스(존 번탈)가 있는 스파르타로 향할 계획을 세운다. 다만 이타카를 떠나버리면 가는 길에 수많은 구혼자들이 아무 명분 없이 텔레마코스를 제거하기 좋기에 비밀리에 준비를 치른 후 몰래 출발한다.
그 즈음 어떤 섬에서 깨어난 오디세우스(맷 데이먼)는 자신에게 돌아가야 할 집이 있음을 기억해내고 같이 생활하던 칼립소와 이야기하며 과거를 회상한다.
2.2 트로이 전쟁에서 (텔레마코스 시점)
텔레마코스는 자신의 아버지 오디세우스의 행방을 찾아 트로이에서 돌아온 스파르타의 왕 메넬라오스를 찾아간다. 사전에 아무 연락 없이 찾아간 것이기에 어떻게 만날까 막막했으나 의외로 마치 올 것을 알고 있었다는 듯 손님을 직접 보고 싶다며 불려가게 된다. 마침 메넬라오스의 딸의 결혼 축하 연회라[14] 잘 왔다고 반갑게 맞이해준 메넬라오스는 텔레마코스를 자신의 옆자리에 앉히고 이야기를 들려주기 시작한다.
메넬라오스는 트로이 전쟁은 트로이가 자신의 부인 헬레네를 납치해서 일어난 전쟁이라고 얘기하나 헬레네는 이에 당신의 형의 탐욕 때문이라고 꼬집으며 반박한다. 헬레네의 얼굴 반쪽은 흉터로 가득하고, [15][16] 트로이 전쟁에 참여했던 대다수의 인물이 귀향하는데 어려움을 겪었고 자신도 겨우 스파르타로 돌아올 수 있었다고 한다.
메넬라오스는 아이아스, 파트로클로스, 아킬레우스 같은 영웅도 있었지만[17] 자신이 가장 다시 보고 싶은 인물은 지략이 가장 뛰어났던 오디세우스라며 텔레마코스에게 트로이 목마에 대해 아주 자세히 알려준다. 오디세우스의 아이디어로 고안한 트로이 목마는 아테나에게 바치는 공물로 속여 트로이 목마 안에 정예 부대가 숨어있다가 상황이 정리 된 후 정문을 열어 아카이아 연합군을 돌격시키는 작전이었다. 메넬라오스도 트로이 목마 안에 있었으며 목마가 바다에 반쯤 잠겨 있어서 밑 부분에 있던 일부 병사는 익사하고[18] 그리스 놈들의 공물은 불태우라며 분노하는 트로이 시민들 때문에 겁을 먹은 일부 병사가 탈출을 하려고 하는 등 여러 소란이 있었으나[19] 작전은 성공해 성문을 열고 트로이의 성 안에 연합군을 돌격시키는데 성공한다. 이 이야기를 들은 텔레마코스는 시인들이 말하는 영웅담이 아닌 참전 장수에게 처음으로 직접 아버지의 활약상을 듣고는 눈물을 흘린다.
한편 옆자리에 앉은 헬레네는 메넬라오스에게 당신의 형이 귀향한 후 자신의 쌍둥이 자매인 클리타임네스트라에게 어떤 꼴을 당했는지도 말하라고 재촉하지만 메넬라오스는 지금 텔레마코스가 알고 싶은 것은 이게 아닐 거라며 대답을 회피한다.
이후 함께 사냥을 하던 중 아가멤논에 대해 이야기를 해준다. 아가멤논은 친딸 이피게네이아를 전쟁을 위한 제물로 바쳐 부인인 클리타임네스트라의 원한을 샀고, 이후 클리타임네스트라는 아가멤논이 자리를 비운 사이 불륜 상대인 아이기스토스와 공모해 아가멤논을 살해했다고 한다. 이후 아가멤논과 클리타임네스트라의 아들인 오레스테스에게 클리타임네스트라와 아이기스토스는 살해당했고 이를 들은 텔레마코스는 자신의 어머니를 죽인 거냐며 놀란다.
메넬라오스는 오레스테스는 그저 아버지의 복수를 한 거라며 이야기하곤 이타카의 왕좌가 너무 오래 비어 있었고 제우스의 율법을 모르는 바다 민족의 위험이 다가오고 있으니 그리스 전역의 군대를 이끌 왕이 필요할 거라 조언한다.
한편 이타카. 베틀에 앉아 있는 페넬로페에게 안티노오스는 당신이 시아버지의 수의 완성을 핑계 삼아 재혼을 미루고 있지만, 사실은 매일 밤 다시 풀고 있지 않냐고 추궁한다. 안티노오스는 오디세우스가 자신에게 본인이 돌아오지 않는다면 이타카를 보살펴 달라 부탁했다며 페넬로페를 유혹하나 텔레마코스에 대해 "그가 다음 왕이다. 만약 우리가 아이를 갖지 않는 한..."이란 실언을 하는 바람에 차가운 냉대를 받고 만다.[20][21] 또한 안티노오스는 시녀 중 한 명인 멜란토(미아 고스)와 내통 관계였는데 수의를 풀고 있던 것 역시 멜란토로부터 들은 것이었다. 하지만 성 안에 몰래 숨어있었던 에우마이오스가 이 둘의 내통 관계를 눈치채게 된다. 멜란토에게 텔레마코스가 사실은 스파르타로 떠난 것을 듣게 되며 친우 폴리부스와 그를 죽일 계획을 세우게 된다.[22]
이후 메넬라오스는 텔레마코스에게 방문 기념 선물을 주고 인사를 끝으로 퇴장한다. 헬레네는 텔레마코스가 떠나기 전 자신의 이름으로 일어난 전쟁에 대해 사과하고 어머니에게도 사과를 전해달라고 한다.[23]
2.2. 트로이 전쟁 이후 (오디세우스와 칼립소 시점)
기억을 잃은 오디세우스는 여신 칼립소의 도움으로 기억을 되짚으며 자신에게 무슨 일이 있었는지 기억을 돌아보기 시작한다.
트로이 전쟁에서 목마 안에 타있던 그리스군은 시논의 희생을 통해 무사히 궁 안에 진입하여 대승을 거둔다. 전쟁에서 승리한 그리스군은 각각 고향으로 돌아가기 시작했다. 오디세우스는 잘 알려진 항로로 가는 다른 그리스군을 보며 앞서 가고 있는 아가멤논의 배와 달리 우리는 다른 항로로 가자고 제안한다. 이 항로가 오히려 빠를 것이며 가는 길에 다른 섬들도 둘러 보고 가자고 덧붙였다. 이는 아마도 아가멤논에게 완전히 질려버려 더는 그의 뒤를 따르고 싶지 않았기 때문으로 보인다. 오디세우스는 섬 마을에 들러 구걸하기도 하고, 부탁하기도 하고, 약탈을 하기도 하며[24][25][26] 고향으로 가는 여정을 시작한다.
전쟁 이후 배를 타고 식량을 얻기 위해 한 섬에 도착한 오디세우스의 부관 한 명이 양 한 마리가 지나가는 것을 발견하고 바로 죽여서 식량으로 쓰려고 하지만 양은 무리지어 다닌다는 오디세우스의 만류로 양을 바로 죽이지 않고 양을 쫒아가기로 하고 양을 쫓아 이동하다가 한 동굴 안으로 들어가게 된다. 동굴 안에는 상태가 괜찮은 식량[27]이 많이 있어서 허락을 구하고[28] 먹으려던 찰나 양들이 우르르 동굴 안으로 몰려들어오는데, 수상함을 느낄 새도 없이 거대한 외눈박이 거인이 마지막으로 들어와 돌문으로 동굴 입구를 닫아 암흑 천지를 만들더니, 이내 어둠 속에서 튀어나오며 병사들을 잡아먹는다.[29] 병사를 잡아먹자마자 아무렇지도 않게 태연히 바로 누우며 눈을 감고 잠을 자는데 기회라 생각하고, 폴리페무스를 죽이기 위해 병사들이 칼을 뽑고 다가가지만 생각해보니 여기서 거인을 죽이면 동굴을 막고 있는 커다란 돌을 치우지 못해 영원히 갇힌다는 것을 깨닫게 되어 포기한다. 결국 며칠 기회를 보다가[30] 어느날 밤 이들이 폴리페무스 몰래 돌문에 끼워서 고정해 둘까 의논했던 거대 나무창을 모닥불에 달군 뒤, 잠든 폴리페무스를 공격해[31] 눈을 뜨게 한 후 곧장 찔러넣어서 실명시킨다.[32] 이 때 처음으로 말을 하는데, 눈이 찔린 고통으로 비명을 지르며[33] 자신의 아버지인 포세이돈에게 저주를 내려달라고 한다.[34] 앞이 보이지 않게 된 폴리페무스가 다음날 아침 동굴 밖으로 양들만 내보내려고 동굴의 입구에 앉아 손으로 양의 등을 만져 오디세우스 일행이 빠져나가는 것을 막으려 하자 등에 지푸라기를 얹어 양털과 비슷한 촉감을 내어 거인을 속이고 빠져나간다.
부하들이 완전히 빠져나가고 마지막으로 오디세우스도 얌전히 탈출하였는데 그 순간 무언가 왠지 석연찮음을 느끼고 동굴 밖에 얼굴을 내민 폴리페무스가 잡아먹혔던 부하의 투구 잔해를 퉤 하고 뱉어낸다.[35] 그것을 보고 희생당한 부하들이 생각난 오디세우스는 순간 분노하여 활을 겨눈다. 하나밖에 없는 눈을 공격당해 시력을 잃은 거인으로부터 조용히 탈출하기 위한 직전이었기에 에우릴로코스(히마시 파텔)는 쏘지 말라고 만류하지만 오디세우스는 기어코 활 시위를 놓으며 거인의 먼 눈을 재차 맞추어 응징하고, 이 공격으로 인해 자신들이 동굴 밖으로 빠져나왔다는 확신을 줘버린 데다가 도리어 폴리페무스를 크게 자극해버린 탓에 졸지에 고통에 광분하여 언덕을 뛰어내려오는 폴리페무스에게서 빠르게 도망쳐야 하는 상황이 되어버린다. 결국 이 과정에서 늦게 도착해 미처 배를 타지 못한 병사 둘이 잡아먹혀버리고, 도망치는 데 성공한 오디세우스와 병사들은 배에 타서 다른 섬으로 이동한다.[36]
다른 섬에 도착하자마자 쪼그려 앉은 한 사람이 보여 병사들이 음식을 받을 수 있냐고 물어보는데, 그 아이는 거인족 어린아이였다. 거인족은 처음에는 오디세우스 일행을 보며 그냥 웃기만 하지만[37] 에우릴로코스가 뭐가 웃기냐며 위협을 하자 거인족 아이는 갑자기 정색을 하고는 큰 소리를 지르고 도망가며 갑옷을 두른 성인 거인족 병사들을 불러 모은다.[38] 이들은 오디세우스 일행의 2-3 배쯤 되는 거인족인데 일반 병사들은 그냥 툭 쳐도 저 멀리 날려 보낼 정도의 무시무시한 괴력을 가졌다. 그냥 거인도 싸우기 버거운데 청동 갑주를 능가하는 풀 플레이트 아머를 두른 거인족을 당연히 이길 수는 없었고 심지어 어떤 마법인지 함정인지 오디세우스의 병사들이 무아지경으로 도망치자 움직이는 나무로 퇴로를 차단하는 덫에 갇혀 거인들의 손에 죽게 되는 모습도 나온다. 물론 오디세우스 및 일부 군인들은 항전해보려고 했으나 결과적으로 체급 차이는 중과부적이었다.[39] 결국 남은 병사들은 나무가 없는 해안가까지 간신히 도착해 배가 있는 바다로 도망치나 속도가 만만치 않았던 거인들은 3대의 배 중 2대를 양쪽에서 괴롭히듯 좌우로 흔들어대거나 무기로 작살내가며 기어이 침몰시키고 생존자들을 철저하게 학살한다. 이때 오디세우스는 병사들을 지키고자 활을 쏘아 일부 라이스트뤼고네스들을 죽이는 데 성공하기는 하지만 결국엔 겨우 후퇴한다. 결국 오디세우스가 탔던 배 1대분의 병사들만 남은 채 간신히 섬을 탈출한다.
겨우 목숨을 부지한 병사들은 또 다른 섬으로 이동하는 것으로 예상했지만, 오디세우스는 이제 식량이 다 떨어졌기 때문에 더 이상 멀리 갈 수 없으니 해안선을 따라 돌아가야 한다고 명령한다. 결국 명령대로 해안선을 따라가다 보니 해안선 위에 연기가 나는 곳을 발견하고, 그곳에 식량이 있을 곳이라는 생각에 이 섬에 정박하기로 한다. 오디세우스는 병사들에게 한 명은 남아서 배를 지키고 나머지는 자신을 따라 집으로 가자고 제안하지만, 에우릴로코스는 한 명만 남는 것은 동의하나 그것은 오디세우스여야 하며 이것은 본인 혼자만의 생각이 아니라 병사들 모두 만장일치로 동의한 의견이라 말한다.[40] 이는 폴리페무스의 저주를 들은 병사들이 두 번에 걸쳐 괴물을 만난 상태였기에 오디세우스가 포세이돈의 저주에 걸렸다고 생각했기 때문이었다. 오디세우스는 반란이라도 일으키는 거냐며 황당해하고, 부하들은 '그것은 당신에게 달렸다'고 답하며 긴장감을 형성하지만, 이내 오디세우스는 활을 달라고 하며, 능청스럽게 그렇다면 내가 책임지고 먹을 식량이라도 마련해 놓겠다며 다녀오라고 이야기하여 험악한 분위기가 그나마 약간 누그러진다.[41] 집으로 올라가는 길에 사자, 표범, 호랑이 등 맹수들이 한 마리씩 있었는데[42] 사람을 잡아먹어도 이상하지 않을 맹수들은 그저 멀뚱히 애처로운 눈빛을 던지며 쳐다보기만 한다. 한편 혼자 떨어져 있다가 중도 합류할 생각이던 오디세우스는 노루 1마리를 활로 쏴 사냥하는데[43], 사냥을 무사히 마치고 노루 사체가 떨어진 구덩이에 가보니 노루는 사라지고 화살에 맞아 죽은 사람이 있었다. 사실 그 섬은 사람을 동물로 바꾸는 주술을 쓰는 마녀 키르케가 사는 섬이였던 것이다. 그렇게 집을 찾아간 병사들에게 키르케는 식사를 차려주는데, 아니나 다를까 밥을 맛있게 먹던 병사들은 그녀의 주술로 인해 돼지로 변해 버렸다.[44] 이윽고 부하들을 찾아 나선 오디세우스도 키르케의 집에 들어와 주술에 걸리는 음식을 먹을 뻔하지만,[45] 부하가 남긴 반지가 음식 그릇에 있는 것을 발견하며 그녀의 술수를 눈치챈다. 오디세우스는 새로 변한 키르케의 누이를 인질 삼아 그녀와 대치하고, 강간하고 약탈하러 온 탐욕스러운 놈들의 본모습을 드러냈을 뿐이라고 비난하는 키르케에게[46] 자신의 병사들은 그저 집에 가고 싶은 것뿐이라며 주술을 해제해 달라 한다. 오디세우스의 말처럼 병사들은 키르케에게 손 하나 대지 않았고, 무기는 집 밖에 내려놓아 달라는 요구도 전부 군말 없이 수용한 것은 사실이기에 결국 키르케가 주술을 해제하여 병사들은 다시 인간으로 돌아오고[47][48] 키르케는 고향에 돌아갈 길을 찾고자 조언을 구하는 오디세우스에게 고향에 돌아갈 길을 찾으려면 저승의 길목에서 예언자 테이레시아스에게 조언을 구해야 한다고 말해준다. 또한 이때 키르케는 오디세우스만이 살아서 고향으로 돌아갈 운명이라 알려주며, 부하들에게는 이를 말하지 말라 당부하며 조언한다.
키르케의 섬을 탈출한 오디세우스의 병사들은 멀리 북쪽에 있는 망자들의 해변에 가서[49] 돌아갈 해답을 얻기 위해 양 두 마리를 제물로 바쳐 저승의 망자들을 이 세상에 소환한다.[50] 그곳에서 처음 트로이 목마를 바치기 위해 희생해야 했던 시논을 다시 보게 된다. 오디세우스는 시논이 임무에 자원하며 자신의 아버지에게 자신이 용감했다고 전해달라며, 더 많은 활약을 했다고 부풀려도 좋다며 너스레를 떨던 모습을 회상한다. 그는 오디세우스에게 자신을 묻어준 것은 고맙지만 왜 자신을 믿지 못하고 목마 안에 군사를 숨긴 걸 자신에게도 숨겼냐고 원망한다. 오디세우스는 그렇게 하지 않았다면 적들은 속지 않았을 거라며 미안해한다. 오디세우스에게 죽음 앞에서 비밀은 없다는 대답을 들은 그는 자신이 저승(하데스)에 온 이후에 자신의 아버지를 만났지만, 아버지는 고향인 이타카에서 굶어 죽은 지 오래였고 그 이유는 안티노오스가 자신이 대신 징병당하면 가족을 돌봐준다는 약속을 깼기 때문임을 말해준다. 시논은 안티노오스에 대한 복수와 서쪽의 미지의 땅으로 가서 미처 묻히지 못한 병사들의 명복을 빌어줄 것을 부탁한다. 그 다음으로 오디세우스에게 말을 건 사람은 전혀 예상하지 못했던 먼저 귀향했던 아가멤논. 아가멤논은 자신은 성대히 귀향했다가 전쟁 출발 전 딸을 신에게 바친 것을 원망한 아내인 클리타임네스트라에게 죽었다며[51] 고향에 돌아간다면 환대 받지 못할 수도 있으니 변장해서 절대로 정문이 아니라 신분을 숨기고 천천히 상황을 살피라고 조언한다. 이야기를 마친 후 예언자는 오디세우스에게 "첫 번째로 세이렌이 있는 곳을 지날 때 그들의 노래를 들어선 안 된다. 이후 카리브디스와 스킬라 중 하나를 선택해야 하는데, 카리브디스는 모든 병사들을 죽일 것이고, 스킬라는 6명만 죽일 것이다. 마지막으로, 헬리오스의 섬에서 태양신의 소를 잡아먹으면 죽을 것이다."라는 예언을 하고, 병사들에게는 말하지 말라고 전한다. 끝에는 오딧세우스만 살아 귀향하는 것이 신들이 정한 운명이라는 테이레시아스의 예언에, 오디세우스는 그렇다면 자신은 신들과 맞서겠다면서 운명을 바꾸고 어떻게든 부하들을 살리려는 의지를 내보인다. 하지만 테이레시아스는 부하들 스스로가 자기들의 죽음을 자초하게 될거라는 말로 답할 뿐.
이후 묻히지 못한 수많은 망자들이 오디세우스와 산 병사들을 원망하며 '자신들의 불명예를 나눠가져라'라며 앞에 모여 있던 오디세우스 일행과 그나마 대화가 가능했던 묻힌 망자들을 밀쳐내고 오디세우스와 병사들을 뒤쫓아오다가, 이내 해변에서 물로 더는 쫓아오지 않고 멈춰서서 떠나는 그들을 원망스럽게 바라본다.[52] 묻히지 못한 모든 망령들을 뒤로 하고, 다시 바다로 나온 오디세우스와 병사들은 세이렌을 마주친다.[53] 오디세우스는 다른 병사들은 밀랍으로 귀를 막아 그 노래를 못 듣게 하지만, 자신은 노래를 듣고 최초로 살아남은 사람이 될 거라며 기둥에 자신을 묶어달라고 한다. 병사들 중 안키알루스(윌 윤 리)[54]가 호기심을 이기지 못하고 밀랍을 떼고 노래에 홀려 병사들의 만류를 뿌리치고 바다에 뛰어들어 세이렌들 쪽으로 헤엄쳐 가며 사라지고, 오디세우스가 굉장히 괴로워 했다는 것 이외에는 모두 무사히 지나간다.[55][56]
이후 오디세우스의 배는 거대한 소용돌이 카리브디스와 바다 괴물 스킬라가 있는 길목에 들어선다. 오디세우스는 일부러 카리브디스가 있는 소용돌이 쪽으로 가 급류를 역으로 추진력으로 삼아 순식간에 섬 뒤로 가자고 제안했고[57] 예상대로 반발한 병사들이 스스로 방향을 꺾어 스킬라가 있는 협곡으로 진입한다. 스킬라의 협곡은 생각보다 안전한 듯 보였으나, 갑자기 협곡 틈 사이에서 스킬라의 기괴한 여섯 아가리가 튀어 나오더니 병사 6명을 낚아채고는 까마득히 높이 있는 바위굴 속으로 순식간에 사라져 버린다.
스킬라에게서 벗어난 후 에우릴로코스가 너무나 담담한 오디세우스의 태도와 표정을 발견하고는 "당신은 이 모든 일들이 일어날 것을 이미 알고 있지 않았냐"며 거세게 항의하지만, 오디세우스는 6명 아니면 모두가 죽는 것이었다고 말한다. 이후 섬이 보이자 지친 병사들은 상륙하려 한다. 오디세우스는 저 섬은 태양신의 섬이라며 이들을 말렸지만, 다들 지쳐 있고 배도 수리할 겸 정박을 요청하자 섬의 소를 절대로 먹으면 안된다는 오디세우스의 말에 물하고 열매만 먹으면 되지 않는거냐며 반드시 그렇게 하겠다고 약속한다. 결국 오디세우스는 그 말은 무조건 지키라고 말하며 섬에 정박을 지시한다.
처음에는 병사들도 소를 먹어서는 안 된다는 것을 지키며[58] 지냈지만, 이윽고 식량이 떨어지고 바람이 역풍으로 불어 오랜 시간 동안 배를 띄울 수 없게 되자[59], 병사들은 참지 못하고 오디세우스 몰래 소를 잡아먹고야 만다.[60] 밤중 몰래 소를 잡아 고기를 게걸스레 뜯고 있는 병사들을 발견한 오디세우스는 자신이 보지 않는다고 신도 모르는 줄 아냐며 어이없어하고, 병사들은 굶어죽을 바엔 저주받아 죽겠다며 오히려 화를 낸다.[61] 그 직후 바람이 멎게 되었고, 이에 병사들은 서둘러 배를 띄워 섬을 빠져나간다. 그러나 이내 거친 폭풍을 만나 배가 반파되며[62] 병사들은 모조리 죽고 오디세우스는 표류하게 된다.[63] 오디세우스는 의식을 잃은 채 표류하다가 칼립소가 있는 섬에 도착하고, 칼립소에게 구출된다.
하지만 오디세우스에게 반한 칼립소는 그가 떠나지 않도록 오디세우스에게 로터스 나무의 꽃을 계속 먹였다. 이는 치료의 효과도 있지만 기억을 잃게 만들기에, 오디세우스는 기억을 잃은 채 칼립소와 7년 가까이 시간을 보내게 된다. 그러나 기억을 잃었음에도 오디세우스의 마음 한 켠에는 부인 페넬로페에 대한 생각이 계속 있었고, 아무것도 기억하지 못함에도 해변에 떠밀려 온 나무를 모아가며 뗏목을 만들려고 했다. 이런 모습을 본 칼립소는 오디세우스를 묶어둘 수 없다는 것을 깨닫고 오디세우스에게 모든 진실을 알려주며, 오디세우스가 기억을 잃고 몸도 성치 않을 때도 절대 놓지 않은 것이 있었다고 이야기한다. 그것은 바로 페넬로페가 건네준 금 브로치. 칼립소는 모든 기억이 돌아온 오디세우스에게 그는 평생 운명을 통제하려 들며 분투했지만, 이 일은 그럴 수 있는 것이 아니라며 포세이돈이 내리는 벌에 몸을 맡기라고 조언한다. 바다에 몸을 던져도 제우스가 지켜줘 죽음은 피할 수 있을 것이라고 칼립소가 알려준 후, 마침내 완전히 기억을 되찾은 오디세우스는 뗏목을 만들어 바다로 떠난 후 폭풍 속에서 표류한다.
3. 절정
뗏목에서 표류해 해안가로 떠내려온 오디세우스가 정신을 차리자 옆에는 아테나가 앉아있었다. 여기가 어디냐고 묻는 오디세우스에게 아테나는 자신의 고향도 알아보지 못하는 거냐며 이곳은 오디세우스의 고향 이타카임을 알린다.
이후 아가멤논의 조언을 기억해 스스로 걸인으로 위장하고 에우마이오스를 만나러 간다. 직후 자신의 충직한 부하였던 에우마이오스의 뒷모습을 보고 반갑게 말을 걸지만 에우마이오스는 피투성이가 된 상태였다. 스파르타에서 돌아오는 텔레마코스를 죽이기 위해 안티노오스의 친우 폴리부스의 부관이 통솔하는 도적들이 안티노오스의 사주를 받고 그의 집 개들과 두 아들을 죽인 뒤였던 것이다. 에우마이오스는 오디세우스도 돌아오지 않고 텔레마코스도 죽을 위기라며 오열한다. 오디세우스가 그를 진정시키며 오디세우스를 정말 사랑한 거 같다고 묻자 에우마이오스는 오디세우스를 자신의 친아들처럼 사랑했다고 말한다. 이를 들은 오디세우스는 여전히 정체를 숨긴 채 자신은 왕의 사자이자 함께 트로이 전쟁에 참여한 병사 "시논"이며, 오디세우스가 오는 중이라고 말한다. 에우마이오스는 텔레마코스가 습격당할 것이며 이미 도적들이 텔레마코스에게 갔다고 알려준다.
오디세우스는 그 길로 신전으로 향한다. 폴리부스의 부관을 필두로 한 도적들은 텔레마코스의 경호원을 모두 암살한 다음 신전의 사제들로 위장해 독을 먹여 텔레마코스와 그의 검술 스승 멘토르를 죽이려고 한다. 그러나 텔레마코스는 부관의 사제복에 핏자국이 있는 걸 보고 뭔가 의심이 들어 건네준 포도주를 마시지 않고 들고만 있었다. 텔레마코스를 따라온 오디세우스가[64] 텔레마코스에게 위험을 경고하자, 멘토르는 여긴 신전인데 어떻게 그런 일이 있을 수 있겠냐며 의문을 표한다. 그러나 직후 이미 독약을 탄 포도주를 마셔버린 멘토르는 허망히 사망한다. 오디세우스와 텔레마코스는 폴리부스의 부관과 도적들을 죽이고[65] 신전에 불을 질러 빠져나오는데 성공한다.[66][67] 그리고 텔레마코스에게 여전히 신분을 숨기며 자신은 시논이고 오디세우스는 살아있으며 그가 곧 돌아올 것이라 말한다. 이후 행패를 부리는 구혼자들을 응징해야 하니 페넬로페에게 재혼하라고 말할 것을 설득한다.
이후 오디세우스의 조언대로 메넬라오스가 선물로 준 화려한 예복을 차려입은 채 당당히 성에 귀환한 텔레마코스는 페넬로페에게 재혼할 남자를 고르라고 설득한다. 재혼할 남자가 정해진다면 그 외에 다른 구혼자들이 흩어질 것이고, 메넬라오스가 돕기로 약속했으니 그 자만 없애면 자신이 왕으로 등극할 수 있다고 했으나 페넬로페는 그런 말에 자신이 왕이 없는 20년간 이 자리를 지켜왔는데 다른 이들보다 자신이 더 정치를 못하리란 법이 있냐, 겨우 수염난 애송이인 네가 뭘 아냐고 분노한다. 또한 자신은 밖에 있는 구혼자 따위와 결혼하고 싶은 생각은 없으며 오히려 모두 불타 죽여버렸으면 한다고 답한다. 이를 들은 텔레마코스가 평화를 원하는지 복수를 원하는지 묻자 페넬로페는 격앙된 목소리로 ‘난 오디세우스를 원한다‘며 일갈하고 자리를 박차고 나가버린다.[68]
거지 차림의 오디세우스는 구혼자들이 있는 연회장에 도착한다. 입구에서 그가 과거에 직접 골라 강아지 때부터 기른 사냥개인 아르고스를 마주하고, 주인을 알아본 아르고스는 최후의 힘을 다해 꼬리를 조금 흔든 뒤 [69]그 자리에서 숨을 거둔다. 오디세우스는 조용히, 아르고스를 언덕 위에 묻어달라 부탁한다. 옆에서 이를 목격한 텔레마코스는 오디세우스와 아르고스가 처음 만났던 장소가 언덕 위라는 걸 떠올리고는, 시논의 정체를 알아채고 감격하지만 이내 함구한다.[70]
연회장에서 여전히 걸인으로 돌아다니다 안티노오스에게 시논의 징병표를 줘 당황하게 하고 이내 구혼자들에게 몰매를 맞지만[71] 텔레마코스가 격분하여 연회 종료와 축객령을 선언하며 상황이 강제로 마무리된다. 그리고 결국 오디세우스는 연회가 끝난 이후까지 남아서 결국 페넬로페와 대화를 한다. 페넬로페는 늙은 시녀인 에우리클레이아에게 오디세우스의 발을 씻겨주라고 한다. 발을 씻겨주기 위해 오디세우스의 신발을 벗기는데 에우리클레이아는 오디세우스의 오른발목에서 피부가 찢어진 흉터를 보고는 걸인이 오디세우스임을 알아차린다. 그 흉터는 오디세우스가 출정하기 전 멧돼지를 사냥하다가 생긴 흉터이기에 알아본 것이다. 다만 오디세우스는 고개를 저으며 정체를 알리지 말라 부탁했고, 에우리클레이아는 고개를 끄덕이며 그 정체를 함구한다.
페넬로페는 걸인이 정말 오디세우스와 함께 트로이 전쟁에서 싸운 사람인지 확인하기 위해 전쟁 출발 전 건네준 장식품에 대해 물어보았고, 당연히 오디세우스는 정확히 묘사하며 의심을 풀게 된다. 이후 오디세우스는 끝까지 정체를 숨기고 페넬로페에게 자신이 왜 이렇게 귀향에 오래 걸리는지 그 이유를 전한다.
오디세우스가 전한 진실은 바로 그가 만든 트로이 목마에 의해 온 도시가 불타는 모습을 보며 하나의 문명을 없앴다는 큰 죄책감을 느꼈고, 그 폐허를 마주한 이후로는 집으로 돌아갈 용기가 없어졌다는 말이었다. 특히 전쟁을 끝내기 위해 본인이 직접 고안한 트로이 목마 작전은 제우스의 규율을 정면으로 깨버리는, 즉 같은 신을 믿는 두 문명 사이를 잇는 최소한의 이성과 규율을 배반한 행위였다는 것을 깨달은 것이다.[72][73] 결과적으로 이 전쟁 때문에 모든 나라가 제우스의 율법을 지키기 어렵게 되었고, 결국 오디세우스는 자신이 문명 그 자체를 무너뜨렸다는 죄책감에 빠져 스스로 그 업보를 치르려 한 것이다.
거기에 자그마치 10년이라는 기간동안 집과 가족을 떠나 전쟁을 치르며 쌓인 분노를 풀기 위해, 병사들은 무기를 든 군인 뿐만 아니라 민가를 습격해 시민들을 학살하고 도시에 불을 지르며 파괴 행각을 일삼았고, 심지어는 자신들과 트로이인이 같이 숭배하는 아테나 여신상의 목까지 쳐버리는 막장 행위가 벌어지게 된 것이다. 이때 계속해서 환영으로 나타나 오디세우스에게 말을 걸던 아테나(젠데이아)의 정체도 밝혀지는데, 오디세우스가 봤던 아테나는 사실 트로이 함락 당시 무고하게 참수당한 아테나의 여사제였다.[74][75] 당시의 시대상을 생각하면 전투 중의 방화와 민가 습격까지는 흔히 일어나던 일이었으나, 종교적 신념을 적극적으로 이용한 기만 작전 뿐만 아니라 신전까지 공격한 것이 문제였다. 당시에 신전은 신들의 보호를 받는 장소라는 걸 모두가 알고 있었고, 전쟁 중이라도 신전으로 대피한 민간인들은 신들의 보호를 받는다 여겨 공격하지 않는 것이 불문율이었다. 하지만 10년 동안의 원정으로 피폐해진 병사들은 그 불문율조차 어겨가며 신전까지 쳐들어갔다.[76]
결국 도시를 정복한 이후에도 이러한 광기는 끝나지 않아 다음날까지 잔혹 행위가 이어졌고,[77] 오디세우스는 침공 중에 도시의 건물이 무너질 때마다 자신의 집이 무너져내리는 것을 보는 듯 전쟁의 참상과 파괴, 폐허를 온몸으로 겪으며 정신적으로 크게 무너져 내려버린 것이다. 그래서 성벽 안의 트로이군이 덤벼들어도 몇 명 쓰러트리지 못하고 트로이 병사가 눈앞에서 칼을 휘둘러도 제대로 피하지 못해 부관이 오디세우스를 구해주고 영문을 모르고 오디세우스를 쳐다보기도 한다.[78]
오디세우스가 말해준 트로이의 참상을 들은 페넬로페는 그에게 “당신이 바다에서 온 사람들(The People from the Sea)인가요?”라고 묻고, 오디세우스는 이를 긍정한다.[79] 하지만 페넬로페는 자기 남편을 눈앞에 둔 상황에서 '자신은 그 폐허 속에서 너무 오랫동안 살아왔으니 이제는 오디세우스가 돌아와야 할 때'라고 말한다.
긴 대화 직후 페넬로페는 남편을 새로 찾기로 결정했다며 다음날 그것을 위해 시험을 할 것이라 선언한다. 그리고 한 명을 정했을 때 연회장에 무기가 있다면 곧장 전투가 일어날 거라며 무기를 모두 2층에 숨긴다.[80] 그리고 새벽에 구혼자들을 불러모아 새 남편이 될 이를 찾기 위한 시험이 열린다. 그 시험은 전쟁 전 오디세우스가 자주 하던 그의 활 시위를 장궁에 걸고, 화살을 발사해 도끼 12개를 한 번에 통과하는 시험이었다. 대부분의 구혼자들은 활 시위를 거는 것에도 낑낑거리며 실패한다. 안티노오스는 어렸을 때 그 활에 시위를 걸 수 있는 것은 이타카에 오디세우스밖에 없다는 것을 이미 봤기 때문에 다른 구혼자들이 이 활에 시위를 거는 것이 가능하냐고 짜증을 낼 때마다 가능한 사람이 있다며 핀잔만 주다, 결국 본인 차례가 되자 본인도 할 수 없다는 것을 알고 있으니 우리 모두가 실패했고 오디세우스의 자리를 완벽하게 대체할 수 없는건 알지만 그렇다 해도 우리 중 한명을 선택해야 한다고 말하며 임기응변으로 곤란한 상황을 능구렁이처럼 넘어가려 든다. 그때 걸인으로 위장하고 있던 오디세우스는 자신의 차례가 남았다고 말한다. 안티노오스는 이를 비웃으며 누가 이 걸인 좀 쫒아내라고 하지만 텔레마코스가 당신이 하지 않는다면 그 기회를 저 걸인에게 주겠다고 말하며 오디세우스에게도 시험의 기회가 주어진다. 오디세우스는 활을 조정하는가 싶더니 거뜬히 활에 시위를 거는데 성공하고,[81] 늘 하던 대로 시위를 튕겨 경고음을 낸 뒤[82] 도끼 12개 사이로 화살을 발사해 과녁에 명중시킨다.
활을 쏘기 전 내는 경고음, 그리고 특징적인 활 소리와 함께 걸인의 정체가 오디세우스라는 사실을 그 자리에 있던 모두가 알게 된다.[83]
직후 제일 먼저 오디세우스를 알아본 안티노오스의 친우 폴리부스가 목에 그의 화살을 맞아 절명하는 것을 시작으로, 무기도 없는 구혼자들을 향한 오디세우스의 일방적인 학살이 시작된다. [84] 그동안 권위를 내세워 구혼자들의 대장질을 했던 안티노오스는 정작 사태가 벌어지니 혼비백산이 되어 가장 먼저 기둥 뒤에 숨으면서 멜란티오스에게 빨리 윗층에 있는 무기들을 가지고 오라고 닦달하고, 멜란티오스는 서둘러 2층의 무기를 보관한 창고로 달려가지만 미리 간파한 에우마이오스에 의해 역으로 창고에 갇히게 된다. [85] 역시 그간 스파이 노릇을 하며 왕실의 정보를 안티노오스에 팔아넘긴 멜란토 역시 자신이 무사하지 못할 것을 깨닫고 중문으로 도망갔으나, 이미 멜란토의 배신을 알고 있던 페넬로페는 "네 자리를 지켜야지"라며 싸늘한 표정과 함께 문을 닫아버린다.[86]
오디세우스가 꽂혀있던 도끼들까지 집어들고 구혼자들을 도륙하던 그때, 멜란티오스는 나무로 된 바닥을 무기로 내리쳐 부숴 구멍을 내고 그 틈새로 무기를 떨어뜨리기 시작한다. 이를 발견한 텔레마코스가 서둘러 멜란티오스를 막으러 뛰어올라간다. 겨우 무기를 든 구혼자들은 오디세우스의 상대가 되지 못하고 화살을 꽂는 등의 부상 정도밖에 입히지 못하지만, 멜란티오스가 방패와 창까지 떨어뜨리기 시작하자 오디세우스도 위기에 봉착한다. 그 와중에 안티노오스는 오디세우스가 위기에 빠져 있을 때도 겁을 먹어 나서지 못하고 다른 구혼자들에게 검과 창을 들고 싸우라며 닦달한다. 한편 텔레마코스는 건장한 멜란티오스에게 체급에서 밀려 고전을 면치 못하지만, 배를 일부러 노출해 공격을 유도해 내고 빈틈을 노려[87] 멜란티오스의 목을 베어내며 승리한다.
등에 창까지 꽂힌 오디세우스는 중과부적으로 화살을 하나 들고 휘두르며 구혼자들의 창에 포위당하는 위기까지 맞게 되지만, 텔레마코스가 내려준 검을 받더니 그 검으로 덤벼오는 구혼자들을 처치한다. 게다가 텔레마코스가 오디세우스에게 검을 내려주며 "아버지!"라고 외쳤기 때문에 오디세우스의 신분이 확실히 드러나자[88] 여러 구혼자들이 전의를 상실하고 복종의 의미로 무릎을 꿇기까지 한다. 오디세우스는 이미 대세가 기운 와중에 마지막으로 발악하던 안티노오스에게 덤벼들어 제압한 후, 저승에 있는 시논에게 자신이 서쪽으로 가서 죽은 전우들을 기릴 것이란 말을 전하라고 하고, 또한 시논에게도 사과하라면서 입에 시논의 징병표를 쑤셔넣은 뒤 배에 칼을 찔러 넣어 죽이며 그의 장대한 귀향을 끝낸다.
4. 결말
마침내 복수를 마친 오디세우스에게 무릎을 꿇은 텔레마코스와 구혼자들, 에우마이오스 등의 하인들은 검을 바닥에 연신 부딪히며 경의를 표한다. 그러나 오디세우스 역시 전투 중 부상을 입은 터였기에 이내 쓰러져 페넬로페에게 안긴 다음, 아테나 형상의 머리핀을 보여준다. 페넬로페는 오디세우스를 알아보고 죽으면 안 된다며 오열하지만, 오디세우스는 "죽는 게 아니라 떠나는 거요(Not dying, exile)"[90]라며 안심시킨 뒤 그녀의 품속에서 미소를 짓는다.[91][92]
세월이 흘러 텔레마코스는 이타카의 새로운 왕이 되었고 오디세우스는 아내 페넬로페와 함께 이타카를 떠나 행복한 여생을 보내게 된다. 오디세우스는 그동안 묻히지 못하고 죽은 부하들의 넋과 혼을 기리기 위해 아내와 서쪽으로 떠난다.[93] 오디세우스는 이것을 스스로 제우스의 규칙을 어긴 자신에게 내리는 유배형이라고 하는데, 그 많은 고생 속에서도 신에 대한 존중을 버리지 않았던 원작의 오디세우스다운 선택이다.[94] 오디세우스는 페넬로페에게 "우리 문명에 대한 이야기는 훗날엔 그저 노래로만 남게 될 것이오. 그럼 우리의 죄도 다시 한 번 잊혀지겠지."라 고백한다.[95] 그러다 이후 회상 장면으로, 영화가 트로이 목마를 비추며 막을 올린 것처럼 트로이 전쟁 때 오디세우스가 불타는 목마를 바라보던 모습으로 영화는 막을 내린다.
- 원작과 차이점
오디세우스는 처음에는 전쟁에 끌려가지 않기 위해서 미친 척 행세하였으나, 영화에서는 그런 모습은 보여지지 않는다.[98] 다만 오디세우스는 페넬로페에게 참전하지 않으면 아가멤논이 자신의 아들을 데려갈 것이라고 말하고, 페넬로페는 함께 서쪽 멀리 도망치면 안되냐고 묻자 자신들이 도망가면 아가멤논의 성격 상 이타카를 불바다로 만들 것이라며 어쩔 수 없이 참전한다. 병역에 끌려가기 싫어 쟁기를 끌고 소금을 뿌리는 장면이 나오지 않으면서 팔라메데스의 원한관계도 사라져 트로이 전쟁때 팔라메데스를 죽일 필요도 없었고 팔라메데스 부친 나우폴로리스 2세가 오디세우스가 죽었다고 거짓 소문을 퍼트리지 않은걸로 보인다.[99]
원전에서는 폴리페무스의 섬을 탈출한 이후 바람의 신 아이올로스의 섬에 도착하여 아이올로스로부터 오디세우스와 그의 부하들이 고향 이타카로 돌아가는데 방해가 되는 바람을 전부 자루에 넣어서 이타카로 순항하게끔 도와주나, 이타카에 거의 다 도착했을 무렵 부하들이 바람이 들은 자루를 오디세우스가 독차지하려는 보물로 착각하여 열어버리는 바람에 순식간에 모든 바람들이 자루에서 빠져나오면서 생긴 돌풍으로 이타카에서 다시 멀어지는 내용이 있으나, 영화상에서는 아이올로스부터 고향 이타카에 거의 다 도착했으나 돌풍에 휩쓸려 이타카로부터 다시 멀어지는 내용 전부 스킵되었다.
트로이 전쟁의 다른 영웅들은 아가멤논과 메넬라오스 이외에는 말로만 등장한다. 특히 트로이 전쟁에서 오디세우스와 쌍벽을 이루는 주연급이라고 할 수 있는 아킬레우스는 말로 딱 한번 언급되는 것이 다이며,[100] 신화에서는 보통 디오메데스와 가장 가까운 사이었던 오디세우스가 메넬라오스의 존경을 받는 것으로 약간 각색되었다. 마찬가지로 영화상에서 저승의 길목에 방문하여 죽은 아킬레우스, 파트로클로스, 대 아이아스, 심지어는 자신의 돌아가신 어머니 안티클레이아의 망령까지 만나지만 전부 다 생략되었으며[101], 대신 앞서 말한 장군들은 텔레마코스가 메넬라오스를 찾아갔을 때 메넬라오스의 대사를 통해 이름으로만 언급된다.
원작에서는 외눈 거인인 폴리페무스가 처음부터 말을 할 수 있으며 이들과 대화를 하던 것으로 나온다. 이에 오디세우스가 자신을 "우티스(아무도 아니)"라고 소개하고 포도로 만든 포도주를 잔뜩 먹여 과음으로 잠들게 한뒤 불에 달군 통나무로 외눈을 찔러 장님으로 만드는 장면이 나오지만, 영화에서는 거인이 이들에게 말을 걸지 않고, 오디세우스가 거인의 눈을 잠든 사이 그냥 찌른다. 또한 원작과 달리 폴리페무스가 유일한 키클롭스로 나오기에 친구인 다른 동족들을 불러 "내 눈을 찌른건 '아무도 아니'다"라고 도와달라는 장면도 삭제되었다.[102] 이 때문에 오디세우스가 도망치는 과정에서 자신의 진짜 이름을 밝혀 도발했다가 되려 포세이돈에게 노여움을 산 이야기를 도망치는 과정에서 폴리페무스의 눈에 화살을 쐈다가[103] 폴리페무스를 자극해버린 탓에 애먼 부하들이 죽어나간 것으로 각색하였다.[104]
원작에서 폴리페무스는 갈라테아[105]라는 바다의 여신[106]에게 사랑에 빠져 구애를 수차례 시도하지만 갈라테아는 눈길 하나 주지 않고, 아키스라는 이름의 청년과 사랑에 빠진다. 단순히 여기서 그치지 않고 두 커플이 자신을 조롱하기까지 하자, 이에 질투심과 분노에 사로잡힌 폴리페무스는 아키스를 돌팔매질로 죽이고 그 일 이후로 사람을 잡아먹기 시작했다. 이 사건이 얼마 지나지 않아 오디세우스 일행이 타이밍 나쁘게 찾아오지만, 이 내용은 영화에서 생략되었다.
또한 아래의 헤르메스의 도움이 삭제된 부분도 그렇지만 전반적으로 그리스의 신들이 직접적으로 개입하는 장면이 삭제되었다. 비교적 격이 낮으며 오디세우스와 직접적으로 교류하는 칼립소와 키르케는 사람의 모습을 한 채로 나오지만, 이들이 다른 신들과 교류하는 장면은 없으며, 소박한 차림과 생활을 하며 여신들이라기보단 신묘한 능력과 지식을 가진 마녀에 가깝게 묘사된다. 본작에서는 천둥과 폭풍 같은 식으로 간접적으로 묘사되는데 신화속 괴물과 마녀, 여신이 직접 나오는것도 그렇고 신들이 존재하지 않는 건 아니라는 해석이 주류다.[107] 다만 직접적 묘사 대신 간접적인 표현을 사용한 것으로 보인다. 심지어 직접적으로 행동한다고 할 수 있는 아테나도 본인의 본모습이라기 보다는 오디세우스 눈 앞에서 죽은 어린 여사제의 모습을 빌려 나타났다는 점에서 주신들이 직접 모습을 드러낸 것은 없다고 봐야할 것.
원작에서는 오디세우스와 부하들이 소 떼가 있는 섬에 상륙하고 그 섬에 사는 소 떼의 주인은 헬리오스이지만, 영화에서는 헬리오스의 인지도 때문인지 소들의 주인이 '태양신(Sun God)'이라고만 나온다. 또한 원전에서 오디세우스의 부하들이 소를 잡아먹고 헬리오스가 제우스에게 벌을 달라고 요청하자, 죽은 소의 가죽이 살아 있는 것처럼 기어다니며 울음소리를 내고 꼬챙이에 꿰어져 있는 쇠고기가 꿈틀거리는 에피소드는 생략되었다.
원작에서는 라이스트뤼고네스들이 바르바로이를 연상시키는, 문명과 거리가 먼 식인 생활을 하는 거인으로 나오지만 영화에서는 그리스군보다 진보한 철갑옷과 망토를 갖춰 입은 선진 문명의 모습으로 등장한다. 거인들은 압도적인 피지컬로 등장하는데 대략 2m 중반에서 후반 정도의 크기로 보이지만 신화속 괴물의 느낌보단 조금 더 인간 종족 느낌이 나기도 한다.[108]
원작에선 트로이에서 이타카로 출항하는 배가 12척이지만 여기선 3척으로 묘사된다. 따라서 라이스트뤼고네스들에게 배 12척 중 11척이 전멸당하는 에피소드도 영화에서는 3척 중 2척이 당한다.
원작에서는 오디세우스가 헤르메스의 도움으로 마녀 키르케의 지팡이에 닿아도 동물이 되지 않는 약초를 먹고 찾아가 그녀에게 칼을 들고 목에 겨눠서 제압한다. 하지만 영화에서는 헤르메스의 도움도 존재하지 않으며, 오디세우스는 키르케의 자매인 까마귀의 목을 잡고 키르케를 잡고 협박해서 위험을 벗어난다. 까마귀의 존재 또한 영화 한정 등장이다. 키르케는 님프들을 거느리고 있었지만 가족과 같이 있지 않았고, 자매라고 해봤자 원전에서 등장하는 인물은 파시파에 정도가 다인데 둘은 신화에서도 자매라는 점 외에는 접점이 없다. 또한 원전에서 키르케는 특출난 마법 실력과 아름다운 외모를 지닌 여신으로 묘사되며 해당 사건 이후 오디세우스와 정분이 나 오디세우스는 1년 정도 키르케와 같이 산다. 하지만 영화에서는 미녀라기보다는 차라리 중세 민담의 늙은 마녀처럼 묘사되며, 서로 눈이 맞는 일도 없다. 사건이 마무리되자 오디세우스는 식량만 보충하고[109] 키르케의 조언을 받은 뒤, 즉시 섬을 떠난다.
키르케와 정분이 나는 장면이 삭제됨에 따라 오디세이아 이후 이어지는 텔레고네이아에 대한 이야기도 없는게 되었다. 이에 결말에는 아들인 텔레마코스가 왕위를 이어받아 이타카의 새로운 왕이 되고 오디세우스와 페넬로페는 바다 너머 미지의 땅으로 떠나는 결말로 끝난다.[110]
세이렌들이 오디세우스 일행을 노래로 유혹하지 못하자 자살하는 장면이 영화에서는 나오지 않는다. 다만 오디세이아에도 나오지 않으므로 원작과의 차이점이라고 할 수는 없다. 세이렌의 자살은 오디세이아가 아니라 다른 작품에 나온다.
신화에서 스킬라는 본래 님프이며 그녀를 사랑하는 글라우코스에 대한 키르케의 질투로 인해 괴물이 되어버린 여인이다. 특히 뱀이나 개의 머리가 여럿 달린 괴수 혹은 님프 시절의 원형이 남아있는 반인반수 형상의 요괴인데, 본작에서는 보다 현실적인 생물의 외형으로 재해석되었다. 거대한 따개비와 유사한 형태로 바위에 붙어살며 꽈배기처럼 꼬인 여러 개의 입들을 길게 늘려 선원들을 포식하며, 외피도 자연적 위장처럼 주변의 바위들과 유사한 질감과 색상이다.
신화에서 아가멤논은 목욕하다가 아이기스토스의 도끼에 맞아죽지만 영화에서는 침대에 누웠다가 아내인 클뤼타임네스트라의 단검에 목을 찔려 죽는다. 원전에서는 이때 카산드라와 자식들도 죽임을 당하지만, 영화에는 카산드라와 자식들의 존재 자체가 등장하지도 않고 언급되지도 않는다. 아이기스토스도 클뤼타임네스트라의 불륜 상대 정도라는 대사 정도로만 언급되며 이후에 복수하는 아들인 오레스테스 역시 언급만 된다.
오디세우스가 칼립소와 헤어진 후 이타카 북쪽의 섬나라인 파이아키아에서 나우시카 공주를 만나는 부분과 이노[111], 메넬라오스가 에이도테아와 만나는 등 부분이 영화에서는 생략되었다.
이타카로 돌아온 오디세우스가 원작에서는 구혼자들을 죽일 때 거의 압도적으로 학살하지만 현실적인 연출을 좋아하는 놀란 감독의 성향답게, 영화에서는 활도 맞고 다굴도 당하는 등 조금 어렵게 싸우는 식으로 긴장감 있게 각색되었다. 아무리 무력이 좋은 사람이라 하더라도 다굴 앞에는 장사 없는 것이 맞으니 이것이 보다 현실적이다. 또한 원작에서는 구혼자들을 다 죽여버리지만 영화에서는 많은 구혼자들이 불리해지자 전투를 포기하고 항복해 복종을 맹세하며, 원작에서 가장 먼저 화살에 맞아 죽었던 안티노오스는 영화에선 가장 마지막에 죽는다. 배신한 하인과 하녀들을 전부 처형하는 장면[112]이나 이후에 구혼자들의 유가족이 일으킨 반란을 진압하는 장면[113]도 영화에서는 하객들을 죽이면 그 가족들의 복수가 이어질거란 언급만 나오고 생략되었다.[114]
원작에선 전혀 없지만 영화에서는 바다 민족과 미케네 문명의 붕괴에 대한 경고가 여러 차례 나온다. 오디세우스는 인간들의 배신과 문명국 트로이의 멸망을 시작으로 암흑시대가 올 것을 예견한다. 실제로 일리아드와 오디세이아는 그리스 청동기 문명이 멸망한 수백년의 암흑기 후에 철기 도시국가 폴리스 시대에 호메로스가 구전되던 설화를 정리하여서 오늘날까지 전해지고 있다. 영화에서의 오디세우스도 자신들의 문명이 노래로만 전해지고 문명이 언젠가 다시 세워지더라도 자신들의 어리석음은 잊힐 것이라 언급한다. 미묘해진 것은 그리스 문명이 망할 것을 예견하고도 텔레마코스를 이타카에 두고 떠났단 점인데 영화가 텔레마코스에 대해 긍정적이고 결말 역시 희망적인 분위기로 끝난걸 보면 이후 텔레마코스는 이타카를 잘 이끄는 왕이 될거란 해석이 가능하다.[115]
원작과 달리 오디세우스는 자신의 속임수로 위대한 문명국가인 트로이를 멸망시키고 무고한 시민들이 처참히 살해당하게 만든 것에 죄책감을 느끼고 있다. 어찌 보면 원전과의 가장 큰 차이점이라 볼 수 있으며, 스포일러로 볼 수도 있다. 가끔씩 나타나는 아테나 여신이 트로이 함락 당시 살해당했던 여사제의 모습으로 나타나는 것도 신전의 여사제를 죽게 내버려 둔 것에서 죄의식을 받았기 때문이다.[116]
사실 원전에 따르면 본작의 해석과는 달리 트로이 목마 역시 제우스의 법도 위반이라고 보기 어렵다. 애당초 크세니아는 손님에 대한 환대를 목적으로 한 법이지 적에 대한 것이 아니며 무사도나 기사도 정신마냥 전쟁은 정정당당하게 해야 한다는 식의 도덕이 아니기 때문이다. 오히려 크세니아를 먼저 어긴 것은 스파르타에서 환대를 받아놓고도 헬레네와 간통하여 그녀를 트로이아로 데려간 파리스이며[117], 트로이아인들 중 그 누구도 아카이아 연합군을 자신들의 손님이라고 생각하지 않았기에 트로이 전쟁은 크세니아를 적용할 만한 일이 아니고 그 과정에서 오디세우스가 어떤 전략을 쓰든 이는 정당한[118] 전쟁 수행 과정의 일부라고 보는 편이 타당하다. 원전에서도 트로이아의 함락은 엄연히 오디세우스의 전공이자 영예로운 위업으로 인식된다. 만약 고대 그리스의 관점에서도 오디세우스의 트로이 목마가 크세니아 위반이었다면, 원전에서 아테나를 위시한 신들이 오디세우스를 그렇게까지 비호해줄 수도 없었을 것이고 그가 이타카로 귀환해 복수를 완수한 이후에도 제우스가 크세니아 위반을 명목으로 구혼자들의 유가족들에게 벼락을 떨어뜨리며 엄포를 놓는 일 역시 없었을 것이다. 그리고 상술하였지만 크세니아를 먼저 어긴 것은 아프로디테의 유혹에 넘어가 메넬라오스의 환대를 제대로 배신하고 10년 전쟁의 도화선을 당긴 파리스고, 따라서 제우스의 법도를 위반한 탓에 세상에 악이 풀려나고 도덕이 무너지며 나아가 그리스 세계 전체가 멸망의 길로 들어선다는 본작의 설정에 따르면 그 원흉 역시 오디세우스가 아닌 파리스여야 한다.[119] 그러나 본작의 전개와 PTSD에 시달리는 군인이라는 본작 오디세우스의 캐릭터성을 생각해보았을 때, 놀란 감독이 이러한 점을 몰랐을 리는 없고 일부러 독자적인 해석을 가미해 의도적으로 원전을 비틀었다고 보는 것이 타당하다. 특히 본작에서는 아테나를 제외한[120] 올림포스의 신들이 직접 화신체를 갖추어 등장하는 경우가 거의 없으며, 트로이 전쟁의 개전 자체도 원전처럼 명분론적 정의구현이 아닌 아가멤논의 정치적 목적 달성이라는 것으로 각색된 데다가 구혼자 유가족들의 반란도 묘사되지 않는다는 점에서 더욱 그렇다. 또한 오디세우스와 그의 계략을 비열하다고 보는 것 역시 단테의 신곡 등 고전 작품들에서도 흔히 드러나는 또 하나의 관점이다. 결론적으로 오디세우스는 당시의 시대상과 창작자들의 관점에 따라 뛰어난 지략을 갖춘 영웅이 되기도, 비열한 술책으로 도시를 무너뜨린 인간이 되기도 했던 것이며 놀란 감독은 자신이 세운 주제를 관객들에게 효과적으로 피력하기 위한 목적으로 후자를 선택한 것으로 볼 수 있다. 그리고 그리스 로마 신화와 오디세이아를 접한 대부분의 관객들은 오디세우스를 군신 아테나조차 인정한 뛰어난 지략가로 인식하고 있으므로, 오디세우스의 캐릭터성에 변주를 준 놀란 감독의 이러한 선택은 성공한 클리셰 비틀기라고도 할 수 있을 것이다.
트로이아 성채로 목마를 들이도록 앞잡이 역할을 한 시논은 <오디세이아>에선 등장하지 않고 트로이아 전쟁 이후 아이네아스의 시선에서 벌어지는 이야기를 다룬, 베르길리우스가 집필한 <아이네이스>에 등장하는 인물이다.
원작에서는 트로이인들이 세워져 있는 목마를 끌고 성 안에 데려오지만 영화에서는 목마의 형태가 앞다리를 들고 뒷다리로 선 말의 형태라서 눕혀진 목마를 끌고 온 뒤 지렛대로 세운다. 또한, 좁은 목마 안에 무기도 없이 밀물과 씨름하면서 다닥다닥 붙어서 작전의 성공만을 기다리는 그리스 별동대를 현실감 있게 묘사했다. 목마 내부에서 배설물을 처리할 방법이 없어 더러운 상태로 있는가 하면, 밀물을 고려하지 못해 안에서 손도 못 쓰고 익사를 당한다는 충격적이면서도 신선한 발상이 나타난 부분도 원작에선 묘사된 바가 없었다.
바다를 건너온 자의 이야기인 텔레고네이아가 사실은 바다 민족으로 각색되는가 했지만 사실은 얼마든지 도리를 어기면서 문명 하나를 통째로 멸망시킬 수 있다는 확신을 퍼뜨린 오디세우스 본인으로 각색되었다.[121] 바다 민족도 머나먼 세상에서 찾아오는 침략자들이 아니라 트로이 목마로 퍼져나가는 도덕의 해이가 불러올 인류의 윤리적 쇠퇴가 문명의 붕괴로 이어질 것임을 암시한다. 역사적으로도 나름 최신 학설을 반영한 것으로 볼 수 있는데, 해양 민족의 정체와 암흑 시대의 도래에 대해서는 여러 가설이 있지만 현대 들어서는 사실 바다 민족에 의해 암흑시대가 도래한 것이 아니라 폭력성이 극에 달한 미케네 문명 자신들이 멸망을 불러온 것이며 멸망한 미케네인들이 지중해를 건너 이집트, 레반트, 히타이트를 쳐들어간 것이 바로 바다 민족의 정체라고 결론이 나고 있기 때문이다. 중간에 마을 노인이 해적이 찾아온 줄 알았다며 이타카군을 오해한 것 또한 이러한 맥락으로, 바다 민족은 늘 있는 해적일 뿐, 멸망의 주체는 문명인들 자신이라는 뜻이 된다. 극중 대사로 보면 초반에는 다른 지역 사람들이 여러 차례 오디세우스 일행을 보고 바다 민족이냐고 묻고 오디세우스 일행은 이를 부정하지만, 결국 오디세우스는 후반에는 이를 인정한다. 실제 역사상으로도 미케네 멸망 후 암흑시대 중 레반트 지역에 자리잡은 바다 민족에는 아나톨리아나 이탈리아 근방에서 온 집단 외에 그리스계도 많았다.
신화를 다루고 있는 만큼, 여러 신화적 존재들과 괴물들이 등장하기는 하나 정작 올림포스 12신들이 명확하게 묘사되는 원작과 달리 본 작품에서는 등장인물들 사이에서 언급만 될 뿐 존재 자체가 일절 등장하지 않는다.[122] 물론 하위 신격인 존재들, 칼립소나 키르케같은 인물들이 등장하긴 하나 원작에 비하면 각색된 면이 강하고 주신들이 서로 회의를 하거나 인간들에게 도움을 주는 등 직접적인 교류가 묘사된걸 비교하면 그 차이가 명확하게 드러난다.
오디세이아의 이야기의 주제를 놓고 따진다면 현대적인 각색이 들어갔음에도 의외로 원작의 주제를 잘 따르고 있는 편이다. 오디세이아가 모험담을 그리고 있기는 하지만 동시에 긴 전쟁으로 고향과 단절되었던 남자가 얼마나 스스로를 쉽게 포기할 수 있는지[123], 그리고 그렇게 자신을 포기한 남자가 여정의 끝에서도 자신이 누군지를 기억할 수 있는지를 보여주었다. 물론 당시에는 없던 PTSD라는 개념이 잘 정립된 21세기에 만들어진 만큼 오디세우스의 혼란의 중심이 신비와 미지보다는 전쟁에 대한 트라우마가 더 강조되는 차이점은 있다. 원작에서도 오디세우스가 트로이 전쟁 당시의 참상에 고통스러워하는 장면이 있지만, 그것이 본 영화만큼 오디세우스의 심리의 중심이 되지는 않는다. 이렇듯 오디세우스 개인의 심리가 집중적으로 묘사되는 한편 오디세우스가 일으킨 행보가 세상에 퍼지면서 윤리가 붕괴되고 문명이 멸망하는 폭력성으로 번지게 될 미래를 직시한다는 점에서 놀란 감독의 전작 오펜하이머의 그리스 로마 신화 판이라는 분석과 비평도 제시되고 있다.[124]
마지막에 오디세우스가 일행을 이끌고 서쪽으로 떠나는 것은 로마 제국의 공식 신화인 아이네이스에서 아이네이아스가 트로이 전쟁의 생존자들과 함께 서쪽의 이탈리아 반도로 이주하는 것과 유사하다. 바다 민족 셰르덴의 사례를 보면 그리스 지역에서 서쪽 이탈리아 근방으로 이주한 사람들도 존재한 것으로 여겨진다.

View File

@ -143,6 +143,10 @@ NARR_SYSTEM = """\
칼립소의 섬, 스킬라와 카립디스, 페넬로페의 수의 짜기, 활쏘기 시합과 구혼자 응징 등)을 드립 소재로 적극 활용해도 된다.
포세이돈의 분노, 아테나의 편애 같은 유명 밈은 써도 되지만 선정적으로 묘사하진 마라.
단, 시청자가 '진짜 이야기'와 '드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지).
- 2026년 영화화(각색판)에서 원작과 달라진 지점(예: 트로이 목마를 신의 법도를 어긴 죄로 재해석, 칼립소와의
동거 삭제, 왕위를 아들에게 넘기고 다시 떠나는 결말 등)도 "요즘은 영화로도 나왔는데 각색이 다르다더라" 식
드립으로 언급해도 된다. 단, 실제 배우·감독 등 실존 인물 이름은 절대 언급하지 마라 — 오직 각색 내용(줄거리
차이)만 다뤄라.
- 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선.
[구조] (반드시 이 4단)
@ -243,6 +247,17 @@ SB_SYSTEM = """\
폭력을 직접 묘사하지 말고 만화적으로 순화해 적어라 — 유혈/찌르는 순간/시체 금지.
(예: 눈 찌르기 → the cyclops comically covering his eye with cartoon stars around his head,
학살 → suitors comically fleeing in panic with exaggerated cartoon expressions)
- camera: 그 장면에 어울리는 카메라 움직임을 아래 중 하나로 고른다. 장면의 '감정'에 맞춰라.
reveal : 상황 전체가 드러남 (첫 컷 기본)
punch_in : 반전·폭로·핵심 한 방 (강하다. 영상당 1~2번만)
slow_in : 몰입이 쌓이는 서술, 마지막 CTA
slow_out : 상황이 점점 보이는 서술
pan_left / pan_right : 이동·전개·시간 흐름
tilt_up : 올려다봄 — 권위·거대함·감탄
tilt_down : 내려다봄 — 추락·좌절·한심함
shake : 충격·당황·사고 (강하다. 영상당 1번이면 충분)
hold : 정적인 대사·여운. 강한 컷 앞뒤에 넣으면 대비가 산다
★ 같은 값을 3연속으로 쓰지 마라. 강한 효과(punch_in/shake)를 연달아 쓰면 산만해진다.
[전역 설정]
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의.
@ -290,8 +305,18 @@ SB_SCHEMA = {
"narration": {"type": "string"},
"caption": {"type": "string"},
"image_prompt": {"type": "string"},
# 값은 media.CAMERA_KINDS 와 반드시 일치해야 한다.
# 모르는 값이 오면 렌더가 조용히 기본 순환으로 떨어진다.
"camera": {
"type": "string",
"enum": [
"reveal", "punch_in", "slow_in", "slow_out",
"pan_left", "pan_right", "tilt_up", "tilt_down",
"shake", "hold",
],
},
},
"required": ["narration", "caption", "image_prompt"],
"required": ["narration", "caption", "image_prompt", "camera"],
},
},
},

View File

@ -605,30 +605,6 @@ _wrap_lines = MU._wrap_lines
mix_bgm = MU.mix_bgm
def _scene_motion(idx: int, n: int, frames: int) -> str:
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
"""
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f = max(frames, 1)
if idx == 1: # 훅: 리빌(살짝 당김→전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
return ("zoompan=z='1.06':"
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
kind = idx % 4
if kind == 0: # 줌아웃(끝에 전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if kind == 1: # 줌인(시작에 전체)
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
@ -649,6 +625,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
tmp.mkdir(exist_ok=True)
segs = []
total = 0.0
# 카메라 순환 시작점 — 콘텐츠마다 다르되 같은 콘텐츠면 항상 같다.
# (media.motion_seed 주석 참조)
cam_seed = MU.motion_seed("".join(str(s.get("narration", "")) for s in scenes))
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
@ -665,7 +644,7 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
band_h = int(H * BAND_RATIO)
img_h = H - band_h
bw, bh = int(W * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
motion = _scene_motion(idx, len(scenes), frames)
motion = MU.scene_motion(sc, idx, len(scenes), frames, cam_seed)
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.

View File

@ -204,6 +204,17 @@ SB_SYSTEM = """\
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
→ 그래야 컷마다 눈 표정이 다양해진다.
- camera: 그 장면에 어울리는 카메라 움직임을 아래 중 하나로 고른다. 장면의 '감정'에 맞춰라.
reveal : 상황 전체가 드러남 (첫 컷 기본)
punch_in : 반전·폭로·핵심 한 방 (강하다. 영상당 1~2번만)
slow_in : 몰입이 쌓이는 서술, 마지막 CTA
slow_out : 상황이 점점 보이는 서술
pan_left / pan_right : 이동·전개·시간 흐름
tilt_up : 올려다봄 — 권위·거대함·감탄
tilt_down : 내려다봄 — 추락·좌절·한심함
shake : 충격·당황·사고 (강하다. 영상당 1번이면 충분)
hold : 정적인 대사·여운. 강한 컷 앞뒤에 넣으면 대비가 산다
★ 같은 값을 3연속으로 쓰지 마라. 강한 효과(punch_in/shake)를 연달아 쓰면 산만해진다.
[전역 설정]
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의. 삼국지 인물을 '귀여운 통통한 고양이'로 의인화.
@ -242,8 +253,18 @@ SB_SCHEMA = {
"narration": {"type": "string"},
"caption": {"type": "string"},
"image_prompt": {"type": "string"},
# 값은 media.CAMERA_KINDS 와 반드시 일치해야 한다.
# 모르는 값이 오면 렌더가 조용히 기본 순환으로 떨어진다.
"camera": {
"type": "string",
"enum": [
"reveal", "punch_in", "slow_in", "slow_out",
"pan_left", "pan_right", "tilt_up", "tilt_down",
"shake", "hold",
],
},
},
"required": ["narration", "caption", "image_prompt"],
"required": ["narration", "caption", "image_prompt", "camera"],
},
},
},

View File

@ -575,30 +575,6 @@ _wrap_lines = MU._wrap_lines
mix_bgm = MU.mix_bgm
def _scene_motion(idx: int, n: int, frames: int) -> str:
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
"""
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f = max(frames, 1)
if idx == 1: # 훅: 리빌(살짝 당김→전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
return ("zoompan=z='1.06':"
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
kind = idx % 4
if kind == 0: # 줌아웃(끝에 전체)
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
if kind == 1: # 줌인(시작에 전체)
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
@ -619,6 +595,9 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
tmp.mkdir(exist_ok=True)
segs = []
total = 0.0
# 카메라 순환 시작점 — 콘텐츠마다 다르되 같은 콘텐츠면 항상 같다.
# (media.motion_seed 주석 참조)
cam_seed = MU.motion_seed("".join(str(s.get("narration", "")) for s in scenes))
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
@ -635,7 +614,7 @@ def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_second
band_h = int(H * BAND_RATIO)
img_h = H - band_h
bw, bh = int(W * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
motion = _scene_motion(idx, len(scenes), frames)
motion = MU.scene_motion(sc, idx, len(scenes), frames, cam_seed)
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.

View File

@ -243,6 +243,187 @@ def _wrap_lines(draw, text, font, max_w):
return lines or [""]
# ---------------------------------------------------------------- 카메라 모션
#
# 엔진 4종(animation / samgukji / greekroman / odyssey)이 각자 복사본을 갖고 있던
# `_scene_motion` 을 여기로 올렸다. 완전히 같은 코드였고(md5 일치), 효과를 늘릴 때마다
# 4곳을 손으로 맞춰야 해 어긋나기 쉬웠다.
#
# 표현식은 ffmpeg `zoompan` 의 z/x/y 만 반환한다 — d/s/fps 는 호출부가 붙인다.
# 회전·틸트 필터는 쓰지 않는다: 렌더가 이미 떨림 방지로 3배 업스케일 중이라
# 필터를 더 얹으면 메모리와 시간이 그만큼 붙는다. zoompan 하나로 낼 수 있는
# 범위 안에서만 다양화했다.
#: 스토리보드가 고를 수 있는 카메라 종류. gen.py 의 SB_SCHEMA enum 과 같은 값이어야 한다.
CAMERA_KINDS = (
"reveal", # 살짝 당긴 상태에서 전체로 — 첫 컷(훅) 기본값
"punch_in", # 앞부분에서 확 들어가 고정 — 반전·폭로
"slow_in", # 완만한 줌인 — 몰입이 쌓이는 서술
"slow_out", # 완만한 줌아웃 — 상황이 드러나는 서술
"pan_left", # 우→좌
"pan_right", # 좌→우
"tilt_up", # 아래→위 — 올려다보는 느낌(권위·거대함)
"tilt_down", # 위→아래 — 내려다보는 느낌(추락·좌절)
"shake", # 미세 흔들림 — 충격·당황
"hold", # 완전 정지 — 정적인 대사, 앞뒤 대비용
)
# ── 움직임이 '계단처럼' 보이지 않게 하는 규칙 ────────────────────────────
#
# zoompan 은 크롭 사각형을 **입력 정수 픽셀**로 반올림한다. 3배 업스케일이면
# 출력 기준 약 0.33px 격자이고, 이 격자 오차(측정값 jerk 0.13~0.16px)는
# 무엇을 해도 거의 줄지 않는다. 실측(2026-08-10):
#
# 업스케일 3배 → 6배 → 8배 : jerk 0.127 → 0.094 → 0.085 (33% 개선)
# 그런데 렌더 시간은 : 5.6초 → 22.5초 → 29.3초 (5배)
#
# 즉 업스케일은 나쁜 거래다. 바닥이 고정이라면 **프레임당 이동량을 그 위로
# 올리는 것**이 답이다. 실제로 부드러웠던 팬은 1.06px/프레임이었고,
# 계단이 보이던 줌은 0.25~0.38px/프레임이었다.
#
# 컷 길이별 불균일도(= jerk / 프레임당 이동), 확대폭 10% 고정 시
# 2초 22% · 4초 34% · 6초 63% ← 컷이 길수록 급격히 나빠진다
# 확대폭을 35% 로 올리면 6초 컷도 16% 로 떨어진다.
#
# 원인은 **확대·이동 폭이 컷 길이와 무관하게 고정**이었다는 것이다. 컷이 길수록
# 같은 거리를 더 잘게 나눠 가므로 한 걸음이 격자 아래로 내려간다.
# → 아래 두 상수로 '한 걸음'을 목표치에 맞춰 폭을 역산한다.
#
# 가감속(smoothstep)은 넣지 않았다. 양 끝에서 속도가 0 이 되어 그 구간이
# 격자 아래로 떨어진다 — 실측에서 정지 프레임이 0개에서 7개로 늘었다.
# 등속이 이 격자 위에서는 가장 부드럽다.
#: 프레임당 목표 이동량(출력 px). 부드러웠던 팬의 실측치(1.06px)를 기준으로 잡았다.
TARGET_PX_PER_FRAME = 1.0
#: 확대폭 한계. 너무 크면 원본 화질이 드러나고 화면이 과하게 요동친다.
_ZOOM_MIN, _ZOOM_MAX = 0.08, 0.30
#: 팬·틸트 이동 거리(화면 대비 비율) 한계.
_PAN_MIN, _PAN_MAX = 0.06, 0.25
#: 출력 가로 폭. 이동량 계산의 기준이며 W 와 같다.
_OUT_W = 1080
#: 확대율 1% 당 화면에서 움직이는 픽셀 수(실측 회귀).
#: 확대폭 a, 프레임 f 일 때 프레임당 이동 = a * 450 / f 로 맞았다.
_ZOOM_PX_GAIN = 450.0
def _zoom_amount(frames: int) -> float:
"""컷 길이에 맞춘 확대폭. 한 걸음이 목표치(≈1px)가 되도록 역산한다."""
f = max(frames, 1)
want = f * TARGET_PX_PER_FRAME / _ZOOM_PX_GAIN
return max(_ZOOM_MIN, min(_ZOOM_MAX, want))
def _pan_zoom(frames: int) -> tuple:
"""팬·틸트용 (확대율, 이동비율). 확대율은 이동할 여백을 만들기 위한 것이다.
이동 거리 = 화면폭 * ratio 이므로, 프레임당 목표 이동에서 ratio 를 역산한다.
"""
f = max(frames, 1)
ratio = f * TARGET_PX_PER_FRAME / _OUT_W
ratio = max(_PAN_MIN, min(_PAN_MAX, ratio))
return 1.0 / (1.0 - ratio), ratio
#: 역할을 알 수 없는 컷에 순환시킬 기본 목록.
#: reveal/punch_in/shake 는 강한 효과라 여기서 뺐다 — 아무 데나 붙으면 산만해진다.
_NEUTRAL_KINDS = ("slow_in", "pan_right", "slow_out", "tilt_up", "pan_left", "tilt_down")
_CX = "iw/2-(iw/zoom/2)" # 가로 중앙 고정
_CY = "ih/2-(ih/zoom/2)" # 세로 중앙 고정
def motion_seed(text: str) -> int:
"""스토리보드 텍스트에서 카메라 순환 시작점을 뽑는다.
난수를 쓰지 않는 이유: 같은 콘텐츠를 다시 렌더하면 같은 영상이 나와야
비교·디버깅이 된다. 반면 콘텐츠가 다르면 시작점이 달라지므로
**영상마다 카메라 순서가 달라진다** — 결정론과 다양성을 함께 얻는다.
"""
import hashlib
digest = hashlib.md5((text or "").encode("utf-8", "ignore")).hexdigest()
return int(digest[:8], 16)
def _camera_expr(kind: str, frames: int) -> str:
"""카메라 종류 → zoompan 표현식.
확대·이동 폭은 상수가 아니라 **컷 길이에서 역산**한다(위 주석 참조).
긴 컷일수록 폭을 키워야 한 걸음이 양자화 격자 위에 남는다.
"""
f = max(frames, 1)
a = _zoom_amount(f) # 이 컷에 맞는 확대폭
pz, _ = _pan_zoom(f) # 팬·틸트용 확대율(여백 확보)
if kind == "reveal":
# 당긴 상태에서 전체로. 확대폭을 조금 더 크게 잡아 '드러나는' 느낌을 준다
ra = min(_ZOOM_MAX, a * 1.4)
return f"zoompan=z='max({1 + ra:.4f}-{ra:.4f}*on/{f},1.0)':x='{_CX}':y='{_CY}'"
if kind == "punch_in":
# 앞 35% 구간에서 들어가고 나머지는 정지. 정지 구간은 움직임이 아예 없어
# 격자 문제가 생기지 않고, 돌진 구간은 걸음이 크므로 부드럽다
rush = max(1, int(f * 0.35))
pa = min(_ZOOM_MAX, a * 1.4)
return f"zoompan=z='min(1.0+{pa:.4f}*on/{rush},{1 + pa:.4f})':x='{_CX}':y='{_CY}'"
if kind == "slow_in":
return f"zoompan=z='1.0+{a:.4f}*on/{f}':x='{_CX}':y='{_CY}'"
if kind == "slow_out":
return f"zoompan=z='{1 + a:.4f}-{a:.4f}*on/{f}':x='{_CX}':y='{_CY}'"
if kind == "pan_right":
return f"zoompan=z='{pz:.4f}':x='(iw-iw/zoom)*on/{f}':y='{_CY}'"
if kind == "pan_left":
# `(1-on/f)` 로 쓰면 1 에 가까운 값을 큰 수에 곱하게 되어 반올림이 어긋난다.
# 실측에서 pan_right 4.1% 대 pan_left 22.3% 로 방향만 다른데 5배 차이가 났다.
# 증가항을 그대로 두고 '빼는' 형태로 쓰면 두 방향의 격자 패턴이 같아진다.
return f"zoompan=z='{pz:.4f}':x='(iw-iw/zoom)-(iw-iw/zoom)*on/{f}':y='{_CY}'"
if kind == "tilt_down":
return f"zoompan=z='{pz:.4f}':x='{_CX}':y='(ih-ih/zoom)*on/{f}'"
if kind == "tilt_up":
return f"zoompan=z='{pz:.4f}':x='{_CX}':y='(ih-ih/zoom)-(ih-ih/zoom)*on/{f}'"
if kind == "shake":
# 흔들림은 의도된 진동이라 걸음이 크다(프레임당 수 px) — 격자 영향 없음
return ("zoompan=z='1.06':"
f"x='{_CX}+8*sin(on*1.6)':"
f"y='{_CY}+6*cos(on*2.2)'")
# hold — **완전 정지**. 예전에는 3px 사인 드리프트를 줬으나, 그 폭이 출력
# 기준 1px 라 격자에 걸려 프레임의 1/3 이 멈췄다 튀었다(실측 20/59).
# 미세하게 움직이느니 아예 안 움직이는 편이 훨씬 깔끔하다.
return f"zoompan=z='1.04':x='{_CX}':y='{_CY}'"
def scene_motion(scene, idx: int, n: int, frames: int, seed: int = 0) -> str:
"""장면 하나의 zoompan 표현식.
카메라를 정하는 순서는 셋이고, 앞에서 정해지면 뒤는 보지 않는다:
1. **스토리보드가 지정한 값** (`scene["camera"]`).
LLM 이 장면 내용을 알고 고른 것이라 가장 정확하다.
2. **위치 휴리스틱** — 첫 컷은 reveal, 마지막 컷은 시선을 모으는 slow_in.
스토리보드가 camera 를 안 줬을 때의 안전망이다.
3. **seed 순환** — 중립 효과를 `(idx + seed)` 로 돌린다. idx 가 1씩
늘어나므로 **인접 컷이 같은 효과가 되는 일이 없고**, seed 가 콘텐츠마다
달라 영상끼리도 순서가 겹치지 않는다.
`scene` 이 dict 가 아니거나 모르는 값이면 조용히 3단계로 떨어진다 —
카메라 때문에 렌더 전체가 실패하면 안 된다.
"""
if isinstance(scene, dict):
want = str(scene.get("camera") or "").strip().lower()
if want in CAMERA_KINDS:
return _camera_expr(want, frames)
if idx == 1:
return _camera_expr("reveal", frames)
if n >= 3 and idx == n:
return _camera_expr("slow_in", frames)
kind = _NEUTRAL_KINDS[(idx + seed) % len(_NEUTRAL_KINDS)]
return _camera_expr(kind, frames)
# ---------------------------------------------------------------- BGM
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus")