Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
355 changes: 355 additions & 0 deletions docs/arch/intro-watermark.md

Large diffs are not rendered by default.

180 changes: 180 additions & 0 deletions docs/design/intro-watermark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# 影片片頭與浮水印 — 來源宣告

## 背景與問題

使用者用 BilingualSub 產生翻譯字幕後,經常會在 Facebook、YouTube、X 等社群平台分享燒錄過的影片。目前產出的影片沒有任何來源標示,觀看者無法得知原始影片出處,也無法找到原始創作者。

缺乏來源宣告會造成兩個問題:

1. **對原始創作者不尊重** — 看起來像是搬運而非善意分享,原作者若看到觀感不佳
2. **對分享者有法律風險** — 翻譯屬於著作權法的「改作」,明確標註來源、提供移除管道可展現善意

## 使用者角色

**分享者**:使用 BilingualSub 翻譯影片字幕,並在社群平台分享給中文受眾的人。動機是讓更多人看到好內容,非營利目的。

## 需求情境

- 分享者:When 我在 Facebook 分享翻譯過的影片, I want to 影片自動包含來源宣告, so I can 讓觀看者知道原始出處、展現對創作者的尊重、降低著作權爭議風險。

## 設計意圖

- **永遠啟用,不提供開關** → 來源宣告是對原作者的基本尊重,不應該是可選的
- **片頭用電影 disclaimer 風格** → 觀眾習慣電影片頭有來源/版權宣告,自然不突兀
- **著作權聲明用中英雙語** → 原作者多為英語系,需要看得懂;分享對象多為中文使用者,也需要中文
- **措辭用「如需移除,請聯繫上傳者」** → 中性語氣,不預設對方態度,提供合理的移除管道
- **浮水印全程顯示** → 即使觀眾跳過片頭,正片中仍能看到原始來源
- **本地檔案跳過** → 本地檔案沒有頻道和 URL 資訊,強加無意義的片頭反而奇怪
- **非 YouTube 平台不顯示頻道 URL** → 各平台頻道 URL 格式不統一,只顯示頻道名即可
- **片頭解析度配合來源影片** → FFmpeg concat 要求兩段影片解析度一致,自動配合可避免黑邊或縮放問題

## User Journey

### Journey 1:分享者 — 翻譯 YouTube 影片並分享

前置條件:使用者提交一個 YouTube URL 進行翻譯

1. 使用者提交 YouTube URL → 系統下載影片並擷取 metadata(含頻道名稱)
2. 系統完成轉錄、翻譯、字幕合併
3. 使用者編輯字幕後點擊燒錄 → 系統開始燒錄流程
4. 系統生成片頭影片(黑底,來源宣告,約 5 秒)→ 解析度配合來源影片
5. 系統燒錄字幕至正片,同時加上右上角浮水印 → 浮水印顯示 `Source: {頻道名稱}`
6. 系統將片頭與正片接合 → 產出最終影片
7. 使用者下載最終影片 → 影片開頭有來源宣告,正片有浮水印

### Journey 2:分享者 — 使用本地影片檔案

前置條件:使用者上傳本地影片檔案(非 URL)

1. 使用者上傳本地影片 → 系統擷取 metadata(無頻道資訊)
2. 系統完成轉錄、翻譯、字幕合併
3. 使用者編輯字幕後點擊燒錄 → 系統開始燒錄流程
4. 系統燒錄字幕至影片,**不加片頭、不加浮水印**
5. 使用者下載最終影片 → 僅有燒錄字幕,與現有行為一致

### Journey 3:分享者 — 使用非 YouTube 平台(Bilibili、Twitter 等)

前置條件:使用者提交非 YouTube 的 URL

1. 使用者提交 URL → 系統透過 yt-dlp 下載並擷取 metadata
2. 系統完成轉錄、翻譯、字幕合併
3. 使用者編輯字幕後點擊燒錄 → 系統開始燒錄流程
4. 系統生成片頭影片 → 頻道名稱正常顯示,**頻道 URL 省略**,影片 URL 正常顯示
5. 系統燒錄字幕至正片,加上右上角浮水印
6. 系統將片頭與正片接合 → 產出最終影片

## 片頭視覺規格

