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
|
*.swo
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
data/*.db
|
data/
|
||||||
logs/
|
logs/
|
||||||
modules/
|
modules/
|
||||||
|
|
||||||
|
# Test / coverage artifacts
|
||||||
|
.coverage
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
# Local configuration (keep config.yaml.example in repo)
|
# Local configuration (keep config.yaml.example in repo)
|
||||||
config.yaml
|
config.yaml
|
||||||
|
|||||||
@@ -72,6 +72,26 @@ owncast:
|
|||||||
# Modules can define their own config keys via ctx.config.register_defaults()
|
# Modules can define their own config keys via ctx.config.register_defaults()
|
||||||
# in their setup() function.
|
# in their setup() function.
|
||||||
modules:
|
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:
|
# custom_commands:
|
||||||
# # Default cooldown in seconds for newly created custom commands.
|
# # Default cooldown in seconds for newly created custom commands.
|
||||||
# # Default: 5
|
# # Default: 5
|
||||||
|
|||||||
+1
-1
Submodule docs updated: b26ca8b75f...988cecbabb
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
BUILTIN_MODULE_NAMES: frozenset[str] = frozenset(
|
BUILTIN_MODULE_NAMES: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
|
"clips",
|
||||||
"custom_commands",
|
"custom_commands",
|
||||||
"quotes",
|
"quotes",
|
||||||
"timers",
|
"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",
|
"aiosqlite>=0.22.1",
|
||||||
"cronsim>=2.7",
|
"cronsim>=2.7",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
|
"m3u8>=6.0.0",
|
||||||
"pyyaml>=6.0.3",
|
"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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
@@ -844,6 +853,7 @@ dependencies = [
|
|||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "cronsim" },
|
{ name = "cronsim" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
|
{ name = "m3u8" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -866,6 +876,7 @@ requires-dist = [
|
|||||||
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
||||||
{ name = "cronsim", specifier = ">=2.7" },
|
{ name = "cronsim", specifier = ">=2.7" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
|
{ name = "m3u8", specifier = ">=6.0.0" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user