Skip to content
Open
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
15 changes: 13 additions & 2 deletions src/rvcg/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,20 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--preset",
choices=["default", "black-fades", "podcast", "presentation", "action"],
choices=[
"default",
"movie",
"balanced",
"all-stages",
"podcast",
"presentation",
"anime",
"action",
"fine",
"black-fades",
],
default="default",
help="Predefined detection sensitivity profile",
help="Predefined detection sensitivity profile (default/movie, balanced, podcast, presentation, anime, action, fine)",
)
parser.add_argument(
"--black-min-dur",
Expand Down
221 changes: 180 additions & 41 deletions src/rvcg/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,50 +43,189 @@ class BoundaryConfig:

@classmethod
def from_preset(cls, preset: str) -> "BoundaryConfig":
if preset in ("default", "black-fades", "fades"):
return cls(
enable_black_fades=True,
enable_white_fades=True,
enable_visual_cuts=False,
enable_subdivide=False,
)
if preset == "all-stages":
return cls(
enable_black_fades=True,
enable_white_fades=True,
enable_visual_cuts=True,
enable_subdivide=True,
)
if preset == "podcast":
return cls(
min_seg=120.0,
max_seg=900.0,
target_seg=450.0,
scene_threshold=0.45,
enable_visual_cuts=True,
enable_subdivide=True,
)
if preset == "presentation":
return cls(
min_seg=60.0,
max_seg=600.0,
target_seg=300.0,
scene_threshold=0.30,
enable_visual_cuts=True,
enable_subdivide=True,
)
if preset == "action":
return cls(
min_seg=90.0,
max_seg=420.0,
target_seg=240.0,
scene_threshold=0.35,
enable_visual_cuts=True,
enable_subdivide=True,
)
p = preset.strip().lower()
if p in ("movie", "black-fades", "fades"):
p = "default"
elif p == "all-stages":
p = "balanced"
if p in PRESETS:
return PRESETS[p].config
return cls()


@dataclass(frozen=True)
class PresetMetadata:
id: str
name: str
description: str
config: BoundaryConfig


PRESETS: dict[str, PresetMetadata] = {
"default": PresetMetadata(
id="default",
name="movie (Fades only - High Precision)",
description="High-precision fade detection only. Ideal for films, cinema, and narrative storytelling where scene transitions are intentional fades.",
config=BoundaryConfig(
min_seg=180.0,
max_seg=720.0,
target_seg=360.0,
workers=8,
scene_threshold=0.38,
black_min_dur=0.40,
black_pic_th=0.95,
black_pix_th=0.12,
white_pic_th=0.95,
white_pix_th=0.10,
enable_black_fades=True,
enable_white_fades=True,
enable_visual_cuts=False,
enable_subdivide=False,
card_dur=8.4,
title_template="Scene {n:02d}",
),
),
"balanced": PresetMetadata(
id="balanced",
name="balanced (Fades + Visual + Subdivide)",
description="Full 3-stage pipeline combining fades, visual cuts, and subdivision. Best all-rounder for YouTube videos, documentaries, and mixed media.",
config=BoundaryConfig(
min_seg=120.0,
max_seg=600.0,
target_seg=300.0,
workers=8,
scene_threshold=0.38,
black_min_dur=0.40,
black_pic_th=0.95,
black_pix_th=0.12,
white_pic_th=0.95,
white_pix_th=0.10,
enable_black_fades=True,
enable_white_fades=True,
enable_visual_cuts=True,
enable_subdivide=True,
card_dur=8.4,
title_template="Scene {n:02d}",
),
),
"podcast": PresetMetadata(
id="podcast",
name="podcast (Interviews & Long Talks)",
description="Long conversational chapters. Uses a higher visual threshold to ignore 2-person alternating camera switches while subdividing long monolithic discussions.",
config=BoundaryConfig(
min_seg=180.0,
max_seg=900.0,
target_seg=480.0,
workers=8,
scene_threshold=0.46,
black_min_dur=0.40,
black_pic_th=0.95,
black_pix_th=0.12,
white_pic_th=0.95,
white_pix_th=0.10,
enable_black_fades=True,
enable_white_fades=True,
enable_visual_cuts=True,
enable_subdivide=True,
card_dur=10.0,
title_template="Chapter {n:02d}",
),
),
"presentation": PresetMetadata(
id="presentation",
name="presentation (Slides & Keynotes)",
description="Slide and topic transitions. Sensitive visual detection catches slide changes that don't shift the entire screen background.",
config=BoundaryConfig(
min_seg=60.0,
max_seg=600.0,
target_seg=300.0,
workers=8,
scene_threshold=0.28,
black_min_dur=0.35,
black_pic_th=0.95,
black_pix_th=0.10,
white_pic_th=0.95,
white_pix_th=0.10,
enable_black_fades=True,
enable_white_fades=True,
enable_visual_cuts=True,
enable_subdivide=True,
card_dur=5.0,
title_template="Slide {n:02d}",
),
),
"anime": PresetMetadata(
id="anime",
name="anime (TV Episodes & Series)",
description="Optimized for 20-45m anime and TV episodes with distinct OP/ED, eyecatches, mid-episode commercial fades, and post-credits previews.",
config=BoundaryConfig(
min_seg=60.0,
max_seg=480.0,
target_seg=240.0,
workers=8,
scene_threshold=0.36,
black_min_dur=0.30,
black_pic_th=0.92,
black_pix_th=0.15,
white_pic_th=0.92,
white_pix_th=0.12,
enable_black_fades=True,
enable_white_fades=True,
enable_visual_cuts=True,
enable_subdivide=True,
card_dur=6.0,
title_template="Part {n:02d}",
),
),
"action": PresetMetadata(
id="action",
name="action (Sports, Gaming & Action)",
description="High-motion gameplay, sports, and fast-cut action. Raised threshold prevents false cuts during combat while subdividing into compact chapters.",
config=BoundaryConfig(
min_seg=90.0,
max_seg=420.0,
target_seg=210.0,
workers=8,
scene_threshold=0.42,
black_min_dur=0.40,
black_pic_th=0.90,
black_pix_th=0.15,
white_pic_th=0.90,
white_pix_th=0.15,
enable_black_fades=True,
enable_white_fades=True,
enable_visual_cuts=True,
enable_subdivide=True,
card_dur=6.0,
title_template="Highlight {n:02d}",
),
),
"fine": PresetMetadata(
id="fine",
name="fine (Micro / Granular Chapters)",
description="Granular chapter boundaries for music videos, highlight reels, micro-tutorials, and rapid navigation.",
config=BoundaryConfig(
min_seg=30.0,
max_seg=180.0,
target_seg=90.0,
workers=8,
scene_threshold=0.32,
black_min_dur=0.25,
black_pic_th=0.90,
black_pix_th=0.15,
white_pic_th=0.90,
white_pix_th=0.15,
enable_black_fades=True,
enable_white_fades=True,
enable_visual_cuts=True,
enable_subdivide=True,
card_dur=4.0,
title_template="Segment {n:02d}",
),
),
}


@dataclass(frozen=True)
class FadePoint:
timestamp: float
Expand Down
23 changes: 12 additions & 11 deletions src/rvcg/static/js/gallery.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ export function renderGallery(onChaptersChanged) {

const card = document.createElement("article");
const isActive = index === state.currentIndex;
card.className = `chapter-card card bg-base-200 border border-base-content/10 shadow-sm transition-all overflow-hidden ${isActive ? "ring-2 ring-primary" : ""}`;
card.className = `chapter-card card bg-base-200 border border-base-content/10 shadow-sm transition-all overflow-hidden hover:border-primary/40 hover:shadow-md ${isActive ? "ring-2 ring-primary" : ""}`;
card.dataset.index = index;

const header = document.createElement("div");
header.className = "card-header flex items-center justify-between px-3 py-2 border-b border-base-content/10 bg-base-300/50 cursor-pointer select-none";
header.className = "card-header flex items-center justify-between px-3 py-2 border-b border-base-content/10 bg-base-300/40 cursor-pointer select-none";
header.addEventListener("click", () => openAt(index, item.start_time, highlightActiveCard));

const hLeft = document.createElement("div");
Expand All @@ -46,11 +46,11 @@ export function renderGallery(onChaptersChanged) {
cutDot.dataset.tippyContent = item.cut_detail || "";

const badge = document.createElement("span");
badge.className = "badge badge-neutral badge-sm font-mono shrink-0";
badge.className = "badge badge-neutral badge-sm font-mono font-bold tracking-tight shrink-0";
badge.textContent = "#" + item.id;

const titleInput = document.createElement("input");
titleInput.className = "input input-ghost input-xs font-semibold flex-1 min-w-0 focus:input-bordered";
titleInput.className = "input input-ghost input-xs font-semibold flex-1 min-w-0 hover:bg-base-100/50 focus:bg-base-100 focus:input-bordered text-xs rounded transition-colors";
titleInput.value = item.title;
titleInput.addEventListener("click", (e) => e.stopPropagation());
titleInput.addEventListener("change", async () => {
Expand Down Expand Up @@ -78,13 +78,13 @@ export function renderGallery(onChaptersChanged) {
hRight.className = "card-header-right flex items-center gap-2 shrink-0";

const metaSpan = document.createElement("span");
metaSpan.className = "text-xs text-base-content/60 font-mono";
metaSpan.textContent = `${item.source_range} · ${item.duration_str}`;
metaSpan.className = "text-[11px] text-base-content/60 font-mono tracking-tight shrink-0";
metaSpan.innerHTML = `<span class="hidden sm:inline">${item.source_range} · </span><span>${item.duration_str}</span>`;
hRight.appendChild(metaSpan);

if (state.cuts.length > 1) {
const delBtn = document.createElement("button");
delBtn.className = "btn btn-ghost btn-xs btn-circle text-base-content/50 hover:text-error";
delBtn.className = "btn btn-ghost btn-xs btn-circle text-base-content/40 hover:text-error hover:bg-error/10 transition-colors";
delBtn.type = "button";
delBtn.textContent = "✕";
delBtn.addEventListener("click", async (e) => {
Expand Down Expand Up @@ -119,16 +119,16 @@ export function renderGallery(onChaptersChanged) {
header.appendChild(hRight);

const grid = document.createElement("div");
grid.className = "contact-grid grid grid-cols-3 grid-rows-3 aspect-video gap-0.5 bg-black/20 p-1";
grid.className = "contact-grid grid grid-cols-3 grid-rows-3 aspect-video gap-0.5 bg-black/40 p-1 rounded-b-box overflow-hidden";

const slots = item.cell_times.length >= 9 ? item.cell_times.slice(0, 9) : item.cell_times;
slots.forEach((baseTime) => {
const cell = document.createElement("div");
cell.className = "grid-cell relative overflow-hidden bg-base-300 cursor-pointer rounded-xs bg-no-repeat";
cell.className = "grid-cell relative overflow-hidden bg-base-300 cursor-pointer rounded-xs bg-no-repeat transition-transform hover:scale-[1.02] hover:z-10";
setCellSpriteFrame(cell, baseTime);

const timeBadge = document.createElement("span");
timeBadge.className = "cell-time absolute bottom-1 right-1 px-1 py-0.5 rounded bg-black/75 text-[10px] text-white font-mono pointer-events-none";
timeBadge.className = "cell-time absolute bottom-1 right-1 px-1.5 py-0.5 rounded bg-black/80 backdrop-blur-md text-[10px] text-white/90 font-mono shadow-sm border border-white/10 pointer-events-none";
timeBadge.textContent = fmtHms(baseTime);
cell.appendChild(timeBadge);

Expand Down Expand Up @@ -196,7 +196,8 @@ export function renderGallery(onChaptersChanged) {
});
}

const mech = state.lastStats ? ` (${formatMechanismSummary(state.lastStats)})` : "";
const mechSummary = formatMechanismSummary(state.lastStats);
const mech = mechSummary ? ` (${mechSummary})` : "";
if (dom.countLabel) {
dom.countLabel.textContent = `${visibleCount} / ${state.cuts.length} chapters${mech}`;
}
Expand Down
Loading
Loading