fixes
This commit is contained in:
@@ -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.
|
||||
|
||||
+30
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
window.__EVANESCERE_FRONTEND_CONFIG__ = {
|
||||
apiBaseUrl: "http://localhost:8000"
|
||||
apiBaseUrl: "http://192.168.1.44:8080"
|
||||
};
|
||||
|
||||
+34
-5
@@ -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<ClipSuggestion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(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() {
|
||||
</header>
|
||||
|
||||
{error && <div className="error-strip">{error}</div>}
|
||||
{notice && <div className="notice-strip">{notice}</div>}
|
||||
|
||||
<section className="band controls-band">
|
||||
<div className="section-title">
|
||||
@@ -176,9 +200,15 @@ export function App() {
|
||||
|
||||
<section className="workbench">
|
||||
<aside className="video-list">
|
||||
<div className="section-title">
|
||||
<Clapperboard size={18} />
|
||||
<h2>Videos</h2>
|
||||
<div className="list-header">
|
||||
<div className="section-title">
|
||||
<Clapperboard size={18} />
|
||||
<h2>Videos</h2>
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={() => void syncWebDav()} disabled={loading}>
|
||||
<FolderSync size={16} />
|
||||
Sync WebDAV
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
@@ -301,4 +331,3 @@ function Metric({ label, value }: { label: string; value: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -1,8 +1,15 @@
|
||||
import type { Artifact, ClipSuggestion, PipelineSettings, TranscriptSegment, Video } from "./types";
|
||||
import type {
|
||||
Artifact,
|
||||
ClipSuggestion,
|
||||
PipelineSettings,
|
||||
TranscriptSegment,
|
||||
Video,
|
||||
WebDavScanResult,
|
||||
} from "./types";
|
||||
|
||||
const runtimeApiBase = window.__EVANESCERE_FRONTEND_CONFIG__?.apiBaseUrl;
|
||||
|
||||
export const apiBaseUrl = (runtimeApiBase || "http://localhost:8000").replace(
|
||||
export const apiBaseUrl = (runtimeApiBase || "http://localhost:8080").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
@@ -37,6 +44,10 @@ export function getVideos(): Promise<Video[]> {
|
||||
return request<Video[]>("/videos");
|
||||
}
|
||||
|
||||
export function scanWebDav(): Promise<WebDavScanResult> {
|
||||
return request<WebDavScanResult>("/webdav/scan", { method: "POST" });
|
||||
}
|
||||
|
||||
export function runVideo(videoId: number) {
|
||||
return request(`/videos/${videoId}/run`, { method: "POST" });
|
||||
}
|
||||
|
||||
+17
-1
@@ -120,6 +120,16 @@ button {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notice-strip {
|
||||
margin-bottom: 14px;
|
||||
border-left: 4px solid #21756b;
|
||||
background: #e9f3f1;
|
||||
color: #155c54;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.band {
|
||||
border-top: 1px solid #d8dee6;
|
||||
padding: 16px 0;
|
||||
@@ -190,6 +200,13 @@ button {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
margin-top: 12px;
|
||||
overflow: auto;
|
||||
@@ -431,4 +448,3 @@ tr.selected td {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,3 +59,11 @@ export interface Artifact {
|
||||
artifact_metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WebDavScanResult {
|
||||
observed: number;
|
||||
newly_stable: number;
|
||||
baseline_initialized: boolean;
|
||||
baseline_inserted: number;
|
||||
baseline_marked_existing: number;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ from evanescere.schemas import (
|
||||
SettingsRead,
|
||||
TranscriptSegmentRead,
|
||||
VideoRead,
|
||||
WebDavScanRead,
|
||||
)
|
||||
from evanescere.services.webdav import WebDavClient, safe_scan_once
|
||||
from evanescere.settings_store import get_pipeline_settings, patch_pipeline_settings
|
||||
|
||||
configure_logging()
|
||||
@@ -68,6 +70,12 @@ def list_videos(db: Session = Depends(get_db)) -> list[Video]:
|
||||
return list(db.scalars(select(Video).order_by(Video.created_at.desc())).all())
|
||||
|
||||
|
||||
@app.post("/webdav/scan", response_model=WebDavScanRead)
|
||||
def scan_webdav(db: Session = Depends(get_db)) -> WebDavScanRead:
|
||||
result = safe_scan_once(WebDavClient.from_settings(), db)
|
||||
return WebDavScanRead(**result.__dict__)
|
||||
|
||||
|
||||
@app.post("/videos/{video_id}/run", response_model=RunRead)
|
||||
def run_video(video_id: int, db: Session = Depends(get_db)) -> PipelineRun:
|
||||
video = db.get(Video, video_id)
|
||||
|
||||
+15
-5
@@ -5,7 +5,11 @@ from evanescere.db import session_scope
|
||||
from evanescere.jobs import enqueue_pipeline, enqueue_render, enqueue_transcribe, enqueue_suggest, enqueue_upload
|
||||
from evanescere.logging_config import configure_logging
|
||||
from evanescere.models import PipelineRun, Video
|
||||
from evanescere.services.webdav import WebDavClient, bootstrap_existing as bootstrap_webdav_existing, scan_once
|
||||
from evanescere.services.webdav import (
|
||||
WebDavClient,
|
||||
bootstrap_existing as bootstrap_webdav_existing,
|
||||
safe_scan_once,
|
||||
)
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
configure_logging()
|
||||
@@ -14,15 +18,21 @@ configure_logging()
|
||||
@app.command()
|
||||
def bootstrap_existing() -> None:
|
||||
with session_scope() as session:
|
||||
count = bootstrap_webdav_existing(WebDavClient.from_settings(), session)
|
||||
typer.echo(f"Marked {count} existing WebDAV recordings as existing_done.")
|
||||
result = bootstrap_webdav_existing(WebDavClient.from_settings(), session)
|
||||
typer.echo(
|
||||
f"Observed {result.observed} recordings; inserted {result.inserted} and marked "
|
||||
f"{result.marked_existing} tracked recordings as existing_done."
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def scan() -> None:
|
||||
with session_scope() as session:
|
||||
observed, stable = scan_once(WebDavClient.from_settings(), session)
|
||||
typer.echo(f"Observed {observed} files; {stable} newly stable.")
|
||||
result = safe_scan_once(WebDavClient.from_settings(), session)
|
||||
typer.echo(
|
||||
f"Observed {result.observed} files; {result.newly_stable} newly stable; "
|
||||
f"baseline initialized: {result.baseline_initialized}."
|
||||
)
|
||||
|
||||
|
||||
@app.command("run-video")
|
||||
|
||||
@@ -12,6 +12,25 @@ DEFAULT_CONFIG_PATHS = (
|
||||
Path("config.toml"),
|
||||
)
|
||||
|
||||
DEFAULT_LLM_SYSTEM_PROMPT = """\
|
||||
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.
|
||||
"""
|
||||
|
||||
DEFAULT_LLM_USER_PROMPT = """\
|
||||
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}
|
||||
"""
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
app_env: str = "dev"
|
||||
@@ -38,6 +57,8 @@ class Settings(BaseModel):
|
||||
deepseek_api_key: str | None = None
|
||||
deepseek_model: str = "deepseek-v4-pro"
|
||||
deepseek_temperature: float = 0.2
|
||||
llm_system_prompt: str = DEFAULT_LLM_SYSTEM_PROMPT
|
||||
llm_user_prompt: str = DEFAULT_LLM_USER_PROMPT
|
||||
|
||||
default_suggest_enabled: bool = True
|
||||
default_render_enabled: bool = True
|
||||
@@ -51,12 +72,13 @@ class Settings(BaseModel):
|
||||
clip_min_seconds: int = 30
|
||||
clip_max_seconds: int = 360
|
||||
transcript_chunk_seconds: int = 900
|
||||
clip_max_candidates_total: int = Field(default=20, ge=1)
|
||||
|
||||
thumbnail_enabled: bool = True
|
||||
thumbnail_provider: str = "frame_overlay"
|
||||
thumbnail_width: int = Field(default=1920, ge=320)
|
||||
thumbnail_height: int = Field(default=1080, ge=180)
|
||||
thumbnail_character_overlay_path: str | None = None
|
||||
thumbnail_character_overlay_dir: str | None = None
|
||||
thumbnail_character_scale: float = Field(default=0.42, gt=0, le=1)
|
||||
thumbnail_character_position: str = "bottom-right"
|
||||
thumbnail_title_enabled: bool = True
|
||||
@@ -94,6 +116,7 @@ def flatten_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
webdav = raw.get("webdav", {})
|
||||
funasr = raw.get("funasr", {})
|
||||
deepseek = raw.get("deepseek", {})
|
||||
llm_prompt = raw.get("llm_prompt", {})
|
||||
defaults = raw.get("defaults", {})
|
||||
clip = raw.get("clip", {})
|
||||
thumbnail = raw.get("thumbnail", {})
|
||||
@@ -119,6 +142,8 @@ def flatten_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"deepseek_api_key": blank_to_none(deepseek.get("api_key")),
|
||||
"deepseek_model": deepseek.get("model", "deepseek-v4-pro"),
|
||||
"deepseek_temperature": deepseek.get("temperature", 0.2),
|
||||
"llm_system_prompt": llm_prompt.get("system", DEFAULT_LLM_SYSTEM_PROMPT),
|
||||
"llm_user_prompt": llm_prompt.get("user", DEFAULT_LLM_USER_PROMPT),
|
||||
"default_suggest_enabled": defaults.get("suggest_enabled", True),
|
||||
"default_render_enabled": defaults.get("render_enabled", True),
|
||||
"default_upload_enabled": defaults.get("upload_enabled", True),
|
||||
@@ -127,11 +152,12 @@ def flatten_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"clip_min_seconds": clip.get("min_seconds", 30),
|
||||
"clip_max_seconds": clip.get("max_seconds", 360),
|
||||
"transcript_chunk_seconds": clip.get("transcript_chunk_seconds", 900),
|
||||
"clip_max_candidates_total": clip.get("max_candidates_total", 20),
|
||||
"thumbnail_enabled": thumbnail.get("enabled", True),
|
||||
"thumbnail_provider": thumbnail.get("provider", "frame_overlay"),
|
||||
"thumbnail_width": thumbnail.get("width", 1920),
|
||||
"thumbnail_height": thumbnail.get("height", 1080),
|
||||
"thumbnail_character_overlay_path": blank_to_none(thumbnail.get("character_overlay_path")),
|
||||
"thumbnail_character_overlay_dir": blank_to_none(thumbnail.get("character_overlay_dir")),
|
||||
"thumbnail_character_scale": thumbnail.get("character_scale", 0.42),
|
||||
"thumbnail_character_position": thumbnail.get("character_position", "bottom-right"),
|
||||
"thumbnail_title_enabled": thumbnail.get("title_enabled", True),
|
||||
|
||||
@@ -108,7 +108,12 @@ def transcribe_video(session: Session, video: Video) -> int:
|
||||
|
||||
def suggest_clips(session: Session, video: Video) -> int:
|
||||
settings = get_settings()
|
||||
logger.info("clip suggestion start video_id=%s chunk_seconds=%s", video.id, settings.transcript_chunk_seconds)
|
||||
logger.info(
|
||||
"clip suggestion start video_id=%s chunk_seconds=%s max_candidates_total=%s",
|
||||
video.id,
|
||||
settings.transcript_chunk_seconds,
|
||||
settings.clip_max_candidates_total,
|
||||
)
|
||||
segments = list(
|
||||
session.scalars(
|
||||
select(TranscriptSegment)
|
||||
@@ -120,7 +125,12 @@ def suggest_clips(session: Session, video: Video) -> int:
|
||||
raise RuntimeError("Cannot suggest clips without transcript segments.")
|
||||
chunks = chunk_transcript(segments, settings.transcript_chunk_seconds)
|
||||
logger.info("clip suggestion chunks video_id=%s segments=%s chunks=%s", video.id, len(segments), len(chunks))
|
||||
candidates = DeepSeekClipClient.from_settings().suggest(chunks)
|
||||
candidates = DeepSeekClipClient.from_settings().suggest(
|
||||
chunks,
|
||||
max_candidates_total=settings.clip_max_candidates_total,
|
||||
min_clip_seconds=settings.clip_min_seconds,
|
||||
max_clip_seconds=settings.clip_max_seconds,
|
||||
)
|
||||
session.query(ClipSuggestion).filter(ClipSuggestion.video_id == video.id).delete()
|
||||
for candidate in candidates:
|
||||
session.add(
|
||||
|
||||
@@ -10,7 +10,7 @@ from evanescere.db import session_scope
|
||||
from evanescere.jobs import enqueue_pipeline
|
||||
from evanescere.logging_config import configure_logging
|
||||
from evanescere.models import PipelineRun, Video
|
||||
from evanescere.services.webdav import WebDavClient, scan_once
|
||||
from evanescere.services.webdav import WebDavClient, safe_scan_once
|
||||
|
||||
configure_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,9 +45,15 @@ def run_forever() -> None:
|
||||
while True:
|
||||
try:
|
||||
with session_scope() as session:
|
||||
observed, stable = scan_once(client, session)
|
||||
result = safe_scan_once(client, session)
|
||||
queued = enqueue_stable_videos()
|
||||
logger.info("scan observed=%s newly_stable=%s queued=%s", observed, stable, queued)
|
||||
logger.info(
|
||||
"scan observed=%s newly_stable=%s baseline_initialized=%s queued=%s",
|
||||
result.observed,
|
||||
result.newly_stable,
|
||||
result.baseline_initialized,
|
||||
queued,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("scheduler scan failed")
|
||||
time.sleep(settings.webdav_poll_interval_seconds)
|
||||
|
||||
@@ -95,6 +95,14 @@ class SettingsRead(BaseModel):
|
||||
bake_subtitles: bool = True
|
||||
|
||||
|
||||
class WebDavScanRead(BaseModel):
|
||||
observed: int
|
||||
newly_stable: int
|
||||
baseline_initialized: bool = False
|
||||
baseline_inserted: int = 0
|
||||
baseline_marked_existing: int = 0
|
||||
|
||||
|
||||
class ClipCandidate(BaseModel):
|
||||
start_sec: float = Field(ge=0)
|
||||
end_sec: float = Field(gt=0)
|
||||
@@ -108,4 +116,3 @@ class ClipCandidate(BaseModel):
|
||||
|
||||
class ClipCandidateResponse(BaseModel):
|
||||
clips: list[ClipCandidate]
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ from evanescere.schemas import ClipCandidate, ClipCandidateResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RESPONSE_SCHEMA_INSTRUCTION = """\
|
||||
Return strict JSON only using this schema:
|
||||
{"clips":[{"start_sec":number,"end_sec":number,"title_zh":string,"summary_zh":string,"reason":string,"score":number,"tags":[string],"subtitle_priority":string}]}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TranscriptChunk:
|
||||
@@ -22,10 +27,20 @@ class TranscriptChunk:
|
||||
|
||||
|
||||
class DeepSeekClipClient:
|
||||
def __init__(self, api_key: str, base_url: str, model: str, temperature: float) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
temperature: float,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
) -> None:
|
||||
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
self.model = model
|
||||
self.temperature = temperature
|
||||
self.system_prompt = system_prompt
|
||||
self.user_prompt = user_prompt
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls) -> DeepSeekClipClient:
|
||||
@@ -37,18 +52,52 @@ class DeepSeekClipClient:
|
||||
settings.deepseek_base_url,
|
||||
settings.deepseek_model,
|
||||
settings.deepseek_temperature,
|
||||
settings.llm_system_prompt,
|
||||
settings.llm_user_prompt,
|
||||
)
|
||||
|
||||
def suggest(self, chunks: list[TranscriptChunk]) -> list[ClipCandidate]:
|
||||
logger.info("deepseek suggestion start chunks=%s model=%s", len(chunks), self.model)
|
||||
def suggest(
|
||||
self,
|
||||
chunks: list[TranscriptChunk],
|
||||
*,
|
||||
max_candidates_total: int,
|
||||
min_clip_seconds: int,
|
||||
max_clip_seconds: int,
|
||||
) -> list[ClipCandidate]:
|
||||
logger.info(
|
||||
"deepseek suggestion start chunks=%s model=%s max_candidates_total=%s",
|
||||
len(chunks),
|
||||
self.model,
|
||||
max_candidates_total,
|
||||
)
|
||||
candidates: list[ClipCandidate] = []
|
||||
quota = candidate_quota_per_chunk(len(chunks), max_candidates_total)
|
||||
for chunk in chunks:
|
||||
candidates.extend(self._suggest_for_chunk(chunk))
|
||||
ranked = dedupe_and_rank_candidates(candidates)
|
||||
candidates.extend(
|
||||
self._suggest_for_chunk(
|
||||
chunk,
|
||||
max_candidates=quota,
|
||||
min_clip_seconds=min_clip_seconds,
|
||||
max_clip_seconds=max_clip_seconds,
|
||||
)
|
||||
)
|
||||
ranked = dedupe_and_rank_candidates(
|
||||
candidates,
|
||||
min_clip_seconds=min_clip_seconds,
|
||||
max_clip_seconds=max_clip_seconds,
|
||||
max_candidates_total=max_candidates_total,
|
||||
)
|
||||
logger.info("deepseek suggestion done raw_candidates=%s ranked_candidates=%s", len(candidates), len(ranked))
|
||||
return ranked
|
||||
|
||||
def _suggest_for_chunk(self, chunk: TranscriptChunk) -> list[ClipCandidate]:
|
||||
def _suggest_for_chunk(
|
||||
self,
|
||||
chunk: TranscriptChunk,
|
||||
*,
|
||||
max_candidates: int,
|
||||
min_clip_seconds: int,
|
||||
max_clip_seconds: int,
|
||||
) -> list[ClipCandidate]:
|
||||
logger.info(
|
||||
"deepseek chunk request start start=%.1f end=%.1f chars=%s",
|
||||
chunk.start_sec,
|
||||
@@ -62,22 +111,16 @@ class DeepSeekClipClient:
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You select interesting short clips from Mandarin livestream transcripts. "
|
||||
"Return strict JSON only. Prefer complete moments with context, funny reactions, "
|
||||
"surprising reveals, emotional peaks, or strong standalone discussion."
|
||||
),
|
||||
"content": f"{self.system_prompt.rstrip()}\n\n{RESPONSE_SCHEMA_INSTRUCTION}",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Find up to 5 clip candidates in this transcript chunk. "
|
||||
"Each clip must be 30-360 seconds and use absolute stream seconds. "
|
||||
"JSON schema: {\"clips\":[{\"start_sec\":number,\"end_sec\":number,"
|
||||
"\"title_zh\":string,\"summary_zh\":string,\"reason\":string,"
|
||||
"\"score\":number,\"tags\":[string],\"subtitle_priority\":string}]}.\n\n"
|
||||
f"Chunk bounds: {chunk.start_sec:.1f}-{chunk.end_sec:.1f}\n"
|
||||
f"Transcript:\n{chunk.text}"
|
||||
"content": render_user_prompt(
|
||||
self.user_prompt,
|
||||
chunk=chunk,
|
||||
max_candidates=max_candidates,
|
||||
min_clip_seconds=min_clip_seconds,
|
||||
max_clip_seconds=max_clip_seconds,
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -95,6 +138,27 @@ class DeepSeekClipClient:
|
||||
return candidates
|
||||
|
||||
|
||||
def render_user_prompt(
|
||||
template: str,
|
||||
*,
|
||||
chunk: TranscriptChunk,
|
||||
max_candidates: int,
|
||||
min_clip_seconds: int,
|
||||
max_clip_seconds: int,
|
||||
) -> str:
|
||||
try:
|
||||
return template.format(
|
||||
max_candidates=max_candidates,
|
||||
min_clip_seconds=min_clip_seconds,
|
||||
max_clip_seconds=max_clip_seconds,
|
||||
chunk_start_sec=f"{chunk.start_sec:.1f}",
|
||||
chunk_end_sec=f"{chunk.end_sec:.1f}",
|
||||
transcript=chunk.text,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown placeholder in [llm_prompt].user: {exc.args[0]}") from exc
|
||||
|
||||
|
||||
def segment_line(segment: TranscriptSegment) -> str:
|
||||
return f"[{segment.start_sec:.1f}-{segment.end_sec:.1f}] {segment.text}"
|
||||
|
||||
@@ -141,17 +205,31 @@ def overlap_ratio(left: ClipCandidate, right: ClipCandidate) -> float:
|
||||
return overlap / shortest
|
||||
|
||||
|
||||
def dedupe_and_rank_candidates(candidates: list[ClipCandidate]) -> list[ClipCandidate]:
|
||||
def candidate_quota_per_chunk(chunk_count: int, max_candidates_total: int) -> int:
|
||||
if chunk_count <= 0:
|
||||
return 0
|
||||
return max(1, (max_candidates_total + chunk_count - 1) // chunk_count)
|
||||
|
||||
|
||||
def dedupe_and_rank_candidates(
|
||||
candidates: list[ClipCandidate],
|
||||
*,
|
||||
min_clip_seconds: int = 30,
|
||||
max_clip_seconds: int = 360,
|
||||
max_candidates_total: int | None = None,
|
||||
) -> list[ClipCandidate]:
|
||||
valid = [
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if candidate.end_sec > candidate.start_sec
|
||||
and 30 <= candidate.end_sec - candidate.start_sec <= 360
|
||||
and min_clip_seconds <= candidate.end_sec - candidate.start_sec <= max_clip_seconds
|
||||
]
|
||||
ranked = sorted(valid, key=lambda item: item.score, reverse=True)
|
||||
chosen: list[ClipCandidate] = []
|
||||
for candidate in ranked:
|
||||
if all(overlap_ratio(candidate, existing) < 0.5 for existing in chosen):
|
||||
chosen.append(candidate)
|
||||
if max_candidates_total is not None and len(chosen) >= max_candidates_total:
|
||||
break
|
||||
logger.debug("dedupe candidates input=%s valid=%s chosen=%s", len(candidates), len(valid), len(chosen))
|
||||
return chosen
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -63,11 +64,7 @@ def generate_thumbnail(
|
||||
raise RuntimeError(f"Unsupported [thumbnail].provider={settings.thumbnail_provider!r}")
|
||||
|
||||
final_path = output_dir / "thumbnail_final.jpg"
|
||||
character_path = (
|
||||
Path(settings.thumbnail_character_overlay_path)
|
||||
if settings.thumbnail_character_overlay_path
|
||||
else None
|
||||
)
|
||||
character_path = select_character_overlay(settings.thumbnail_character_overlay_dir)
|
||||
compose_thumbnail(
|
||||
background_path=background,
|
||||
output_path=final_path,
|
||||
@@ -90,7 +87,7 @@ def generate_thumbnail(
|
||||
"provider": provider,
|
||||
"source_frame": str(base_frame),
|
||||
"background": str(background),
|
||||
"character_overlay_path": settings.thumbnail_character_overlay_path or "",
|
||||
"character_overlay_path": str(character_path or ""),
|
||||
},
|
||||
)
|
||||
logger.info("thumbnail generation done video_id=%s clip_id=%s output=%s", video.id, clip.id, final_path)
|
||||
@@ -237,6 +234,24 @@ def overlay_character(image, overlay_path: Path, scale: float, position: str) ->
|
||||
image.alpha_composite(overlay, (max(0, x), max(0, y)))
|
||||
|
||||
|
||||
def select_character_overlay(overlay_dir: str | None) -> Path | None:
|
||||
if not overlay_dir:
|
||||
return None
|
||||
directory = Path(overlay_dir)
|
||||
if not directory.is_dir():
|
||||
logger.warning("thumbnail character overlay directory missing path=%s", directory)
|
||||
return None
|
||||
candidates = sorted(
|
||||
path for path in directory.iterdir() if path.is_file() and path.suffix.lower() == ".png"
|
||||
)
|
||||
if not candidates:
|
||||
logger.warning("thumbnail character overlay directory has no png files path=%s", directory)
|
||||
return None
|
||||
selected = random.choice(candidates)
|
||||
logger.info("thumbnail character overlay selected path=%s candidates=%s", selected, len(candidates))
|
||||
return selected
|
||||
|
||||
|
||||
def load_font(size: int):
|
||||
from PIL import ImageFont
|
||||
|
||||
|
||||
@@ -5,16 +5,17 @@ from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
from urllib.parse import quote, unquote, urljoin, urlparse
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from evanescere.config import get_settings
|
||||
from evanescere.models import Video
|
||||
from evanescere.models import Setting, Video
|
||||
|
||||
VIDEO_SUFFIXES = {".flv", ".mp4", ".mkv", ".mov"}
|
||||
WEBDAV_BASELINE_KEY = "webdav_initial_baseline"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -26,6 +27,22 @@ class WebDavFile:
|
||||
modified_at: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootstrapResult:
|
||||
observed: int
|
||||
inserted: int
|
||||
marked_existing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanResult:
|
||||
observed: int
|
||||
newly_stable: int
|
||||
baseline_initialized: bool = False
|
||||
baseline_inserted: int = 0
|
||||
baseline_marked_existing: int = 0
|
||||
|
||||
|
||||
class WebDavClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -100,7 +117,7 @@ def parse_propfind_response(xml_text: str, base_url: str) -> list[WebDavFile]:
|
||||
href_path = urlparse(href).path
|
||||
if href_path.rstrip("/") == base_path:
|
||||
continue
|
||||
filename = PurePosixPath(href_path).name
|
||||
filename = unquote(PurePosixPath(href_path).name)
|
||||
if not filename or PurePosixPath(filename).suffix.lower() not in VIDEO_SUFFIXES:
|
||||
continue
|
||||
resource_type = response.find(".//d:resourcetype", ns)
|
||||
@@ -129,9 +146,29 @@ def has_stable_size(video: Video, required_equal_samples: int = 2) -> bool:
|
||||
return len(sizes) == 1 and next(iter(sizes)) > 0
|
||||
|
||||
|
||||
def bootstrap_existing(client: WebDavClient, session: Session) -> int:
|
||||
count = 0
|
||||
for file in client.list_files():
|
||||
def has_webdav_baseline(session: Session) -> bool:
|
||||
return session.get(Setting, WEBDAV_BASELINE_KEY) is not None
|
||||
|
||||
|
||||
def mark_webdav_baseline(session: Session, result: BootstrapResult) -> None:
|
||||
value = {
|
||||
"initialized_at": datetime.now(UTC).isoformat(),
|
||||
"observed": result.observed,
|
||||
"inserted": result.inserted,
|
||||
"marked_existing": result.marked_existing,
|
||||
}
|
||||
row = session.get(Setting, WEBDAV_BASELINE_KEY)
|
||||
if row is None:
|
||||
session.add(Setting(key=WEBDAV_BASELINE_KEY, value=value))
|
||||
else:
|
||||
row.value = value
|
||||
|
||||
|
||||
def bootstrap_existing(client: WebDavClient, session: Session) -> BootstrapResult:
|
||||
files = client.list_files()
|
||||
inserted = 0
|
||||
marked_existing = 0
|
||||
for file in files:
|
||||
video = session.query(Video).filter(Video.source_url == file.url).one_or_none()
|
||||
if video is None:
|
||||
video = Video(
|
||||
@@ -143,11 +180,35 @@ def bootstrap_existing(client: WebDavClient, session: Session) -> int:
|
||||
processing_status="done",
|
||||
)
|
||||
session.add(video)
|
||||
count += 1
|
||||
inserted += 1
|
||||
logger.debug("bootstrap existing file=%s size=%s", file.filename, file.size_bytes)
|
||||
elif video.ingest_status == "observing" and video.processing_status == "pending":
|
||||
video.size_bytes = file.size_bytes
|
||||
video.ingest_status = "existing_done"
|
||||
video.processing_status = "done"
|
||||
marked_existing += 1
|
||||
logger.debug("bootstrap marked existing video_id=%s file=%s", video.id, file.filename)
|
||||
result = BootstrapResult(
|
||||
observed=len(files),
|
||||
inserted=inserted,
|
||||
marked_existing=marked_existing,
|
||||
)
|
||||
mark_webdav_baseline(session, result)
|
||||
session.flush()
|
||||
logger.info("bootstrap existing done inserted=%s", count)
|
||||
return count
|
||||
logger.info(
|
||||
"bootstrap existing done observed=%s inserted=%s marked_existing=%s",
|
||||
result.observed,
|
||||
result.inserted,
|
||||
result.marked_existing,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_initial_baseline(client: WebDavClient, session: Session) -> BootstrapResult | None:
|
||||
if has_webdav_baseline(session):
|
||||
return None
|
||||
logger.info("webdav baseline missing; recording currently visible files as existing_done")
|
||||
return bootstrap_existing(client, session)
|
||||
|
||||
|
||||
def scan_once(client: WebDavClient, session: Session) -> tuple[int, int]:
|
||||
@@ -175,3 +236,17 @@ def scan_once(client: WebDavClient, session: Session) -> tuple[int, int]:
|
||||
logger.info("webdav file stable video_id=%s file=%s size=%s", video.id, file.filename, file.size_bytes)
|
||||
session.flush()
|
||||
return observed, newly_stable
|
||||
|
||||
|
||||
def safe_scan_once(client: WebDavClient, session: Session) -> ScanResult:
|
||||
baseline = ensure_initial_baseline(client, session)
|
||||
if baseline is not None:
|
||||
return ScanResult(
|
||||
observed=baseline.observed,
|
||||
newly_stable=0,
|
||||
baseline_initialized=True,
|
||||
baseline_inserted=baseline.inserted,
|
||||
baseline_marked_existing=baseline.marked_existing,
|
||||
)
|
||||
observed, newly_stable = scan_once(client, session)
|
||||
return ScanResult(observed=observed, newly_stable=newly_stable)
|
||||
|
||||
+59
-1
@@ -1,5 +1,11 @@
|
||||
from evanescere.schemas import ClipCandidate
|
||||
from evanescere.services.llm import dedupe_and_rank_candidates, parse_clip_response
|
||||
from evanescere.services.llm import (
|
||||
TranscriptChunk,
|
||||
candidate_quota_per_chunk,
|
||||
dedupe_and_rank_candidates,
|
||||
parse_clip_response,
|
||||
render_user_prompt,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_clip_response():
|
||||
@@ -62,3 +68,55 @@ def test_dedupe_and_rank_candidates_filters_short_and_overlapping():
|
||||
ranked = dedupe_and_rank_candidates(candidates)
|
||||
assert [candidate.title_zh for candidate in ranked] == ["best", "second"]
|
||||
|
||||
|
||||
def test_candidate_quota_uses_total_budget_across_chunks():
|
||||
assert candidate_quota_per_chunk(1, 20) == 20
|
||||
assert candidate_quota_per_chunk(4, 20) == 5
|
||||
assert candidate_quota_per_chunk(0, 20) == 0
|
||||
|
||||
|
||||
def test_dedupe_respects_configured_duration_and_total_cap():
|
||||
candidates = [
|
||||
ClipCandidate(
|
||||
start_sec=index * 240,
|
||||
end_sec=index * 240 + duration,
|
||||
title_zh=f"clip-{index}",
|
||||
summary_zh="",
|
||||
reason="",
|
||||
score=1 - index / 100,
|
||||
)
|
||||
for index, duration in enumerate([60, 180, 181, 90])
|
||||
]
|
||||
ranked = dedupe_and_rank_candidates(
|
||||
candidates,
|
||||
min_clip_seconds=30,
|
||||
max_clip_seconds=180,
|
||||
max_candidates_total=2,
|
||||
)
|
||||
assert [candidate.title_zh for candidate in ranked] == ["clip-0", "clip-1"]
|
||||
|
||||
|
||||
def test_render_user_prompt_inserts_configured_values():
|
||||
prompt = render_user_prompt(
|
||||
"{max_candidates}|{min_clip_seconds}|{max_clip_seconds}|{chunk_start_sec}|{chunk_end_sec}|{transcript}",
|
||||
chunk=TranscriptChunk(12.25, 42.75, "hello"),
|
||||
max_candidates=20,
|
||||
min_clip_seconds=30,
|
||||
max_clip_seconds=180,
|
||||
)
|
||||
assert prompt == "20|30|180|12.2|42.8|hello"
|
||||
|
||||
|
||||
def test_render_user_prompt_rejects_unknown_placeholder():
|
||||
try:
|
||||
render_user_prompt(
|
||||
"{unknown}",
|
||||
chunk=TranscriptChunk(0, 1, "hello"),
|
||||
max_candidates=20,
|
||||
min_clip_seconds=30,
|
||||
max_clip_seconds=180,
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "Unknown placeholder" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected unknown placeholder to fail")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from evanescere.services.thumbnail import select_character_overlay
|
||||
|
||||
|
||||
def test_select_character_overlay_returns_none_for_empty_directory(tmp_path: Path):
|
||||
assert select_character_overlay(str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_select_character_overlay_filters_png_files(tmp_path: Path):
|
||||
first = tmp_path / "first.PNG"
|
||||
second = tmp_path / "second.png"
|
||||
first.write_bytes(b"")
|
||||
second.write_bytes(b"")
|
||||
(tmp_path / "notes.txt").write_text("ignore me")
|
||||
|
||||
with patch("evanescere.services.thumbnail.random.choice", return_value=second) as choice:
|
||||
assert select_character_overlay(str(tmp_path)) == second
|
||||
|
||||
assert choice.call_args.args[0] == [first, second]
|
||||
|
||||
@@ -33,3 +33,20 @@ def test_parse_propfind_response_filters_video_files():
|
||||
WebDavFile("https://host/webdav/recordings/stream.flv", "stream.flv", 123, None)
|
||||
]
|
||||
|
||||
|
||||
def test_parse_propfind_response_preserves_encoded_chinese_filename():
|
||||
xml = """<?xml version="1.0"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response>
|
||||
<D:href>/Hirumi/hirumi-2-06%E6%9C%8801%E6%97%A501%E6%97%B602%E5%88%8641%E7%A7%92.flv</D:href>
|
||||
<D:propstat><D:prop><D:getcontentlength>123</D:getcontentlength></D:prop></D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"""
|
||||
assert parse_propfind_response(xml, "http://recording.home.arpa/Hirumi") == [
|
||||
WebDavFile(
|
||||
"http://recording.home.arpa/Hirumi/hirumi-2-06%E6%9C%8801%E6%97%A501%E6%97%B602%E5%88%8641%E7%A7%92.flv",
|
||||
"hirumi-2-06月01日01时02分41秒.flv",
|
||||
123,
|
||||
None,
|
||||
)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user