### 設計風格

Variant 3 / 英文 eyebrow 左對齊文件風,黑底電影 disclaimer 風格。

### 內容結構(從上到下,左對齊)

| 層級 | 內容 | 字體 | 大小 | 顏色(白字透明度) |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ---- | ------------------ |
| Eyebrow | `ORIGINAL VIDEO FROM` | Inter, 500 weight | 10px | 0.3 |
| 中文標籤 | `原始影片來自` | Noto Serif TC, 300 weight | 13px | 0.6 |
| 頻道名稱 | `{channel}` | Inter, 500 weight | 32px | 1.0 |
| 頻道 URL | `youtube.com/@{channel}`(僅 YouTube) | Inter, 400 weight | 11px | 0.35 |
| 影片標題 | `{title}` | Noto Serif TC, 300 weight | 16px | 0.7 |
| 影片 URL | `{url}` | Inter, 400 weight | 12px | 0.5 |
| 中文聲明 | 翻譯字幕由開源專案 BilingualSub 產生 / 所有內容及著作權屬於原始創作者所有 / 如需移除,請聯繫上傳者 | Noto Serif TC, 300 weight | 12px | 0.45 |
| 英文聲明 | Subtitles generated by BilingualSub (open source) / All content and copyrights belong to the original creator / For removal requests, please contact the uploader | Inter, 400 weight | 11px | 0.35 |
| 品牌 | `BilingualSub`(右下角) | Inter, 400 weight, uppercase, letter-spacing 3px | 10px | 0.25 |

### 動畫

- 各區塊依序淡入(間隔約 0.3s),帶微小上滑位移
- 全部顯示後停留約 2 秒
- 整體淡出
- 總時長約 5 秒

### 解析度

配合來源影片的 width x height 生成,確保 concat 無縫接合。

### 實現方式

FFmpeg `color` filter(黑底)+ 多層 `drawtext` filter + `fade` filter。不需外部圖片素材。

## 浮水印視覺規格

| 項目 | 規格 |
| -------- | -------------------------------- |
| 位置 | 右上角(top: 18px, right: 20px) |
| 內容 | `Source: {channel}` |
| 字體 | Inter, 400 weight |
| 大小 | 12px |
| 顏色 | 白字,透明度 0.45 |
| 陰影 | 黑色陰影(確保在亮色背景上可讀) |
| 顯示範圍 | 正片全程 |

### 實現方式

在 burn 步驟的 FFmpeg filter chain 中加入 `drawtext` filter。

## 替代流程

- **yt-dlp 未回傳頻道名稱**:使用 `uploader` 欄位作為 fallback;若兩者皆無,使用影片標題代替頻道名稱,浮水印顯示 `Source: {title}`
- **影片標題過長**:片頭中截斷顯示(FFmpeg drawtext 限定最大寬度為影片寬度的 70%)

## 錯誤情境

### 系統錯誤

- **FFmpeg 片頭生成失敗**:跳過片頭,僅產出燒錄字幕的正片(降級處理),記錄警告 log
- **FFmpeg concat 失敗**:同上,降級為僅正片輸出

### 使用者誤操作

- 無特殊情境(功能為自動觸發,無使用者操作)

## Out of Scope

- 前端片頭/浮水印開關
- 使用者自訂片頭內容或樣式
- 使用者自訂浮水印位置或內容
- 片尾(outro)
- 動態浮水印(移動、閃爍等)

## 整合點

- **yt-dlp**:需要從 `info_dict` 擷取 `channel`、`uploader`、`channel_url` 欄位(目前未擷取)
- **FFmpeg**:片頭生成(drawtext + color + fade)、浮水印(drawtext)、接合(concat)
- **Pipeline**:在現有 burn 步驟之後新增片頭生成和接合步驟
- **Job dataclass**:新增 `video_channel` 欄位

## Acceptance Criteria

- Given 使用者提交 YouTube URL 並完成燒錄
When 下載最終影片
Then 影片開頭有約 5 秒的來源宣告片頭,包含頻道名稱、影片標題、YouTube URL、中英雙語著作權聲明

- Given 使用者提交 YouTube URL 並完成燒錄
When 播放最終影片的正片部分
Then 右上角全程顯示半透明浮水印 `Source: {頻道名稱}`

