diff --git a/README.md b/README.md index 513bfbd..8bc2e72 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ Evanescere turns recorded livestreams into suggested, rendered clips: 1. Poll IIS WebDAV for finished recordings. -2. Wait until file size is unchanged across polling cycles. -3. Download the stable source file to Framework-local storage. -4. Remux FLV/H264 to MP4 and extract ASR-ready audio. -5. Transcribe Mandarin audio through a FunASR-compatible API. -6. Ask DeepSeek for ranked timeline-aware clip suggestions. -7. Generate subtitles, render clips, generate thumbnails with optional local image generation and VTuber overlay, and optionally upload. +2. Record files visible during the first scan as a manual-only historical baseline. +3. For files first seen later, wait until file size is unchanged across polling cycles. +4. Download the stable source file to Framework-local storage. +5. Remux FLV/H264 to MP4 and extract ASR-ready audio. +6. Transcribe Mandarin audio through a FunASR-compatible API. +7. Ask DeepSeek for ranked timeline-aware clip suggestions. +8. Generate subtitles, render clips, generate thumbnails with optional local image generation and VTuber overlay, and optionally upload. The Framework Arch server is the intended production host. The Hyper-V Arch VM can be used for development, API testing, and database/control-plane work. @@ -28,7 +29,8 @@ Runtime data flow: ```text IIS WebDAV recordings - -> scheduler polls file size + -> first scan records existing files as manual-only baseline entries + -> later scans poll new file sizes -> PostgreSQL records video state -> Redis queues pipeline job -> worker downloads source to configured storage.local_root @@ -67,8 +69,8 @@ The checked-in `docker-compose.yml` mounts local `./config.toml` there for the A ## Services And Ports - Frontend: `http://localhost:3000` -- Backend API: `http://localhost:8000` -- API docs: `http://localhost:8000/docs` +- Backend API: `http://localhost:8080` +- API docs: `http://localhost:8080/docs` - Redis: `localhost:6379` when exposed by Compose - PostgreSQL: only started by Compose when using the `local-db` profile @@ -92,12 +94,14 @@ Run database migrations: docker compose run --rm api alembic upgrade head ``` -Bootstrap existing WebDAV files so old recordings are marked `existing_done` instead of auto-processed: +The scheduler automatically records files visible during its first scan as `existing_done`. Those historical recordings appear in the UI but are not automatically processed. To establish that baseline manually before starting the scheduler: ```bash docker compose run --rm api evanescere bootstrap-existing ``` +In the frontend, click `Sync WebDAV` to perform the same safe first-time baseline import or to discover later files. Select any historical FLV row and click its play button to manually queue a test pipeline run. + Useful test commands: ```bash @@ -141,7 +145,7 @@ The frontend reads the backend URL from `frontend/public/config.js` at runtime: ```js window.__EVANESCERE_FRONTEND_CONFIG__ = { - apiBaseUrl: "http://localhost:8000" + apiBaseUrl: "http://192.168.1.44:8080" }; ``` @@ -181,11 +185,11 @@ provider = "frame_overlay" This uses the extracted frame as the background, then programmatically overlays the title and character PNG using Pillow. -To overlay the VTuber character, use a transparent PNG: +To overlay the VTuber character, place transparent PNG variants in a directory. Evanescere picks one randomly for each thumbnail: ```toml [thumbnail] -character_overlay_path = "/data/evanescere/assets/vtuber.png" +character_overlay_dir = "/data/evanescere/assets/" character_scale = 0.42 character_position = "bottom-right" ``` @@ -277,6 +281,8 @@ The full commented example lives in `config.example.toml`. These are the keys th | `deepseek` | `api_key` | empty | DeepSeek API key. Required when clip suggestion is enabled. | | `deepseek` | `model` | `deepseek-v4-pro` | Model used for clip suggestion. | | `deepseek` | `temperature` | `0.2` | Sampling temperature for clip suggestion. | +| `llm_prompt` | `system` | VTuber clip editor prompt | Custom system prompt for clip selection. The required JSON schema is appended by code. | +| `llm_prompt` | `user` | timestamped transcript prompt | Custom user prompt template. Supports documented placeholders. | | `defaults` | `suggest_enabled` | `true` | Initial setting for automatic LLM clip suggestion. | | `defaults` | `render_enabled` | `true` | Initial setting for automatic rendering. | | `defaults` | `upload_enabled` | `true` | Initial setting for automatic upload after render. For testing, set false. | @@ -285,11 +291,12 @@ The full commented example lives in `config.example.toml`. These are the keys th | `clip` | `min_seconds` | `30` | Minimum LLM clip duration accepted by backend. | | `clip` | `max_seconds` | `360` | Maximum LLM clip duration accepted by backend. | | `clip` | `transcript_chunk_seconds` | `900` | Transcript seconds sent to DeepSeek per request. | +| `clip` | `max_candidates_total` | `20` | Maximum suggestions kept across the entire transcript after overlap deduplication. | | `thumbnail` | `enabled` | `true` | Enables thumbnail generation during clip render. | | `thumbnail` | `provider` | `frame_overlay` | `frame_overlay` or `command`. | | `thumbnail` | `width` | `1920` | Final thumbnail width in pixels. | | `thumbnail` | `height` | `1080` | Final thumbnail height in pixels. | -| `thumbnail` | `character_overlay_path` | empty | Optional transparent PNG of the VTuber character. | +| `thumbnail` | `character_overlay_dir` | `/data/evanescere/assets/` | Directory of transparent VTuber PNG variants. One is selected randomly per thumbnail. | | `thumbnail` | `character_scale` | `0.42` | Character overlay height as a fraction of final thumbnail height. | | `thumbnail` | `character_position` | `bottom-right` | Character placement. | | `thumbnail` | `title_enabled` | `true` | Draws the clip title onto the final thumbnail. | @@ -324,12 +331,42 @@ docker compose run --rm api Useful API endpoints for generated files: +- `POST /webdav/scan` - `GET /videos/{video_id}/artifacts` - `GET /clips/{clip_id}/artifacts` - `GET /artifacts/{artifact_id}` Thumbnail outputs are visible through the artifact endpoints. Upload command payloads include `thumbnail` when a `thumbnail_final` artifact exists. +## Custom LLM Prompt + +Edit `[llm_prompt]` in your private `config.toml` to tune clip selection without rebuilding containers: + +```toml +[llm_prompt] +system = """ +Your custom editorial guidance. +""" + +user = """ +Choose up to {max_candidates} clips between {min_clip_seconds} and {max_clip_seconds} seconds. +Stream range: {chunk_start_sec}-{chunk_end_sec} +Transcript: +{transcript} +""" +``` + +Available user-prompt placeholders: + +- `{max_candidates}` +- `{min_clip_seconds}` +- `{max_clip_seconds}` +- `{chunk_start_sec}` +- `{chunk_end_sec}` +- `{transcript}` + +The backend appends its strict JSON response schema to the system message so custom prompt wording cannot accidentally remove the machine-readable output contract. + ## Debugging Set verbose logs in `config.toml`: @@ -352,6 +389,7 @@ docker compose logs -f api What to look for: - Scheduler logs `webdav propfind done`, `new webdav file observed`, and `webdav file stable`. +- On first startup, scheduler logs `webdav baseline missing` and stores all currently visible recordings as `existing_done`. - Worker logs `pipeline start`, `prepare media`, `transcription start`, `clip suggestion start`, `render start`, and `upload start`. - ffmpeg command failures include the failing command and stderr tail. - DeepSeek logs include transcript chunk bounds, character counts, candidate counts, and usage when returned by the SDK. @@ -371,4 +409,4 @@ This is intentionally noisy and should usually stay off in production. - FunASR/ROCm setup is intentionally outside the main Compose file for now. Benchmark the Framework host manually before binding the project to a specific GPU runtime. - The uploader adapter is deliberately small. `[upload].adapter = "command"` is enough to integrate a Bilibili uploader later without changing the pipeline core. -- Existing recordings should be bootstrapped before scheduler-driven production runs, otherwise old stable files may be queued as new work. +- Existing recordings are automatically baselined on the first scheduler scan. The explicit `evanescere bootstrap-existing` command remains available when you want to establish or refresh the baseline before starting services. diff --git a/config.example.toml b/config.example.toml index ee3bb62..8a2528c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -74,6 +74,30 @@ model = "deepseek-v4-pro" # Lower values improve consistency. temperature = 0.2 +[llm_prompt] +# Customize these prompts freely. The backend appends the required JSON response schema. +system = """ +You are an expert editor for Mandarin VTuber livestream clips. +Identify moments that work as entertaining standalone clips for Bilibili viewers. +Prioritize strong reactions, jokes, surprising turns, memorable conversations, and moments with a clear payoff. +Avoid repetitive stretches, dead air, and segments that require too much missing context. +Return only the requested JSON object. +""" + +# Available placeholders: +# {max_candidates}, {min_clip_seconds}, {max_clip_seconds}, +# {chunk_start_sec}, {chunk_end_sec}, {transcript} +user = """ +Review the timestamped livestream transcript below. +Choose up to {max_candidates} compelling clip candidates. +Each clip must be between {min_clip_seconds} and {max_clip_seconds} seconds long. +Use absolute stream timestamps and give each candidate a concise Chinese title and summary. + +Transcript range: {chunk_start_sec}-{chunk_end_sec} +Transcript: +{transcript} +""" + [defaults] # Initial automatic pipeline settings. These can later be changed through the API/UI. suggest_enabled = true @@ -91,7 +115,10 @@ min_seconds = 30 max_seconds = 360 # Transcript seconds per DeepSeek request. Larger chunks use more tokens. -transcript_chunk_seconds = 900 +transcript_chunk_seconds = 14400 + +# Maximum number of suggestions kept across the entire transcript. +max_candidates_total = 20 [thumbnail] # Generate thumbnails during clip render. @@ -104,8 +131,8 @@ provider = "frame_overlay" width = 1920 height = 1080 -# Optional transparent PNG of the VTuber character. -character_overlay_path = "" +# Directory of transparent VTuber character PNGs. One is selected randomly per thumbnail. +character_overlay_dir = "/data/evanescere/assets/" # Character height as a fraction of thumbnail height. character_scale = 0.42 diff --git a/docker-compose.yml b/docker-compose.yml index 0df4959..7dbae88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: build: . command: uvicorn evanescere.api:app --host 0.0.0.0 --port 8000 ports: - - "8000:8000" + - "8080:8000" volumes: - ./config.toml:/etc/evanescere/config.toml:ro - ./storage:/data/evanescere diff --git a/frontend/public/config.js b/frontend/public/config.js index 4769616..485c099 100644 --- a/frontend/public/config.js +++ b/frontend/public/config.js @@ -1,3 +1,3 @@ window.__EVANESCERE_FRONTEND_CONFIG__ = { - apiBaseUrl: "http://localhost:8000" + apiBaseUrl: "http://192.168.1.44:8080" }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f9bdc79..5719c80 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Check, Clapperboard, + FolderSync, Play, RefreshCw, Save, @@ -19,6 +20,7 @@ import { patchSettings, renderClip, runVideo, + scanWebDav, uploadClip, } from "./api"; import type { ClipSuggestion, PipelineSettings, TranscriptSegment, Video } from "./types"; @@ -47,7 +49,7 @@ function formatTimeRange(start: number, end: number) { } function statusTone(value: string) { - if (["done", "stable", "approved", "auto_approved"].includes(value)) return "good"; + if (["done", "stable", "existing_done", "approved", "auto_approved"].includes(value)) return "good"; if (["failed", "error"].includes(value)) return "bad"; if (["running", "queued", "observing", "pending"].includes(value)) return "busy"; return "neutral"; @@ -61,6 +63,7 @@ export function App() { const [clips, setClips] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const selectedVideo = useMemo( () => videos.find((video) => video.id === selectedVideoId) ?? null, @@ -124,6 +127,26 @@ export function App() { } } + async function syncWebDav() { + setLoading(true); + setError(null); + setNotice(null); + try { + const result = await scanWebDav(); + if (result.baseline_initialized) { + setNotice( + `Imported ${result.baseline_inserted + result.baseline_marked_existing} existing files as manual-only baseline entries.`, + ); + } else { + setNotice(`Observed ${result.observed} files; ${result.newly_stable} newly stable.`); + } + await refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Unknown error"); + setLoading(false); + } + } + async function saveSettings() { if (!settings) return; await withRefresh(() => patchSettings(settings)); @@ -149,6 +172,7 @@ export function App() { {error &&
{error}
} + {notice &&
{notice}
}
@@ -176,9 +200,15 @@ export function App() {