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
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:
+5
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -16,6 +16,7 @@
|
||||
|
||||
BUILTIN_MODULE_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"clips",
|
||||
"custom_commands",
|
||||
"quotes",
|
||||
"timers",
|
||||
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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"])
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"aiosqlite>=0.22.1",
|
||||
"cronsim>=2.7",
|
||||
"jinja2>=3.1.6",
|
||||
"m3u8>=6.0.0",
|
||||
"pyyaml>=6.0.3",
|
||||
]
|
||||
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user