- Given 使用者上傳本地影片檔案並完成燒錄
When 下載最終影片
Then 影片不包含片頭,不包含浮水印,行為與現有一致

- Given 使用者提交非 YouTube 平台的 URL(如 Bilibili)並完成燒錄
When 下載最終影片
Then 片頭顯示頻道名稱但不顯示頻道 URL,影片 URL 正常顯示

- Given 來源影片解析度為 720p
When 系統生成片頭
Then 片頭解析度為 1280x720,與正片 concat 後無黑邊或縮放

- Given FFmpeg 片頭生成過程中發生錯誤
When 系統偵測到錯誤
Then 跳過片頭,僅輸出燒錄字幕的正片,並記錄警告 log

## 開放問題

(無)

## 視覺參考

HTML 預覽檔:`preview-intro-watermark.html`(Variant 3 / 英文 eyebrow 左對齊文件風)
1 change: 1 addition & 0 deletions src/bilingualsub/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class FileType(StrEnum):
VIDEO = "video"
AUDIO = "audio"
SOURCE_VIDEO = "source_video"
INTRO_VIDEO = "intro_video"


class SSEEvent(StrEnum):
Expand Down
3 changes: 3 additions & 0 deletions src/bilingualsub/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,13 @@ class Job:
video_height: int = 0
video_title: str = ""
video_description: str = ""
video_channel: str = "" # channel name; empty → skip intro flow
video_channel_url: str = "" # channel URL; empty → omit channel URL line in intro
glossary_text: str = ""
subtitle_source: str = ""
processing_mode: ProcessingMode = ProcessingMode.SUBTITLE
video_duration: float = 0.0
video_fps: float = 0.0
output_files: dict[FileType, Path] = field(default_factory=dict)
event_queue: asyncio.Queue[dict[str, object]] = field(default_factory=asyncio.Queue)
created_at: float = field(default_factory=time.monotonic)
Expand Down
74 changes: 73 additions & 1 deletion src/bilingualsub/api/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@
from bilingualsub.utils import (
FFmpegError,
burn_subtitles,
concat_videos,
extract_audio,
extract_video_metadata,
generate_intro,
trim_video,
)

Expand Down Expand Up @@ -293,9 +295,20 @@ async def run_download(job: Job) -> None:
# Save metadata for subtitle phase
job.video_width = metadata.width
job.video_height = metadata.height
job.video_fps = metadata.fps
job.video_title = metadata.title
job.video_description = metadata.description
job.video_duration = metadata.duration
job.video_channel = metadata.channel

# Only show channel URL for YouTube sources
source_url = job.source_url or ""
is_youtube_source = "youtube.com" in source_url or "youtu.be" in source_url
raw_channel_url = metadata.channel_url
job.video_channel_url = (
raw_channel_url if (is_youtube_source and raw_channel_url) else ""
)

job.output_files[FileType.SOURCE_VIDEO] = video_path

_send_download_complete(job)
Expand Down Expand Up @@ -538,6 +551,11 @@ async def run_subtitle(job: Job) -> None:
)


_BURN_PROGRESS_END = 80.0
_INTRO_PROGRESS_END = 90.0
_CONCAT_PROGRESS_END = 99.0


async def run_burn(job: Job, srt_content: str) -> None:
"""Burn user-edited SRT into the source video."""
log = logger.bind(job_id=job.id)
Expand All @@ -550,11 +568,16 @@ async def run_burn(job: Job, srt_content: str) -> None:
_send_progress(job, JobStatus.BURNING, 0.0, "burn", "Burning subtitles")
output_video = work_dir / "output.mp4"

has_channel = bool(job.video_channel)
watermark_text = f"Source: {job.video_channel}" if has_channel else None

burn_scale = _BURN_PROGRESS_END / 100.0 if has_channel else 1.0

def on_burn_progress(percent: float) -> None:
_send_progress(
job,
JobStatus.BURNING,
percent,
percent * burn_scale,
"burn",
f"Burning subtitles ({percent:.0f}%)",
)
Expand All @@ -565,7 +588,56 @@ def on_burn_progress(percent: float) -> None:
srt_path,
output_video,
on_progress=on_burn_progress,
watermark_text=watermark_text,
)

