Added clips module.
Dependency Audit / Dependency Audit (push) Successful in 18s
CI / Formatting (push) Successful in 14s
CI / Linting (push) Successful in 15s
CI / Tests (Python 3.12) (push) Successful in 29s
CI / Tests (Python 3.13) (push) Successful in 28s
CI / Tests (Python 3.14) (push) Successful in 27s
CI / Type Checking (push) Successful in 25s
CI / Spelling (push) Successful in 15s

This commit is contained in:
2026-03-03 19:29:23 -05:00
parent 4de187ba6c
commit 84b057a400
17 changed files with 1930 additions and 2 deletions
+5 -1
View File
@@ -24,9 +24,13 @@ env/
*.swo
# Runtime data
data/*.db
data/
logs/
modules/
# Test / coverage artifacts
.coverage
.pytest_cache/
# Local configuration (keep config.yaml.example in repo)
config.yaml
+20
View File
@@ -72,6 +72,26 @@ owncast:
# Modules can define their own config keys via ctx.config.register_defaults()
# in their setup() function.
modules:
# clips:
# # Seconds of recent stream footage to keep available for clipping.
# # Default: 300
# cache_duration: 300
# # Minimum clip length in seconds.
# # Default: 5
# min_clip_length: 5
# # Maximum clip length in seconds.
# # Default: 120
# max_clip_length: 120
# # Directory where saved clip files are stored.
# # Default: "data/clips"
# clips_dir: "data/clips"
# # Seconds before an unused clip editor session expires.
# # Default: 900
# session_expiry: 900
# # Seconds to keep stream footage available after the stream stops.
# # Default: 120
# grace_period: 120
# custom_commands:
# # Default cooldown in seconds for newly created custom commands.
# # Default: 5
+1 -1
Submodule docs updated: b26ca8b75f...988cecbabb
+1
View File
@@ -16,6 +16,7 @@
BUILTIN_MODULE_NAMES: frozenset[str] = frozenset(
{
"clips",
"custom_commands",
"quotes",
"timers",
+201
View File
@@ -0,0 +1,201 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Clips module for Owlbot.
Allows chat users to create clips from the live Owncast stream. Caches the HLS
stream in real time and provides a browser-based clip editor.
"""
from __future__ import annotations
import asyncio
import contextlib
import shutil
from pathlib import Path
from owlbot.api import (
EventContext,
EventType,
ModuleContext,
StreamStartedEvent,
StreamStoppedEvent,
on_event,
on_setup,
on_teardown,
)
from .cache import start_caching, stop_caching
from .commands import clip_command, clips_command, delclip_command
from .processing import ProcessingManager
from .routes import (
cleanup_session,
clip_page,
clip_thumbnail,
clip_video,
clips_list_page,
editor_js,
editor_page,
editor_preview_video,
editor_submit,
)
from .types import ModuleState, get_state
__all__ = [
"clip_command",
"clip_page",
"clip_thumbnail",
"clip_video",
"clips_command",
"clips_list_page",
"delclip_command",
"editor_js",
"editor_page",
"editor_preview_video",
"editor_submit",
"on_stream_started",
"on_stream_stopped",
"setup",
"teardown",
]
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""Initialize the clips module.
Validates ffmpeg availability, creates the database schema, registers
config defaults, creates the clips storage directory, and starts caching
if a stream is already live.
:param ctx: Module context with config, storage, and other services.
"""
for binary in ("ffmpeg", "ffprobe"):
if shutil.which(binary) is None:
msg = (
f"'{binary}' not found in PATH. "
f"The clips module requires ffmpeg to be installed."
)
raise RuntimeError(msg)
await ctx.storage.execute("""
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
creator TEXT NOT NULL,
created_at TEXT NOT NULL,
duration REAL
)
""")
ctx.config.register_defaults(
{
"cache_duration": 300,
"min_clip_length": 5,
"max_clip_length": 120,
"clips_dir": "data/clips",
"session_expiry": 900,
"grace_period": 120,
}
)
# Create clips directory if it doesn't exist.
clips_dir = Path(str(ctx.config.get("clips_dir")))
clips_dir.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240
# Initialize runtime state.
ctx.state["clips"] = ModuleState()
module_state = get_state(ctx)
module_state.manager = ProcessingManager(ctx.logger)
# Check if stream is already live.
try:
status = await ctx.owncast_client.get_status()
if status.get("online", False):
ctx.logger.info("Stream is already live. Starting HLS cache.")
await start_caching(ctx)
except Exception:
ctx.logger.warning(
"Could not check Owncast status during setup.", exc_info=True
)
@on_event(EventType.STREAM_STARTED)
async def on_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
"""Start HLS caching when the stream goes live.
:param ctx: The event context.
"""
# If there's a grace period task running, cancel it and wait for it to finish.
module_state = get_state(ctx.module)
if module_state.grace_task is not None:
ctx.module.logger.debug("Cancelling active grace period task.")
module_state.grace_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await module_state.grace_task
module_state.grace_task = None
# Wipe old cache before starting fresh.
await stop_caching(ctx.module)
await start_caching(ctx.module)
@on_event(EventType.STREAM_STOPPED)
async def on_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
"""Begin grace period when the stream goes offline.
:param ctx: The event context.
"""
async def _grace_period(module_ctx: ModuleContext) -> None:
grace = int(module_ctx.config.get("grace_period"))
module_ctx.logger.info(f"Stream stopped. Grace period: {grace}s.")
await asyncio.sleep(grace)
await stop_caching(module_ctx)
module_ctx.logger.info("Grace period ended. Cache cleaned up.")
ctx.module.logger.debug("Stream stopped event received. Scheduling grace period.")
get_state(ctx.module).grace_task = asyncio.create_task(_grace_period(ctx.module))
@on_teardown
async def teardown(ctx: ModuleContext) -> None:
"""Clean up the clips module.
Cancels grace/expiry tasks and cleans up temp files.
:param ctx: Module context.
"""
module_state = get_state(ctx)
# 1. Cancel grace period task if running.
if module_state.grace_task is not None:
ctx.logger.debug("Cancelling grace period task during teardown.")
module_state.grace_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await module_state.grace_task
# 2. Stop caching.
await stop_caching(ctx)
# 3. Cancel all session expiry tasks (prevents races during drain).
for session in module_state.sessions.values():
if session.expiry_task is not None:
session.expiry_task.cancel()
# 4. Clean up editor sessions and their working directories.
for token in list(module_state.sessions):
cleanup_session(module_state.sessions, token)
ctx.logger.info("Clips module cleaned up.")
+337
View File
@@ -0,0 +1,337 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""HLS stream caching subsystem for the clips module.
Polls the Owncast HLS variant playlist, downloads new segments, and manages
a rolling window of cached chunks on disk.
"""
from __future__ import annotations
import asyncio
import contextlib
import os
import shutil
import tempfile
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import urljoin
import m3u8 # type: ignore[import-untyped]
if TYPE_CHECKING:
from owlbot.api import ModuleContext
from .types import get_state
@dataclass
class ChunkInfo:
"""Metadata for a single cached HLS segment."""
sequence: int
duration: float
path: Path
class ChunkCache:
"""Manages a rolling window of cached HLS stream segments.
Tracks downloaded chunks and prunes old ones when the total duration
exceeds the configured cache window.
"""
def __init__(self, cache_dir: Path, cache_duration: int) -> None:
"""Initialize the chunk cache.
:param cache_dir: Directory to store cached .ts files.
:param cache_duration: Maximum cache window in seconds.
"""
self._cache_dir = cache_dir
self._cache_duration = cache_duration
self._chunks: deque[ChunkInfo] = deque()
self._total_duration = 0.0
@property
def chunks(self) -> list[ChunkInfo]:
"""Return the list of cached chunks."""
return list(self._chunks)
@property
def total_duration(self) -> float:
"""Return total duration of cached chunks in seconds."""
return self._total_duration
@property
def last_sequence(self) -> int | None:
"""Return the sequence number of the most recent chunk, or None."""
if not self._chunks:
return None
return self._chunks[-1].sequence
def segment_path(self, sequence: int) -> Path:
"""Return the file path for a segment by sequence number.
:param sequence: HLS media sequence number.
:return: Path where the segment .ts file should be stored.
"""
return self._cache_dir / f"segment-{sequence}.ts"
def add_chunk(self, sequence: int, duration: float, path: Path) -> None:
"""Add a downloaded chunk to the cache.
:param sequence: HLS media sequence number.
:param duration: Segment duration in seconds.
:param path: Path to the downloaded .ts file.
"""
self._chunks.append(ChunkInfo(sequence=sequence, duration=duration, path=path))
self._total_duration += duration
def prune(self) -> None:
"""Remove oldest chunks until total duration is within the cache window."""
while self._total_duration > self._cache_duration and len(self._chunks) > 1:
old = self._chunks.popleft()
self._total_duration -= old.duration
if old.path.exists():
old.path.unlink()
def snapshot(self, work_dir: Path) -> tuple[list[Path], float]:
"""Hardlink current chunks into a working directory.
:param work_dir: Directory to hardlink files into.
:return: Tuple of (list of hardlinked file paths, total duration).
"""
files: list[Path] = []
duration = 0.0
for chunk in self._chunks:
if not chunk.path.exists():
continue
dest = work_dir / chunk.path.name
os.link(chunk.path, dest)
files.append(dest)
duration += chunk.duration
return files, duration
def cleanup(self) -> None:
"""Remove the cache directory and all its contents."""
if self._cache_dir.exists():
shutil.rmtree(self._cache_dir)
self._chunks.clear()
self._total_duration = 0.0
async def start_caching(ctx: ModuleContext) -> None:
"""Start the HLS caching background task.
Creates a chunk cache and launches the background polling loop
that downloads new stream segments.
:param ctx: The module context.
"""
module_state = get_state(ctx)
if module_state.cache is not None:
ctx.logger.debug("Caching already active, skipping start.")
return
owncast_url = ctx.owncast_client.base_url
master_url = f"{owncast_url.rstrip('/')}/hls/stream.m3u8"
cache_duration = int(ctx.config.get("cache_duration"))
cache_dir_path = Path(tempfile.mkdtemp(prefix="owlbot-clips-"))
cache = ChunkCache(cache_dir=cache_dir_path, cache_duration=cache_duration)
module_state.cache = cache
task = asyncio.create_task(
_polling_loop(ctx, cache, master_url),
)
module_state.cache_task = task
ctx.logger.info(f"HLS caching started. Cache dir: {cache_dir_path}")
async def stop_caching(ctx: ModuleContext) -> None:
"""Stop the HLS caching background task and clean up temp files.
:param ctx: The module context.
"""
module_state = get_state(ctx)
if module_state.cache_task is not None:
module_state.cache_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await module_state.cache_task
module_state.cache_task = None
if module_state.cache is not None:
module_state.cache.cleanup()
module_state.cache = None
ctx.logger.info("HLS cache stopped and cleaned up.")
async def _resolve_variant_url(ctx: ModuleContext, master_url: str) -> str | None:
"""Fetch and parse the master playlist, return the highest bandwidth variant URL.
:param ctx: The module context.
:param master_url: URL to the master m3u8 playlist.
:return: URL to the highest bandwidth variant playlist, or None on failure.
"""
try:
async with ctx.http.get(master_url) as resp:
if resp.status != 200:
ctx.logger.warning(f"Master playlist returned HTTP {resp.status}.")
return None
content = await resp.text()
except Exception:
ctx.logger.warning("Failed to fetch master playlist.", exc_info=True)
return None
playlist = m3u8.loads(content)
if not playlist.playlists:
ctx.logger.warning("No variant playlists found in master playlist.")
return None
# Select highest bandwidth variant.
best = max(
playlist.playlists,
key=lambda p: p.stream_info.bandwidth or 0,
)
variant_uri: str = best.uri
# Resolve relative URI against master URL.
variant_url = urljoin(master_url, variant_uri)
ctx.logger.debug(
f"Selected variant: {variant_url} "
f"(bandwidth={best.stream_info.bandwidth}, "
f"resolution={best.stream_info.resolution})"
)
return variant_url
async def _polling_loop(
ctx: ModuleContext,
cache: ChunkCache,
master_url: str,
) -> None:
"""Background task that polls the HLS variant playlist for new segments.
:param ctx: The module context.
:param cache: The ChunkCache instance to populate.
:param master_url: URL to the master m3u8 playlist.
"""
max_retries = 10
variant_url: str | None = None
for attempt in range(1, max_retries + 1):
variant_url = await _resolve_variant_url(ctx, master_url)
if variant_url is not None:
break
ctx.logger.warning(
f"Variant URL resolution failed (attempt {attempt}/{max_retries}). "
f"Retrying in 3s."
)
await asyncio.sleep(3.0)
if variant_url is None:
ctx.logger.error(
f"Could not resolve variant URL after {max_retries} attempts. "
f"Caching disabled."
)
module_state = get_state(ctx)
cache.cleanup()
module_state.cache = None
module_state.cache_task = None
return
poll_interval = 2.0 # Default, updated from playlist
while True:
try:
async with ctx.http.get(variant_url) as resp:
if resp.status != 200:
ctx.logger.warning(f"Variant playlist returned HTTP {resp.status}.")
await asyncio.sleep(poll_interval)
continue
content = await resp.text()
playlist = m3u8.loads(content)
# Update poll interval from target duration.
if playlist.target_duration:
new_interval = float(playlist.target_duration)
if new_interval != poll_interval:
ctx.logger.debug(
f"Poll interval updated: {poll_interval:.1f}s -> "
f"{new_interval:.1f}s."
)
poll_interval = new_interval
media_sequence = playlist.media_sequence or 0
for i, segment in enumerate(playlist.segments):
seq = media_sequence + i
# Skip already-downloaded segments.
last = cache.last_sequence
if last is not None and seq <= last:
continue
# Download the segment.
segment_url = urljoin(variant_url, segment.uri)
try:
async with ctx.http.get(segment_url) as seg_resp:
if seg_resp.status != 200:
ctx.logger.warning(
f"Segment {seq} returned HTTP {seg_resp.status}."
)
continue
data = await seg_resp.read()
except Exception:
ctx.logger.warning(
f"Failed to download segment {seq}.", exc_info=True
)
continue
chunk_path = cache.segment_path(seq)
await asyncio.to_thread(chunk_path.write_bytes, data)
cache.add_chunk(
sequence=seq,
duration=segment.duration,
path=chunk_path,
)
ctx.logger.debug(
f"Cached segment {seq} "
f"({segment.duration:.2f}s, {len(data)} bytes)."
)
# Prune old segments.
pre_count = len(cache.chunks)
cache.prune()
pruned = pre_count - len(cache.chunks)
if pruned:
ctx.logger.debug(
f"Pruned {pruned} segment(s). "
f"Cache: {len(cache.chunks)} chunks, "
f"{cache.total_duration:.1f}s total."
)
except asyncio.CancelledError:
raise
except Exception:
ctx.logger.error("Error in HLS polling loop.", exc_info=True)
await asyncio.sleep(poll_interval)
+164
View File
@@ -0,0 +1,164 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Chat commands for the clips module."""
from __future__ import annotations
import secrets
import shutil
import tempfile
from pathlib import Path
from owlbot.api import CommandContext, on_command
from .routes import schedule_session_expiry
from .types import EditorSession, get_state
@on_command("clip", cooldown=15)
async def clip_command(ctx: CommandContext) -> None:
"""Create a clip from the current stream.
Snapshots the HLS cache, generates a preview MP4, and sends the user
a link to the clip editor.
:param ctx: The command context.
"""
module = ctx.module
module_state = get_state(module)
# Check if stream is live / cache is available.
min_clip_length = int(ctx.module.config.get("min_clip_length"))
min_cache_duration = min_clip_length * 2
if (
module_state.cache is None
or module_state.cache.total_duration < min_cache_duration
):
ctx.logger.debug("Clip command rejected: not enough cached data.")
await ctx.owncast_client.send_message(
"Not enough stream data is available yet. Please try again later."
)
return
# Snapshot chunks into a working directory.
work_dir = Path(tempfile.mkdtemp(prefix="owlbot-clip-work-"))
snapshot_files, snapshot_duration = module_state.cache.snapshot(work_dir)
ctx.logger.debug(
f"Snapshot for {ctx.user.display_name}: {len(snapshot_files)} chunks, "
f"{snapshot_duration:.1f}s into {work_dir}."
)
if not snapshot_files:
shutil.rmtree(work_dir, ignore_errors=True)
await ctx.owncast_client.send_message("No stream data available for clipping.")
return
# Generate preview and send editor link.
manager = module_state.manager
preview_path = work_dir / "preview.mp4"
try:
duration = await manager.generate_preview(snapshot_files, preview_path)
except Exception:
module.logger.error("Failed to generate clip preview.", exc_info=True)
shutil.rmtree(work_dir, ignore_errors=True)
await ctx.owncast_client.send_message(
"Failed to create clip preview. Please try again."
)
return
# Clean up hardlinked chunks (preview is self-contained now).
for f in snapshot_files:
if f.exists():
f.unlink()
# Create editor session.
token = secrets.token_urlsafe(32)
session_expiry = int(module.config.get("session_expiry"))
session = EditorSession(
preview_path=preview_path,
work_dir=work_dir,
duration=duration,
creator=ctx.user.display_name,
)
module_state.sessions[token] = session
schedule_session_expiry(
module_state.sessions,
token,
session,
session_expiry,
module.logger,
)
url = ctx.routes.url_for(f"/edit/{token}")
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
f"Your clip preview is ready! Edit it here: {url}",
)
module.logger.info(
f"Clip preview generated for {ctx.user.display_name} "
f"({duration:.1f}s, token={token[:8]}...)"
)
@on_command("clips", cooldown=15)
async def clips_command(ctx: CommandContext) -> None:
"""Send the URL to the clips list page.
:param ctx: The command context.
"""
url = ctx.routes.url_for("/list")
await ctx.owncast_client.send_message(f"Clips: {url}")
@on_command("delclip", requires_moderator=True)
async def delclip_command(ctx: CommandContext) -> None:
"""Delete a clip by ID. Moderator only.
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !delclip <clip_id>")
return
try:
clip_id = int(args[0])
except ValueError:
await ctx.owncast_client.send_message("Usage: !delclip <clip_id>")
return
row = await ctx.storage.fetch_one("SELECT id FROM clips WHERE id = ?", (clip_id,))
if not row:
ctx.logger.debug(f"Clip {clip_id} not found for deletion.")
await ctx.owncast_client.send_message(f"Clip {clip_id} not found.")
return
# Delete files from disk.
clips_dir = Path(str(ctx.module.config.get("clips_dir")))
clip_path = clips_dir / f"{clip_id}.mp4"
if clip_path.exists():
clip_path.unlink()
thumbnail_path = clips_dir / f"{clip_id}.webp"
if thumbnail_path.exists():
thumbnail_path.unlink()
# Delete DB record.
await ctx.storage.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
ctx.logger.info(f"Clip {clip_id} deleted by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Clip {clip_id} deleted.")
+242
View File
@@ -0,0 +1,242 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Video processing manager for the clips module.
Serializes ffmpeg/ffprobe execution via semaphores.
"""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import logging
from pathlib import Path
class ProcessingManager:
"""Manages ffmpeg/ffprobe subprocess execution with concurrency control.
Provides semaphore-gated methods for preview generation, clip
creation, and thumbnail extraction.
"""
def __init__(self, logger: logging.Logger) -> None:
"""Initialize the processing manager.
:param logger: Logger instance scoped to the clips module.
"""
self._logger = logger
self._ffmpeg_semaphore = asyncio.Semaphore(1)
self._ffprobe_semaphore = asyncio.Semaphore(1)
async def generate_preview(
self,
chunk_files: list[Path],
output_path: Path,
) -> float:
"""Generate a preview MP4 from a list of HLS chunk files.
Acquires the ffmpeg semaphore for the concat step, then the ffprobe
semaphore for the duration probe.
:param chunk_files: Ordered list of .ts chunk file paths.
:param output_path: Where to write the output MP4.
:return: Duration of the generated preview in seconds.
:raises RuntimeError: If ffmpeg fails or no chunks provided.
"""
if not chunk_files:
msg = "ffmpeg: no chunk files provided for preview generation."
raise RuntimeError(msg)
concat_path = output_path.parent / "concat.txt"
async with self._ffmpeg_semaphore:
concat_path.write_text(
"\n".join(f"file '{f}'" for f in chunk_files),
encoding="utf-8",
)
try:
self._logger.debug(
f"Generating preview from {len(chunk_files)} chunks "
f"-> {output_path}."
)
await self._run_ffprocess(
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_path),
"-c",
"copy",
"-movflags",
"+faststart",
str(output_path),
)
finally:
if concat_path.exists():
concat_path.unlink()
async with self._ffprobe_semaphore:
duration = await self._probe_duration(output_path)
self._logger.debug(f"Preview generated ({duration:.1f}s).")
return duration
async def create_clip(
self,
preview_path: Path,
output_path: Path,
start: float,
end: float,
) -> float:
"""Cut a clip from a preview MP4.
Acquires the ffmpeg semaphore for the cut step, then the ffprobe
semaphore for the duration probe.
:param preview_path: Path to the preview MP4.
:param output_path: Where to write the final clip MP4.
:param start: Start time in seconds.
:param end: End time in seconds.
:return: Duration of the created clip in seconds.
:raises RuntimeError: If ffmpeg fails.
"""
async with self._ffmpeg_semaphore:
self._logger.debug(
f"Cutting clip {start:.1f}-{end:.1f}s "
f"from {preview_path} -> {output_path}."
)
await self._run_ffprocess(
"ffmpeg",
"-y",
"-ss",
str(start),
"-to",
str(end),
"-i",
str(preview_path),
"-c",
"copy",
"-movflags",
"+faststart",
str(output_path),
)
async with self._ffprobe_semaphore:
duration = await self._probe_duration(output_path)
self._logger.debug(f"Clip created ({duration:.1f}s).")
return duration
async def generate_thumbnail(
self,
clip_path: Path,
output_path: Path,
*,
duration: float,
) -> None:
"""Extract a single frame from the middle of a clip as a WebP thumbnail.
Acquires the ffmpeg semaphore for the extraction step.
:param clip_path: Path to the source MP4 clip.
:param output_path: Where to write the output WebP image.
:param duration: Duration of the clip in seconds (used to find midpoint).
:raises RuntimeError: If ffmpeg fails.
"""
midpoint = duration / 2
async with self._ffmpeg_semaphore:
self._logger.debug(
f"Generating thumbnail at {midpoint:.1f}s -> {output_path}."
)
await self._run_ffprocess(
"ffmpeg",
"-y",
"-ss",
str(midpoint),
"-i",
str(clip_path),
"-frames:v",
"1",
"-f",
"webp",
str(output_path),
)
self._logger.debug(f"Thumbnail generated: {output_path}.")
async def _run_ffprocess(
self,
*args: str,
) -> tuple[bytes, bytes]:
"""Run an ffmpeg/ffprobe subprocess with cancellation safety.
If the calling coroutine is cancelled (e.g. by a handler timeout),
the subprocess is killed before re-raising.
:param args: Command and arguments (e.g. "ffmpeg", "-y", ...).
:return: Tuple of (stdout, stderr) bytes.
:raises RuntimeError: If the process returns non-zero.
"""
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await proc.communicate()
except asyncio.CancelledError:
proc.kill()
await proc.wait()
raise
if proc.returncode != 0:
stderr_text = stderr.decode(errors="replace").strip()
if stderr_text:
self._logger.warning("%s stderr:\n%s", args[0], stderr_text)
msg = f"{args[0]} failed (exit {proc.returncode})"
raise RuntimeError(msg)
return stdout, stderr
async def _probe_duration(
self,
file_path: Path,
) -> float:
"""Get the duration of a media file using ffprobe.
:param file_path: Path to the media file.
:return: Duration in seconds.
:raises RuntimeError: If ffprobe fails.
"""
stdout, _ = await self._run_ffprocess(
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
str(file_path),
)
data = json.loads(stdout)
return float(data["format"]["duration"])
+445
View File
@@ -0,0 +1,445 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""HTTP routes for the clips module."""
from __future__ import annotations
import asyncio
import math
import shutil
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from aiohttp import web
from owlbot.api import RouteContext, on_route
from .types import EditorSession, get_state
if TYPE_CHECKING:
import logging
_EDITOR_JS_PATH = Path(__file__).resolve().parent / "static" / "editor.js"
def _validate_clip_params(
*,
start: float,
end: float,
preview_duration: float,
min_length: int,
max_length: int,
) -> str | None:
"""Validate clip start/end parameters.
:param start: Start time in seconds.
:param end: End time in seconds.
:param preview_duration: Duration of the preview in seconds.
:param min_length: Minimum clip length in seconds.
:param max_length: Maximum clip length in seconds.
:return: Error message string, or None if valid.
"""
if not math.isfinite(start) or not math.isfinite(end):
return "Invalid start or end time."
if start < 0:
return "Start time cannot be negative."
if end <= start:
return "End time must be after start time."
if end > preview_duration:
return f"End time exceeds preview duration ({preview_duration:.1f}s)."
duration = end - start
if duration < min_length:
return f"Clip is too short. Minimum length is {min_length} seconds."
if duration > max_length:
return f"Clip is too long. Maximum length is {max_length} seconds."
return None
def _get_session(ctx: RouteContext, token: str) -> EditorSession | None:
"""Look up an editor session by token, returning None if missing.
:param ctx: The route context.
:param token: The editor session token.
:return: The session, or None.
"""
sessions = get_state(ctx.module).sessions
session = sessions.get(token)
if session is None:
ctx.module.logger.debug(
f"Session lookup failed: token={token[:8]}... not found."
)
return None
return session
def cleanup_session(sessions: dict[str, EditorSession], token: str) -> None:
"""Remove a session and clean up its working directory.
:param sessions: The sessions dict from module state.
:param token: The session token to remove.
"""
session = sessions.pop(token, None)
if session is not None:
if session.expiry_task is not None:
session.expiry_task.cancel()
shutil.rmtree(session.work_dir, ignore_errors=True)
def schedule_session_expiry(
sessions: dict[str, EditorSession],
token: str,
session: EditorSession,
delay: float,
logger: logging.Logger,
) -> None:
"""Schedule a task that cleans up the session after *delay* seconds.
Any existing expiry task on *session* is cancelled first.
:param sessions: The sessions dict from module state.
:param token: The session token.
:param session: The editor session.
:param delay: Seconds until expiry.
:param logger: Logger instance for debug messages.
"""
if session.expiry_task is not None:
session.expiry_task.cancel()
async def _expire() -> None:
await asyncio.sleep(delay)
if sessions.get(token) is not session:
return
logger.debug(f"Session expired: token={token[:8]}...")
cleanup_session(sessions, token)
session.expiry_task = asyncio.create_task(_expire())
def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
"""Render an error page with navigation back to the clips list.
:param ctx: The route context.
:param status: HTTP status code.
:param message: Error message to display.
:return: HTML error response.
"""
page = ctx.templates.render(
"error.html",
message=message,
list_url=ctx.routes.url_for("/list"),
)
return web.Response(status=status, text=page, content_type="text/html")
@on_route("/edit/{token}", methods=["GET"])
async def editor_page(ctx: RouteContext) -> web.Response:
"""Serve the clip editor page.
:param ctx: The route context.
:return: HTML response with the editor, or error page.
"""
token = ctx.match_info["token"]
session = _get_session(ctx, token)
if session is None:
return _error_page(ctx, 404, "Session not found or expired.")
preview_url = ctx.routes.url_for(f"/edit/{token}/preview")
min_length = int(ctx.config.get("min_clip_length"))
max_length = int(ctx.config.get("max_clip_length"))
duration = session.duration
# Clamp max_length to actual preview duration.
effective_max = min(max_length, int(duration))
page = ctx.templates.render(
"editor.html",
preview_url=preview_url,
duration=duration,
min_length=min_length,
max_length=effective_max,
token=token,
submit_url=ctx.routes.url_for(f"/edit/{token}"),
list_url=ctx.routes.url_for("/list"),
editor_js_url=ctx.routes.url_for("/static/editor.js"),
)
return web.Response(text=page, content_type="text/html")
@on_route("/edit/{token}/preview", methods=["GET"])
async def editor_preview_video(ctx: RouteContext) -> web.StreamResponse:
"""Serve the preview video file for the clip editor.
:param ctx: The route context.
:return: The preview MP4 as a streaming response.
"""
token = ctx.match_info["token"]
session = _get_session(ctx, token)
if session is None:
return web.Response(status=404)
if not session.preview_path.exists():
return web.Response(status=404)
return web.FileResponse(session.preview_path)
@on_route("/static/editor.js", methods=["GET"])
async def editor_js(ctx: RouteContext) -> web.StreamResponse:
"""Serve the clip editor JavaScript.
:param ctx: The route context.
:return: The editor.js file with caching headers.
"""
return web.FileResponse(
_EDITOR_JS_PATH,
headers={"Cache-Control": "max-age=86400"},
)
@on_route("/edit/{token}", methods=["POST"])
async def editor_submit(ctx: RouteContext) -> web.Response:
"""Handle clip editor form submission.
Validates parameters, processes the clip inline with ffmpeg, and
redirects to the finished clip page.
:param ctx: The route context.
:return: Redirect to the clip page, or error response.
"""
token = ctx.match_info["token"]
session = _get_session(ctx, token)
if session is None:
return _error_page(ctx, 404, "Session not found or expired.")
data = await ctx.request.post()
try:
start = float(str(data.get("start", "0")))
end = float(str(data.get("end", "0")))
except ValueError:
return _error_page(ctx, 400, "Invalid start or end time.")
title = str(data.get("title", "")).strip()[:200] or None
preview_duration = session.duration
min_length = int(ctx.config.get("min_clip_length"))
max_length = int(ctx.config.get("max_clip_length"))
error = _validate_clip_params(
start=start,
end=end,
preview_duration=preview_duration,
min_length=min_length,
max_length=max_length,
)
if error is not None:
ctx.logger.debug(f"Clip submit validation failed: {error}")
return _error_page(ctx, 400, error)
# Remove session to prevent double-submission.
sessions = get_state(ctx.module).sessions
sessions.pop(token, None)
if session.expiry_task is not None:
session.expiry_task.cancel()
clips_dir = Path(str(ctx.config.get("clips_dir")))
work_dir = session.work_dir
manager = get_state(ctx.module).manager
clip_id: int | None = None
clip_path: Path | None = None
try:
# Cut clip from preview.
temp_clip_path = work_dir / "clip.mp4"
actual_duration = await manager.create_clip(
session.preview_path,
temp_clip_path,
start,
end,
)
# Insert DB row.
cursor = await ctx.storage.execute(
"INSERT INTO clips (title, creator, created_at, duration) "
"VALUES (?, ?, ?, ?)",
(title, session.creator, datetime.now(UTC).isoformat(), actual_duration),
)
clip_id = cursor.lastrowid
if clip_id is None:
msg = "Failed to retrieve last inserted row ID."
raise RuntimeError(msg)
# Move to final location.
clip_path = clips_dir / f"{clip_id}.mp4"
shutil.move(temp_clip_path, clip_path)
# Generate thumbnail (best-effort).
thumbnail_path = clips_dir / f"{clip_id}.webp"
try:
await manager.generate_thumbnail(
clip_path,
thumbnail_path,
duration=actual_duration,
)
except Exception:
ctx.logger.warning(
f"Thumbnail generation failed for clip {clip_id}.",
exc_info=True,
)
thumbnail_path.unlink(missing_ok=True)
ctx.logger.info(f"Clip {clip_id} ready ({actual_duration:.1f}s).")
return web.HTTPFound(ctx.routes.url_for(f"/clip/{clip_id}"))
except Exception:
ctx.logger.error("Failed to create clip.", exc_info=True)
# Clean up partial state to avoid orphaned DB rows or files.
if clip_id is not None:
try:
await ctx.storage.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
except Exception:
ctx.logger.warning(
f"Failed to clean up DB row for clip {clip_id}.",
exc_info=True,
)
if clip_path is not None:
clip_path.unlink(missing_ok=True)
return _error_page(ctx, 500, "Clip processing failed.")
finally:
shutil.rmtree(work_dir, ignore_errors=True)
@on_route("/clip/{clip_id}", methods=["GET"])
async def clip_page(ctx: RouteContext) -> web.Response:
"""Serve an individual clip page.
:param ctx: The route context.
:return: HTML response with the clip page.
"""
try:
clip_id = int(ctx.match_info["clip_id"])
except ValueError:
return _error_page(ctx, 404, "Clip not found.")
row = await ctx.storage.fetch_one(
"SELECT id, title, creator, created_at FROM clips WHERE id = ?",
(clip_id,),
)
if row is None:
return _error_page(ctx, 404, "Clip not found.")
video_url = ctx.routes.url_for(f"/clip/{clip_id}/video")
page = ctx.templates.render(
"clip.html",
clip_id=row["id"],
title=row["title"],
creator=row["creator"],
created_at=row["created_at"],
video_url=video_url,
list_url=ctx.routes.url_for("/list"),
)
return web.Response(text=page, content_type="text/html")
@on_route("/clip/{clip_id}/video", methods=["GET"])
async def clip_video(ctx: RouteContext) -> web.StreamResponse:
"""Serve a clip video file.
:param ctx: The route context.
:return: The MP4 file as a streaming response.
"""
try:
clip_id = int(ctx.match_info["clip_id"])
except ValueError:
return web.Response(status=404)
row = await ctx.storage.fetch_one(
"SELECT id FROM clips WHERE id = ?",
(clip_id,),
)
if row is None:
return web.Response(status=404)
clips_dir = Path(str(ctx.config.get("clips_dir")))
clip_path = clips_dir / f"{clip_id}.mp4"
if not clip_path.exists():
return web.Response(status=404)
return web.FileResponse(clip_path)
@on_route("/clip/{clip_id}/thumbnail", methods=["GET"])
async def clip_thumbnail(ctx: RouteContext) -> web.StreamResponse:
"""Serve a clip thumbnail image.
:param ctx: The route context.
:return: The WebP image as a response, or 404.
"""
try:
clip_id = int(ctx.match_info["clip_id"])
except ValueError:
return web.Response(status=404)
row = await ctx.storage.fetch_one(
"SELECT id FROM clips WHERE id = ?",
(clip_id,),
)
if row is None:
return web.Response(status=404)
clips_dir = Path(str(ctx.config.get("clips_dir")))
thumbnail_path = clips_dir / f"{clip_id}.webp"
if not thumbnail_path.exists():
return web.Response(status=404)
return web.FileResponse(thumbnail_path)
@on_route("/list", methods=["GET"])
async def clips_list_page(ctx: RouteContext) -> web.Response:
"""Serve a page listing all clips.
:param ctx: The route context.
:return: HTML response with the clips list.
"""
rows = await ctx.storage.fetch_all(
"SELECT id, title, created_at FROM clips ORDER BY created_at DESC"
)
clips = [
{
"id": row["id"],
"title": row["title"] or f"Clip #{row['id']}",
"created_at": datetime.fromisoformat(row["created_at"]).strftime(
"%B %-d, %Y"
),
"url": ctx.routes.url_for(f"/clip/{row['id']}"),
"thumbnail_url": ctx.routes.url_for(f"/clip/{row['id']}/thumbnail"),
}
for row in rows
]
page = ctx.templates.render("list.html", clips=clips)
return web.Response(text=page, content_type="text/html")
@@ -0,0 +1,217 @@
(function() {
// Configuration
const cfg = window.EDITOR_CONFIG;
const DURATION = cfg.duration;
const MIN_LENGTH = cfg.minLength;
const MAX_LENGTH = cfg.maxLength;
// DOM References
const video = document.getElementById("video");
const slider = document.getElementById("slider");
const track = document.getElementById("track");
const handleStart = document.getElementById("handle-start");
const handleEnd = document.getElementById("handle-end");
const startTimeEl = document.getElementById("start-time");
const endTimeEl = document.getElementById("end-time");
const clipLengthEl = document.getElementById("clip-length");
const playhead = document.getElementById("playhead");
const btnPreview = document.getElementById("btn-preview");
const pctEl = document.getElementById("loading-pct");
const loadingEl = document.getElementById("loading");
// Utilities
function formatTime(s) {
s = Math.round(s);
const m = Math.floor(s / 60);
const sec = s % 60;
return m + ":" + (sec < 10 ? "0" : "") + sec;
}
function clamp(val, min, max) {
return Math.max(min, Math.min(max, val));
}
function pctToTime(pct) {
return Math.round(clamp(pct, 0, 1) * DURATION);
}
function getSliderPct(e) {
const rect = slider.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width;
}
// Video Loading
function showLoadError(message) {
const alert = document.createElement("div");
alert.className = "alert alert-danger";
alert.setAttribute("role", "alert");
alert.textContent = message;
const link = document.createElement("a");
link.href = cfg.listUrl;
link.textContent = "Return to clips list";
loadingEl.textContent = "";
loadingEl.appendChild(alert);
loadingEl.appendChild(link);
}
const xhr = new XMLHttpRequest();
xhr.open("GET", cfg.previewUrl);
xhr.responseType = "blob";
xhr.onprogress = function(e) {
if (e.lengthComputable) {
pctEl.textContent = Math.round((e.loaded / e.total) * 100) + "%";
}
};
xhr.onerror = function() {
showLoadError("Failed to load the video preview. Please check your connection and try again.");
};
xhr.onload = function() {
if (xhr.status !== 200) {
showLoadError("This editor session has expired or is no longer available.");
return;
}
video.src = URL.createObjectURL(xhr.response);
video.addEventListener("loadedmetadata", function() {
video.currentTime = startTime;
}, { once: true });
loadingEl.classList.add("d-none");
document.getElementById("editor-controls").classList.remove("d-none");
};
xhr.send();
// Slider State & UI
const clipLength = Math.min(MAX_LENGTH, DURATION);
let startTime = DURATION - clipLength;
let endTime = DURATION;
function updateUI() {
const startPct = (startTime / DURATION) * 100;
const endPct = (endTime / DURATION) * 100;
handleStart.style.left = startPct + "%";
handleEnd.style.left = endPct + "%";
track.style.left = startPct + "%";
track.style.width = (endPct - startPct) + "%";
startTimeEl.textContent = formatTime(startTime);
endTimeEl.textContent = formatTime(endTime);
clipLengthEl.textContent = formatTime(endTime - startTime);
}
function updatePlayhead() {
const pct = (video.currentTime / DURATION) * 100;
playhead.style.left = pct + "%";
}
// Slider Interaction
let dragging = null;
let dragOffset = 0;
function onPointerDown(e) {
const target = e.target;
if (target === handleStart) {
dragging = "start";
} else if (target === handleEnd) {
dragging = "end";
} else if (target === track) {
dragging = "track";
dragOffset = pctToTime(getSliderPct(e)) - startTime;
}
if (dragging) {
e.preventDefault();
if (previewActive) {
video.pause();
setPreviewActive(false);
}
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
}
}
function onPointerMove(e) {
const t = pctToTime(getSliderPct(e));
const length = endTime - startTime;
if (dragging === "start") {
startTime = clamp(t, 0, DURATION - MIN_LENGTH);
if (endTime - startTime < MIN_LENGTH) endTime = startTime + MIN_LENGTH;
if (endTime - startTime > MAX_LENGTH) endTime = startTime + MAX_LENGTH;
endTime = Math.min(endTime, DURATION);
} else if (dragging === "end") {
endTime = clamp(t, MIN_LENGTH, DURATION);
if (endTime - startTime < MIN_LENGTH) startTime = endTime - MIN_LENGTH;
if (endTime - startTime > MAX_LENGTH) startTime = endTime - MAX_LENGTH;
startTime = Math.max(startTime, 0);
} else if (dragging === "track") {
let newStart = t - dragOffset;
newStart = clamp(newStart, 0, DURATION - length);
startTime = Math.round(newStart);
endTime = startTime + length;
}
video.currentTime = dragging === "end" ? endTime : startTime;
updateUI();
}
function onPointerUp() {
dragging = null;
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
}
// Preview Playback
let previewActive = false;
function setPreviewActive(active) {
previewActive = active;
if (active) {
btnPreview.textContent = "Stop Preview";
btnPreview.classList.remove("btn-primary");
btnPreview.classList.add("btn-danger");
} else {
btnPreview.textContent = "Preview Clip";
btnPreview.classList.remove("btn-danger");
btnPreview.classList.add("btn-primary");
}
}
// Form Submission
function onCreateClick() {
document.getElementById("editor-controls").classList.add("d-none");
document.getElementById("processing").classList.remove("d-none");
document.title = "Creating Clip...";
document.querySelector(".breadcrumb-item.active").textContent = "Creating Clip";
document.querySelector("h1").textContent = "Creating Clip";
document.getElementById("form-start").value = startTime;
document.getElementById("form-end").value = endTime;
document.getElementById("form-title").value = document.getElementById("title").value;
document.getElementById("submit-form").submit();
}
// Event Bindings
slider.addEventListener("pointerdown", onPointerDown);
btnPreview.addEventListener("click", function() {
if (previewActive) {
video.pause();
setPreviewActive(false);
} else {
video.currentTime = startTime;
video.play();
setPreviewActive(true);
}
});
video.addEventListener("timeupdate", function() {
updatePlayhead();
if (previewActive && video.currentTime >= endTime) {
video.pause();
setPreviewActive(false);
}
});
document.getElementById("btn-create").addEventListener("click", onCreateClick);
// Initialization
updateUI();
})();
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}{{ title or "Clip #" ~ clip_id }}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ list_url }}">Clips</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ title or "Clip #" ~ clip_id }}</li>
</ol>
</nav>
<h1 class="mb-3">{{ title or "Clip #" ~ clip_id }}</h1>
<div class="text-body-secondary mb-3">
<p>Created by <strong>{{ creator }}</strong> on <time datetime="{{ created_at }}">{{ created_at }}</time></p>
</div>
<video class="w-100 rounded" controls preload="metadata">
<source src="{{ video_url }}" type="video/mp4">
Your browser does not support the video tag.
</video>
{% endblock %}
{% block scripts %}
<script>
document.querySelectorAll("time[datetime]").forEach(function(el) {
var d = new Date(el.getAttribute("datetime"));
var month = d.toLocaleString(undefined, { month: "long" });
var day = d.getDate();
var year = d.getFullYear();
var time = d.toLocaleString(undefined, { hour: "2-digit", minute: "2-digit" });
el.textContent = month + " " + day + ", " + year + " at " + time;
});
</script>
{% endblock %}
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Clip Editor{% endblock %}
{% block head %}
<style>
.slider-container {
position: relative;
height: 40px;
background: var(--bs-tertiary-bg);
border-radius: 8px;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
user-select: none;
touch-action: none;
}
.slider-track {
position: absolute;
top: 0; bottom: 0;
background: rgba(var(--bs-primary-rgb), 0.15);
border-top: 2px solid rgba(var(--bs-primary-rgb), 0.5);
border-bottom: 2px solid rgba(var(--bs-primary-rgb), 0.5);
cursor: grab;
}
.slider-handle {
position: absolute;
top: -4px; bottom: -4px;
width: 12px;
background: var(--bs-primary);
border-radius: 4px;
cursor: ew-resize;
z-index: 2;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
transform: translateX(-50%);
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.slider-handle:hover {
transform: translateX(-50%) scaleY(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.slider-handle::after {
content: '';
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 4px; height: 16px;
border-left: 1px solid rgba(255, 255, 255, 0.3);
border-right: 1px solid rgba(255, 255, 255, 0.3);
}
.slider-playhead {
position: absolute;
top: -2px; bottom: -2px;
width: 2px;
background: var(--bs-danger);
border-radius: 1px;
z-index: 3;
pointer-events: none;
transform: translateX(-50%);
}
</style>
{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ list_url }}">Clips</a></li>
<li class="breadcrumb-item active" aria-current="page">Clip Editor</li>
</ol>
</nav>
<h1 class="mb-3">Clip Editor</h1>
<div class="text-center py-5" id="loading">
<div class="spinner-border mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Loading preview...<br><span id="loading-pct"></span></p>
</div>
<div id="processing" class="text-center py-5 d-none">
<div class="spinner-border mb-3" role="status">
<span class="visually-hidden">Creating clip...</span>
</div>
<p>Your clip is being created...<br>You will be redirected automatically when it is ready.</p>
</div>
<div id="editor-controls" class="d-none">
<video class="w-100 rounded mb-3" id="video"></video>
<div class="slider-container mb-3" id="slider">
<div class="slider-track" id="track"></div>
<div class="slider-handle start" id="handle-start"></div>
<div class="slider-handle end" id="handle-end"></div>
<div class="slider-playhead" id="playhead"></div>
</div>
<div class="d-flex justify-content-between font-monospace small mb-3">
<span>Start: <strong id="start-time">0:00</strong></span>
<span>Length: <strong id="clip-length">0:00</strong></span>
<span>End: <strong id="end-time">0:00</strong></span>
</div>
<div class="text-center mb-3">
<button type="button" class="btn btn-primary" id="btn-preview">Preview Clip</button>
</div>
<div class="mb-3">
<label for="title" class="form-label">Title (optional)</label>
<input type="text" class="form-control" id="title" name="title" placeholder="Enter a title for your clip" maxlength="200">
</div>
<div class="mb-3">
<button type="button" class="btn btn-success" id="btn-create">Create Clip</button>
</div>
<form id="submit-form" method="POST" action="{{ submit_url }}" class="d-none">
<input type="hidden" name="start" id="form-start">
<input type="hidden" name="end" id="form-end">
<input type="hidden" name="title" id="form-title">
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
window.EDITOR_CONFIG = {
previewUrl: {{ preview_url | tojson }},
submitUrl: {{ submit_url | tojson }},
listUrl: {{ list_url | tojson }},
duration: {{ duration | tojson }},
minLength: {{ min_length | tojson }},
maxLength: {{ max_length | tojson }}
};
</script>
<script src="{{ editor_js_url }}"></script>
{% endblock %}
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Error{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ list_url }}">Clips</a></li>
<li class="breadcrumb-item active" aria-current="page">Error</li>
</ol>
</nav>
<h1 class="mb-3">Error</h1>
<div class="alert alert-danger" role="alert">{{ message }}</div>
<a href="{{ list_url }}">Return to clips list</a>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Clips{% endblock %}
{% block head %}
<style>
.clip-thumb {
aspect-ratio: 16 / 9;
object-fit: cover;
}
</style>
{% endblock %}
{% block content %}
<h1 class="mb-3">Clips</h1>
{% if clips %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for clip in clips %}
<div class="col">
<a href="{{ clip.url }}" class="text-decoration-none">
<div class="card h-100">
<img src="{{ clip.thumbnail_url }}" class="card-img-top clip-thumb" alt="{{ clip.title }}">
<div class="card-body">
<h5 class="card-title">{{ clip.title }}</h5>
<p class="card-text text-body-secondary">{{ clip.created_at }}</p>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
{% else %}
<p>No clips have been created yet.</p>
{% endif %}
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Typed state containers for the clips module."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import asyncio
from pathlib import Path
from owlbot.api import ModuleContext
from .cache import ChunkCache
from .processing import ProcessingManager
@dataclass
class EditorSession:
"""A clip editor session."""
preview_path: Path
work_dir: Path
duration: float
creator: str
expiry_task: asyncio.Task[None] | None = None
@dataclass
class ModuleState:
"""Runtime state for the clips module."""
cache: ChunkCache | None = None
sessions: dict[str, EditorSession] = field(default_factory=dict)
grace_task: asyncio.Task[None] | None = None
cache_task: asyncio.Task[None] | None = None
_manager: ProcessingManager | None = None
@property
def manager(self) -> ProcessingManager:
"""Return the processing manager, raising if not yet initialized.
:return: The active ProcessingManager.
:raises RuntimeError: If the manager has not been set.
"""
if self._manager is None:
msg = "ProcessingManager is not initialized."
raise RuntimeError(msg)
return self._manager
@manager.setter
def manager(self, value: ProcessingManager | None) -> None:
self._manager = value
def get_state(ctx: ModuleContext) -> ModuleState:
"""Retrieve the ModuleState from the module context.
:param ctx: The module context.
:return: The ModuleState instance.
:raises RuntimeError: If ModuleState has not been initialized.
"""
state = ctx.state.get("clips")
if not isinstance(state, ModuleState):
raise RuntimeError("ModuleState is not initialized.")
return state
+1
View File
@@ -12,6 +12,7 @@ dependencies = [
"aiosqlite>=0.22.1",
"cronsim>=2.7",
"jinja2>=3.1.6",
"m3u8>=6.0.0",
"pyyaml>=6.0.3",
]
Generated
+11
View File
@@ -567,6 +567,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
]
[[package]]
name = "m3u8"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -844,6 +853,7 @@ dependencies = [
{ name = "aiosqlite" },
{ name = "cronsim" },
{ name = "jinja2" },
{ name = "m3u8" },
{ name = "pyyaml" },
]
@@ -866,6 +876,7 @@ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.22.1" },
{ name = "cronsim", specifier = ">=2.7" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "m3u8", specifier = ">=6.0.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
]