if has_channel:
intro_path = work_dir / "intro.mp4"
try:
await asyncio.to_thread(
generate_intro,
intro_path,
width=job.video_width,
height=job.video_height,
fps=job.video_fps if job.video_fps > 0 else 30.0,
channel=job.video_channel,
video_title=job.video_title,
video_url=job.source_url,
channel_url=job.video_channel_url,
on_progress=lambda p: _send_progress(
job,
JobStatus.BURNING,
_BURN_PROGRESS_END
+ p * (_INTRO_PROGRESS_END - _BURN_PROGRESS_END) / 100.0,
"intro",
f"Generating intro ({p:.0f}%)",
),
)
job.output_files[FileType.INTRO_VIDEO] = intro_path
except FFmpegError as exc:
log.warning("intro_generation_failed", error=str(exc))
# Degrade gracefully: skip intro, use subtitle-only main video
else:
final_path = work_dir / "final.mp4"
try:
await asyncio.to_thread(
concat_videos,
intro_path,
output_video,
final_path,
on_progress=lambda p: _send_progress(
job,
JobStatus.BURNING,
_INTRO_PROGRESS_END
+ p * (_CONCAT_PROGRESS_END - _INTRO_PROGRESS_END) / 100.0,
"concat",
f"Combining intro and video ({p:.0f}%)",
),
)
output_video = final_path
except FFmpegError as exc:
log.warning("concat_failed", error=str(exc))

job.output_files[FileType.VIDEO] = output_video
_send_complete(job)
log.info("burn_complete", job_id=job.id)
Expand Down
16 changes: 16 additions & 0 deletions src/bilingualsub/core/downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class VideoMetadata:
height: int
fps: float
description: str = ""
channel: str = "" # channel name; empty for local uploads
channel_url: str = "" # raw channel URL from yt-dlp; empty for local uploads

def __post_init__(self) -> None:
"""Validate metadata constraints."""
Expand Down Expand Up @@ -119,6 +121,7 @@ def download_video(
if isinstance(info_title, str) and info_title.strip():
metadata.title = info_title.strip()
metadata.description = _sanitize_description(info_dict.get("description", ""))
metadata.channel, metadata.channel_url = _extract_channel_from_info(info_dict)

return metadata

Expand All @@ -130,6 +133,15 @@ def _sanitize_description(raw: Any) -> str:
return raw.strip()


def _extract_channel_from_info(info_dict: dict[str, Any]) -> tuple[str, str]:
"""Extract channel name and URL from a yt-dlp info_dict."""
channel_raw = info_dict.get("channel") or info_dict.get("uploader") or ""
channel = channel_raw.strip() if isinstance(channel_raw, str) else ""
raw_url = info_dict.get("channel_url") or ""
channel_url = raw_url.strip() if isinstance(raw_url, str) else ""
return channel, channel_url


_SUPPORTED_EXTRACTOR_CLASSES: list[type] = [
cls for cls in gen_extractor_classes() if cls.IE_NAME != "generic"
]
Expand Down Expand Up @@ -279,13 +291,17 @@ def _extract_metadata_from_info_dict(
if fps is None or fps <= 0:
fps = 30.0

channel, channel_url = _extract_channel_from_info(info_dict)

return VideoMetadata(
title=title,
duration=float(duration),
width=int(width),
height=int(height),
fps=float(fps),
description=_sanitize_description(info_dict.get("description", "")),
channel=channel,
channel_url=channel_url,
)


Expand Down
4 changes: 4 additions & 0 deletions src/bilingualsub/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
from bilingualsub.utils.ffmpeg import (
FFmpegError,
burn_subtitles,
concat_videos,
extract_audio,
extract_video_metadata,
generate_intro,
get_audio_duration,
split_audio,
trim_video,
Expand All @@ -15,8 +17,10 @@
"FFmpegError",
"Settings",
"burn_subtitles",
"concat_videos",
"extract_audio",
"extract_video_metadata",
"generate_intro",
"get_audio_duration",
"get_groq_api_key",
"get_settings",
Expand Down
Loading
Loading