initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
.env
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
|
*.pyc
|
||||||
|
storage
|
||||||
|
tmp
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
.env
|
||||||
|
config.toml
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
storage/
|
||||||
|
tmp/
|
||||||
|
.DS_Store
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg curl fonts-noto-cjk \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY alembic.ini ./alembic.ini
|
||||||
|
COPY migrations ./migrations
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "evanescere.api:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
# Evanescere
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Evanescere is split into a static frontend and an API/worker backend.
|
||||||
|
|
||||||
|
- `frontend`: React/Vite UI. It builds to static files and is served by nginx in Docker.
|
||||||
|
- `src/evanescere/api.py`: FastAPI backend. It serves JSON only.
|
||||||
|
- `src/evanescere/scheduler.py`: polling loop for IIS WebDAV.
|
||||||
|
- `src/evanescere/jobs.py`: Dramatiq queue actors backed by Redis.
|
||||||
|
- `src/evanescere/pipeline.py`: orchestration for media, ASR, LLM, render, thumbnail, and upload stages.
|
||||||
|
- `src/evanescere/services`: adapters for WebDAV, ffmpeg, FunASR, DeepSeek, subtitles, thumbnails, artifacts, and upload.
|
||||||
|
- `migrations`: Alembic migrations for PostgreSQL.
|
||||||
|
|
||||||
|
Runtime data flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IIS WebDAV recordings
|
||||||
|
-> scheduler polls file size
|
||||||
|
-> PostgreSQL records video state
|
||||||
|
-> Redis queues pipeline job
|
||||||
|
-> worker downloads source to configured storage.local_root
|
||||||
|
-> ffmpeg remuxes/extracts audio
|
||||||
|
-> FunASR creates timestamped transcript
|
||||||
|
-> DeepSeek creates clip suggestions
|
||||||
|
-> ffmpeg renders clips/subtitles/thumbnails
|
||||||
|
-> thumbnail service composes frame/generated image + VTuber overlay + title
|
||||||
|
-> uploader adapter runs, or noop for testing
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend tracks durable state in PostgreSQL. Large media artifacts stay on the Framework SSD under `[storage].local_root`; WebDAV is used for source ingestion and can later be used for pushing final artifacts back to the Windows server.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Runtime configuration is TOML-based. Copy the commented example and edit your private config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
The application looks for config in this order:
|
||||||
|
|
||||||
|
1. `/etc/evanescere/config.toml`
|
||||||
|
2. `./config.toml`
|
||||||
|
3. Built-in development defaults, only if no config file exists.
|
||||||
|
|
||||||
|
For Docker/Compose, mount your config file to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/evanescere/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
The checked-in `docker-compose.yml` mounts local `./config.toml` there for the API, workers, and scheduler.
|
||||||
|
|
||||||
|
## Services And Ports
|
||||||
|
|
||||||
|
- Frontend: `http://localhost:3000`
|
||||||
|
- Backend API: `http://localhost:8000`
|
||||||
|
- API docs: `http://localhost:8000/docs`
|
||||||
|
- Redis: `localhost:6379` when exposed by Compose
|
||||||
|
- PostgreSQL: only started by Compose when using the `local-db` profile
|
||||||
|
|
||||||
|
## Test Environment
|
||||||
|
|
||||||
|
Create and edit `config.toml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
For a self-contained local test stack, use the Compose PostgreSQL profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile local-db up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run database migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm api alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
Bootstrap existing WebDAV files so old recordings are marked `existing_done` instead of auto-processed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm api evanescere bootstrap-existing
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful test commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm api evanescere scan
|
||||||
|
docker compose run --rm api evanescere videos
|
||||||
|
docker compose run --rm api evanescere run-video VIDEO_ID
|
||||||
|
docker compose logs -f api scheduler worker-media worker-ai
|
||||||
|
```
|
||||||
|
|
||||||
|
Automated tests are written with `pytest`. After installing Python dev dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Without installing dependencies locally, the available lightweight checks are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m compileall src tests migrations
|
||||||
|
docker compose config --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
For safe test/debug runs, set these in `config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[app]
|
||||||
|
log_level = "DEBUG"
|
||||||
|
|
||||||
|
[defaults]
|
||||||
|
upload_enabled = false
|
||||||
|
|
||||||
|
[upload]
|
||||||
|
adapter = "noop"
|
||||||
|
```
|
||||||
|
|
||||||
|
`adapter = "noop"` prevents accidental real uploads. `upload_enabled = false` lets you inspect rendered artifacts before adding an uploader.
|
||||||
|
|
||||||
|
## Frontend Development
|
||||||
|
|
||||||
|
The frontend reads the backend URL from `frontend/public/config.js` at runtime:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.__EVANESCERE_FRONTEND_CONFIG__ = {
|
||||||
|
apiBaseUrl: "http://localhost:8000"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual frontend development requires npm packages. Install only after you approve package installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev server runs on `http://localhost:5173`. Make sure `[app].cors_origins` includes that URL.
|
||||||
|
|
||||||
|
Static build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The production Compose frontend service builds the static app and serves `dist/` through nginx.
|
||||||
|
|
||||||
|
## Thumbnail Generation
|
||||||
|
|
||||||
|
Each rendered clip can produce three thumbnail artifacts:
|
||||||
|
|
||||||
|
- `thumbnail_base`: a representative frame extracted from the source MP4 near the middle of the clip.
|
||||||
|
- `thumbnail_generated`: optional output from a local image generation command.
|
||||||
|
- `thumbnail_final`: the final composed JPG with optional title and VTuber character overlay.
|
||||||
|
|
||||||
|
Default provider:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[thumbnail]
|
||||||
|
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:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[thumbnail]
|
||||||
|
character_overlay_path = "/data/evanescere/assets/vtuber.png"
|
||||||
|
character_scale = 0.42
|
||||||
|
character_position = "bottom-right"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported positions are `bottom-right`, `bottom-left`, `center-right`, and `center-left`.
|
||||||
|
|
||||||
|
For a local image generation model, configure a command provider:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[thumbnail]
|
||||||
|
provider = "command"
|
||||||
|
command = "/opt/evanescere-thumbnail/generate-thumbnail"
|
||||||
|
```
|
||||||
|
|
||||||
|
The command receives JSON on stdin and must create the file named by `output_path`. This makes it easy to wrap ComfyUI, Stable Diffusion, Flux, or any other local generator. Payload shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"video_id": 123,
|
||||||
|
"clip_id": 456,
|
||||||
|
"source_frame": "/data/evanescere/videos/123/clips/456/thumbnail_base.jpg",
|
||||||
|
"output_path": "/data/evanescere/videos/123/clips/456/thumbnail_generated.png",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"title_zh": "标题",
|
||||||
|
"summary_zh": "摘要",
|
||||||
|
"reason": "推荐原因",
|
||||||
|
"tags": ["tag"],
|
||||||
|
"transcript": [
|
||||||
|
{"start_sec": 10.0, "end_sec": 15.0, "text": "片段台词"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After the command writes `thumbnail_generated.png`, Evanescere still applies the same title and VTuber overlay to produce `thumbnail_final.jpg`.
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
On the Framework Arch server:
|
||||||
|
|
||||||
|
1. Install Docker/Compose.
|
||||||
|
2. Copy `config.example.toml` to `config.toml`.
|
||||||
|
3. Set `[database].url` to the reachable PostgreSQL server.
|
||||||
|
4. Set `[redis].url = "redis://redis:6379/0"` if using Compose Redis.
|
||||||
|
5. Set `[storage].local_root = "/data/evanescere"` and mount a Framework SSD directory there.
|
||||||
|
6. Set `[webdav]` credentials and `base_url`.
|
||||||
|
7. Set `[funasr].base_url` to the local FunASR service.
|
||||||
|
8. Set `[deepseek].api_key`.
|
||||||
|
9. Keep `[upload].adapter = "noop"` until the full pipeline has been tested.
|
||||||
|
|
||||||
|
Start production services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose run --rm api alembic upgrade head
|
||||||
|
docker compose run --rm api evanescere bootstrap-existing
|
||||||
|
```
|
||||||
|
|
||||||
|
Then watch logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api scheduler worker-media worker-ai
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use your existing PostgreSQL server instead of the Compose `local-db` profile, do not start the profile. Ensure PostgreSQL accepts connections from the Framework host and that `[database].url` uses the reachable host/IP.
|
||||||
|
|
||||||
|
## Config Reference
|
||||||
|
|
||||||
|
The full commented example lives in `config.example.toml`. These are the keys the backend reads:
|
||||||
|
|
||||||
|
| Section | Key | Default | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `app` | `env` | `dev` | Free-form environment label included in logs. Use `prod` on production. |
|
||||||
|
| `app` | `log_level` | `INFO` | Python logging level. Use `DEBUG` for pipeline bring-up. |
|
||||||
|
| `app` | `log_sql` | `false` | Enables SQLAlchemy SQL logging and engine echo. Noisy in production. |
|
||||||
|
| `app` | `cors_origins` | local frontend URLs | Array of frontend origins allowed to call FastAPI. |
|
||||||
|
| `database` | `url` | local PostgreSQL URL | SQLAlchemy PostgreSQL connection string. |
|
||||||
|
| `redis` | `url` | local Redis URL | Redis broker URL for Dramatiq jobs. |
|
||||||
|
| `storage` | `local_root` | `./storage` | Root directory for downloaded sources, remuxed MP4s, audio, transcripts, subtitles, clips, and thumbnails. |
|
||||||
|
| `webdav` | `base_url` | local placeholder | IIS WebDAV directory containing recording files. |
|
||||||
|
| `webdav` | `username` | empty | WebDAV basic-auth username, if required. |
|
||||||
|
| `webdav` | `password` | empty | WebDAV basic-auth password, if required. |
|
||||||
|
| `webdav` | `verify_tls` | `true` | Whether WebDAV HTTPS certificates are verified. |
|
||||||
|
| `webdav` | `poll_interval_seconds` | `60` | Scheduler interval. A file is stable after two equal positive size samples. |
|
||||||
|
| `funasr` | `base_url` | local FunASR URL | FunASR-compatible API base URL. The client calls `/audio/transcriptions`. |
|
||||||
|
| `funasr` | `api_key` | empty | Optional bearer token for the FunASR service. |
|
||||||
|
| `funasr` | `model` | `paraformer-zh` | Model name sent to FunASR. |
|
||||||
|
| `deepseek` | `base_url` | `https://api.deepseek.com` | OpenAI-compatible DeepSeek API base URL. |
|
||||||
|
| `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. |
|
||||||
|
| `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. |
|
||||||
|
| `defaults` | `preserve_final_artifacts` | `true` | Whether rendered final artifacts are marked for preservation. |
|
||||||
|
| `defaults` | `bake_subtitles` | `true` | Whether rendered clips include hard-baked subtitles by default. |
|
||||||
|
| `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. |
|
||||||
|
| `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_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. |
|
||||||
|
| `thumbnail` | `command` | empty | Local image generation command used when `provider = "command"`. |
|
||||||
|
| `upload` | `adapter` | `noop` | `noop` or `command`. |
|
||||||
|
| `upload` | `command` | empty | Command invoked when `adapter = "command"`; receives JSON metadata on stdin. |
|
||||||
|
|
||||||
|
Frontend runtime config:
|
||||||
|
|
||||||
|
| File | Key | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `frontend/public/config.js` | `apiBaseUrl` | URL the static frontend uses to call the backend. Change this when hosting frontend/backend on different hosts. |
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
evanescere scan
|
||||||
|
evanescere bootstrap-existing
|
||||||
|
evanescere videos
|
||||||
|
evanescere run-video VIDEO_ID
|
||||||
|
evanescere transcribe VIDEO_ID
|
||||||
|
evanescere suggest VIDEO_ID
|
||||||
|
evanescere render CLIP_ID
|
||||||
|
evanescere upload CLIP_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside Compose, prefix commands with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm api
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful API endpoints for generated files:
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
Set verbose logs in `config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[app]
|
||||||
|
log_level = "DEBUG"
|
||||||
|
log_sql = false
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inspect service logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f scheduler
|
||||||
|
docker compose logs -f worker-media
|
||||||
|
docker compose logs -f worker-ai
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
What to look for:
|
||||||
|
|
||||||
|
- Scheduler logs `webdav propfind done`, `new webdav file observed`, and `webdav file stable`.
|
||||||
|
- 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.
|
||||||
|
- Thumbnail logs include frame extraction, local image generation command execution, VTuber overlay path, and final artifact path.
|
||||||
|
- API logs include method, path, status code, and elapsed time for each frontend request.
|
||||||
|
|
||||||
|
Enable SQL logs only when diagnosing database behavior:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[app]
|
||||||
|
log_sql = true
|
||||||
|
```
|
||||||
|
|
||||||
|
This is intentionally noisy and should usually stay off in production.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 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.
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql+psycopg://evanescere:evanescere@localhost:5432/evanescere
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Evanescere configuration file.
|
||||||
|
#
|
||||||
|
# Copy this file to config.toml for local Compose runs:
|
||||||
|
#
|
||||||
|
# cp config.example.toml config.toml
|
||||||
|
#
|
||||||
|
# In containers, mount your config to:
|
||||||
|
#
|
||||||
|
# /etc/evanescere/config.toml
|
||||||
|
#
|
||||||
|
# Secrets such as DeepSeek and WebDAV credentials belong in your private config.toml.
|
||||||
|
|
||||||
|
[app]
|
||||||
|
# Free-form environment label included in logs.
|
||||||
|
env = "dev"
|
||||||
|
|
||||||
|
# Python logging level: DEBUG, INFO, WARNING, ERROR.
|
||||||
|
log_level = "DEBUG"
|
||||||
|
|
||||||
|
# Enable SQLAlchemy SQL logging. Very noisy; keep false unless debugging DB behavior.
|
||||||
|
log_sql = false
|
||||||
|
|
||||||
|
# Frontend origins allowed to call FastAPI. Include the Vite dev server while developing.
|
||||||
|
cors_origins = ["http://localhost:3000", "http://localhost:5173"]
|
||||||
|
|
||||||
|
[database]
|
||||||
|
# PostgreSQL connection string used by SQLAlchemy.
|
||||||
|
# For Compose local-db profile: postgresql+psycopg://evanescere:evanescere@postgres:5432/evanescere
|
||||||
|
# For an existing DB: postgresql+psycopg://USER:PASSWORD@HOST:5432/DBNAME
|
||||||
|
url = "postgresql+psycopg://evanescere:evanescere@postgres:5432/evanescere"
|
||||||
|
|
||||||
|
[redis]
|
||||||
|
# Redis URL used by Dramatiq workers.
|
||||||
|
url = "redis://redis:6379/0"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
# Container path for working media. Mount a Framework SSD directory here in Compose.
|
||||||
|
local_root = "/data/evanescere"
|
||||||
|
|
||||||
|
[webdav]
|
||||||
|
# IIS WebDAV directory containing recording files. Point at the collection, not one file.
|
||||||
|
base_url = "https://windows-server.example.local/webdav/recordings"
|
||||||
|
|
||||||
|
# Leave username/password empty if your WebDAV endpoint does not require basic auth.
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
|
||||||
|
# Set false only for trusted internal/self-signed testing.
|
||||||
|
verify_tls = true
|
||||||
|
|
||||||
|
# Scheduler interval. A file becomes stable after two equal positive size samples.
|
||||||
|
poll_interval_seconds = 60
|
||||||
|
|
||||||
|
[funasr]
|
||||||
|
# FunASR-compatible API base URL. Evanescere calls /audio/transcriptions.
|
||||||
|
base_url = "http://funasr:10096/v1"
|
||||||
|
|
||||||
|
# Optional bearer token for the FunASR service.
|
||||||
|
api_key = ""
|
||||||
|
|
||||||
|
# Model name sent to FunASR. Adjust this to match your deployed service.
|
||||||
|
model = "paraformer-zh"
|
||||||
|
|
||||||
|
[deepseek]
|
||||||
|
# OpenAI-compatible DeepSeek API base URL.
|
||||||
|
base_url = "https://api.deepseek.com"
|
||||||
|
|
||||||
|
# Required when clip suggestion is enabled.
|
||||||
|
api_key = ""
|
||||||
|
|
||||||
|
# Initial MVP model.
|
||||||
|
model = "deepseek-v4-pro"
|
||||||
|
|
||||||
|
# Lower values improve consistency.
|
||||||
|
temperature = 0.2
|
||||||
|
|
||||||
|
[defaults]
|
||||||
|
# Initial automatic pipeline settings. These can later be changed through the API/UI.
|
||||||
|
suggest_enabled = true
|
||||||
|
render_enabled = true
|
||||||
|
|
||||||
|
# For testing, consider false until uploads are wired and verified.
|
||||||
|
upload_enabled = true
|
||||||
|
|
||||||
|
preserve_final_artifacts = true
|
||||||
|
bake_subtitles = true
|
||||||
|
|
||||||
|
[clip]
|
||||||
|
# LLM clip duration bounds accepted by the backend.
|
||||||
|
min_seconds = 30
|
||||||
|
max_seconds = 360
|
||||||
|
|
||||||
|
# Transcript seconds per DeepSeek request. Larger chunks use more tokens.
|
||||||
|
transcript_chunk_seconds = 900
|
||||||
|
|
||||||
|
[thumbnail]
|
||||||
|
# Generate thumbnails during clip render.
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# frame_overlay: use extracted video frame as background.
|
||||||
|
# command: run command below to call a local image generation workflow.
|
||||||
|
provider = "frame_overlay"
|
||||||
|
|
||||||
|
width = 1920
|
||||||
|
height = 1080
|
||||||
|
|
||||||
|
# Optional transparent PNG of the VTuber character.
|
||||||
|
character_overlay_path = ""
|
||||||
|
|
||||||
|
# Character height as a fraction of thumbnail height.
|
||||||
|
character_scale = 0.42
|
||||||
|
|
||||||
|
# Supported: bottom-right, bottom-left, center-right, center-left.
|
||||||
|
character_position = "bottom-right"
|
||||||
|
|
||||||
|
# Draw the clip title onto the final thumbnail.
|
||||||
|
title_enabled = true
|
||||||
|
|
||||||
|
# Used only when provider = "command". Receives JSON on stdin and must write output_path.
|
||||||
|
command = ""
|
||||||
|
|
||||||
|
[upload]
|
||||||
|
# noop: do not upload, only record what would have happened.
|
||||||
|
# command: run command below with JSON metadata on stdin.
|
||||||
|
adapter = "noop"
|
||||||
|
command = ""
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
command: uvicorn evanescere.api:app --host 0.0.0.0 --port 8000
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/etc/evanescere/config.toml:ro
|
||||||
|
- ./storage:/data/evanescere
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
worker-media:
|
||||||
|
build: .
|
||||||
|
command: dramatiq evanescere.jobs
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/etc/evanescere/config.toml:ro
|
||||||
|
- ./storage:/data/evanescere
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
worker-ai:
|
||||||
|
build: .
|
||||||
|
command: dramatiq evanescere.jobs
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/etc/evanescere/config.toml:ro
|
||||||
|
- ./storage:/data/evanescere
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
build: .
|
||||||
|
command: python -m evanescere.scheduler
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/etc/evanescere/config.toml:ro
|
||||||
|
- ./storage:/data/evanescere
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
profiles: ["local-db"]
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: evanescere
|
||||||
|
POSTGRES_PASSWORD: evanescere
|
||||||
|
POSTGRES_DB: evanescere
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY index.html tsconfig.json vite.config.ts ./
|
||||||
|
COPY public ./public
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Evanescere</title>
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /config.js {
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "evanescere-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
window.__EVANESCERE_FRONTEND_CONFIG__ = {
|
||||||
|
apiBaseUrl: "http://localhost:8000"
|
||||||
|
};
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Clapperboard,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Send,
|
||||||
|
Settings2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
apiBaseUrl,
|
||||||
|
approveClip,
|
||||||
|
getClips,
|
||||||
|
getSettings,
|
||||||
|
getTranscript,
|
||||||
|
getVideos,
|
||||||
|
patchSettings,
|
||||||
|
renderClip,
|
||||||
|
runVideo,
|
||||||
|
uploadClip,
|
||||||
|
} from "./api";
|
||||||
|
import type { ClipSuggestion, PipelineSettings, TranscriptSegment, Video } from "./types";
|
||||||
|
|
||||||
|
const settingLabels: Record<keyof PipelineSettings, string> = {
|
||||||
|
suggest_enabled: "Suggest",
|
||||||
|
render_enabled: "Render",
|
||||||
|
upload_enabled: "Upload",
|
||||||
|
preserve_final_artifacts: "Preserve",
|
||||||
|
bake_subtitles: "Bake subs",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(seconds: number | null) {
|
||||||
|
if (seconds === null) return "";
|
||||||
|
const total = Math.round(seconds);
|
||||||
|
const hours = Math.floor(total / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const secs = total % 60;
|
||||||
|
return hours > 0
|
||||||
|
? `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
|
||||||
|
: `${minutes}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeRange(start: number, end: number) {
|
||||||
|
return `${formatDuration(start)}-${formatDuration(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(value: string) {
|
||||||
|
if (["done", "stable", "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";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
|
const [settings, setSettings] = useState<PipelineSettings | null>(null);
|
||||||
|
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||||
|
const [transcript, setTranscript] = useState<TranscriptSegment[]>([]);
|
||||||
|
const [clips, setClips] = useState<ClipSuggestion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedVideo = useMemo(
|
||||||
|
() => videos.find((video) => video.id === selectedVideoId) ?? null,
|
||||||
|
[selectedVideoId, videos],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadSelected = useCallback(async (videoId: number) => {
|
||||||
|
const [nextTranscript, nextClips] = await Promise.all([getTranscript(videoId), getClips(videoId)]);
|
||||||
|
setTranscript(nextTranscript);
|
||||||
|
setClips(nextClips);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [nextSettings, nextVideos] = await Promise.all([getSettings(), getVideos()]);
|
||||||
|
setSettings(nextSettings);
|
||||||
|
setVideos(nextVideos);
|
||||||
|
const targetVideoId = selectedVideoId ?? nextVideos[0]?.id ?? null;
|
||||||
|
setSelectedVideoId(targetVideoId);
|
||||||
|
if (targetVideoId !== null) {
|
||||||
|
await loadSelected(targetVideoId);
|
||||||
|
} else {
|
||||||
|
setTranscript([]);
|
||||||
|
setClips([]);
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loadSelected, selectedVideoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
async function handleSelect(videoId: number) {
|
||||||
|
setSelectedVideoId(videoId);
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await loadSelected(videoId);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withRefresh(action: () => Promise<unknown>) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await refresh();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : "Unknown error");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
if (!settings) return;
|
||||||
|
await withRefresh(() => patchSettings(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>Evanescere</h1>
|
||||||
|
<p>{apiBaseUrl}</p>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar">
|
||||||
|
<button type="button" className="icon-button" onClick={() => void refresh()} disabled={loading}>
|
||||||
|
<RefreshCw size={18} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button type="button" className="icon-button primary" onClick={() => void saveSettings()} disabled={loading || !settings}>
|
||||||
|
<Save size={18} />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && <div className="error-strip">{error}</div>}
|
||||||
|
|
||||||
|
<section className="band controls-band">
|
||||||
|
<div className="section-title">
|
||||||
|
<Settings2 size={18} />
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-row">
|
||||||
|
{settings &&
|
||||||
|
(Object.keys(settingLabels) as Array<keyof PipelineSettings>).map((key) => (
|
||||||
|
<label className="switch" key={key}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings[key]}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) =>
|
||||||
|
current ? { ...current, [key]: event.currentTarget.checked } : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{settingLabels[key]}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="workbench">
|
||||||
|
<aside className="video-list">
|
||||||
|
<div className="section-title">
|
||||||
|
<Clapperboard size={18} />
|
||||||
|
<h2>Videos</h2>
|
||||||
|
</div>
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{videos.map((video) => (
|
||||||
|
<tr key={video.id} className={video.id === selectedVideoId ? "selected" : ""}>
|
||||||
|
<td>{video.id}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="link-button" onClick={() => void handleSelect(video.id)}>
|
||||||
|
{video.filename}
|
||||||
|
</button>
|
||||||
|
<div className="muted">{formatDuration(video.duration_sec)}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusPill value={video.ingest_status} />
|
||||||
|
<StatusPill value={video.processing_status} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="square-button"
|
||||||
|
title="Run pipeline"
|
||||||
|
onClick={() => void withRefresh(() => runVideo(video.id))}
|
||||||
|
>
|
||||||
|
<Play size={17} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="detail-pane">
|
||||||
|
<div className="detail-header">
|
||||||
|
<div>
|
||||||
|
<h2>{selectedVideo?.filename ?? "No video selected"}</h2>
|
||||||
|
<p>{selectedVideo?.source_url ?? ""}</p>
|
||||||
|
</div>
|
||||||
|
<div className="metric-row">
|
||||||
|
<Metric label="Transcript" value={transcript.length.toString()} />
|
||||||
|
<Metric label="Clips" value={clips.length.toString()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="split">
|
||||||
|
<div className="transcript-pane">
|
||||||
|
<h3>Transcript</h3>
|
||||||
|
<div className="transcript-lines">
|
||||||
|
{transcript.slice(0, 180).map((segment) => (
|
||||||
|
<div className="transcript-line" key={segment.id}>
|
||||||
|
<span>{formatTimeRange(segment.start_sec, segment.end_sec)}</span>
|
||||||
|
<p>{segment.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="clips-pane">
|
||||||
|
<h3>Clips</h3>
|
||||||
|
<div className="clip-list">
|
||||||
|
{clips.map((clip) => (
|
||||||
|
<article className="clip-item" key={clip.id}>
|
||||||
|
<div className="clip-main">
|
||||||
|
<div>
|
||||||
|
<h4>{clip.title_zh}</h4>
|
||||||
|
<p>{clip.summary_zh}</p>
|
||||||
|
</div>
|
||||||
|
<strong>{Math.round(clip.score * 100)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="clip-meta">
|
||||||
|
<span>{formatTimeRange(clip.start_sec, clip.end_sec)}</span>
|
||||||
|
<StatusPill value={clip.approval_status} />
|
||||||
|
<StatusPill value={clip.render_status} />
|
||||||
|
<StatusPill value={clip.upload_status} />
|
||||||
|
</div>
|
||||||
|
<div className="toolbar compact">
|
||||||
|
<button type="button" className="icon-button" onClick={() => void withRefresh(() => approveClip(clip.id))}>
|
||||||
|
<Check size={16} />
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button type="button" className="icon-button" onClick={() => void withRefresh(() => renderClip(clip.id))}>
|
||||||
|
<Send size={16} />
|
||||||
|
Render
|
||||||
|
</button>
|
||||||
|
<button type="button" className="icon-button" onClick={() => void withRefresh(() => uploadClip(clip.id))}>
|
||||||
|
<Upload size={16} />
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ value }: { value: string }) {
|
||||||
|
return <span className={`status-pill ${statusTone(value)}`}>{value}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="metric">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Artifact, ClipSuggestion, PipelineSettings, TranscriptSegment, Video } from "./types";
|
||||||
|
|
||||||
|
const runtimeApiBase = window.__EVANESCERE_FRONTEND_CONFIG__?.apiBaseUrl;
|
||||||
|
|
||||||
|
export const apiBaseUrl = (runtimeApiBase || "http://localhost:8000").replace(
|
||||||
|
/\/$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(text || `${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettings(): Promise<PipelineSettings> {
|
||||||
|
return request<PipelineSettings>("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchSettings(settings: PipelineSettings): Promise<PipelineSettings> {
|
||||||
|
return request<PipelineSettings>("/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideos(): Promise<Video[]> {
|
||||||
|
return request<Video[]>("/videos");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runVideo(videoId: number) {
|
||||||
|
return request(`/videos/${videoId}/run`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTranscript(videoId: number): Promise<TranscriptSegment[]> {
|
||||||
|
return request<TranscriptSegment[]>(`/videos/${videoId}/transcript`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClips(videoId: number): Promise<ClipSuggestion[]> {
|
||||||
|
return request<ClipSuggestion[]>(`/videos/${videoId}/clips`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClipArtifacts(clipId: number): Promise<Artifact[]> {
|
||||||
|
return request<Artifact[]>(`/clips/${clipId}/artifacts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveClip(clipId: number): Promise<ClipSuggestion> {
|
||||||
|
return request<ClipSuggestion>(`/clips/${clipId}/approve`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderClip(clipId: number): Promise<ClipSuggestion> {
|
||||||
|
return request<ClipSuggestion>(`/clips/${clipId}/render`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadClip(clipId: number): Promise<ClipSuggestion> {
|
||||||
|
return request<ClipSuggestion>(`/clips/${clipId}/upload`, { method: "POST" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color: #1f252b;
|
||||||
|
background: #f4f6f8;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
width: min(1440px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #18222b;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p,
|
||||||
|
.detail-header p,
|
||||||
|
.muted {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #66737f;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar.compact {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button,
|
||||||
|
.square-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid #b7c0ca;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f252b;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.square-button {
|
||||||
|
width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover,
|
||||||
|
.square-button:hover {
|
||||||
|
border-color: #21756b;
|
||||||
|
background: #e9f3f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.primary {
|
||||||
|
border-color: #21756b;
|
||||||
|
background: #21756b;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:disabled,
|
||||||
|
.square-button:disabled {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-strip {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-left: 4px solid #b42318;
|
||||||
|
background: #fff3f0;
|
||||||
|
color: #76180f;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band {
|
||||||
|
border-top: 1px solid #d8dee6;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-band {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #2f3b46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title h2,
|
||||||
|
.detail-header h2,
|
||||||
|
.transcript-pane h3,
|
||||||
|
.clips-pane h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 690;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #ccd3db;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #2f3b46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #21756b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(340px, 0.36fr) minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-list,
|
||||||
|
.detail-pane {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-list {
|
||||||
|
border-top: 1px solid #d8dee6;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll {
|
||||||
|
margin-top: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d8dee6;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 520px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #e4e8ed;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: #586674;
|
||||||
|
font-weight: 650;
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.selected td {
|
||||||
|
background: #eef6f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
display: inline;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #174b91;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-pane {
|
||||||
|
border-top: 1px solid #d8dee6;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
min-width: 88px;
|
||||||
|
border: 1px solid #d8dee6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
display: block;
|
||||||
|
color: #66737f;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 0.95fr) minmax(320px, 1.05fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-pane,
|
||||||
|
.clips-pane {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-lines,
|
||||||
|
.clip-list {
|
||||||
|
margin-top: 12px;
|
||||||
|
max-height: 68vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-lines {
|
||||||
|
border: 1px solid #d8dee6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 112px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-bottom: 1px solid #edf0f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line span {
|
||||||
|
color: #66737f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line p {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-item {
|
||||||
|
border: 1px solid #d8dee6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-main h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #18222b;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-main p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: #4f5c68;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-main strong {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 44px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eef6f5;
|
||||||
|
color: #155c54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #66737f;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: #edf0f3;
|
||||||
|
color: #4f5c68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.good {
|
||||||
|
background: #e5f5ec;
|
||||||
|
color: #17623b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.bad {
|
||||||
|
background: #fff0ec;
|
||||||
|
color: #a02717;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.busy {
|
||||||
|
background: #fff4d6;
|
||||||
|
color: #6f4d00;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.workbench,
|
||||||
|
.split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-lines,
|
||||||
|
.clip-list {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app-shell {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.controls-band,
|
||||||
|
.detail-header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface Video {
|
||||||
|
id: number;
|
||||||
|
source_url: string;
|
||||||
|
filename: string;
|
||||||
|
size_bytes: number | null;
|
||||||
|
duration_sec: number | null;
|
||||||
|
codec_metadata: Record<string, unknown>;
|
||||||
|
ingest_status: string;
|
||||||
|
processing_status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptSegment {
|
||||||
|
id: number;
|
||||||
|
video_id: number;
|
||||||
|
start_sec: number;
|
||||||
|
end_sec: number;
|
||||||
|
text: string;
|
||||||
|
speaker: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
segment_metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClipSuggestion {
|
||||||
|
id: number;
|
||||||
|
video_id: number;
|
||||||
|
start_sec: number;
|
||||||
|
end_sec: number;
|
||||||
|
title_zh: string;
|
||||||
|
summary_zh: string;
|
||||||
|
reason: string;
|
||||||
|
score: number;
|
||||||
|
tags: string[];
|
||||||
|
subtitle_priority: string;
|
||||||
|
approval_status: string;
|
||||||
|
render_status: string;
|
||||||
|
upload_status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineSettings {
|
||||||
|
suggest_enabled: boolean;
|
||||||
|
render_enabled: boolean;
|
||||||
|
upload_enabled: boolean;
|
||||||
|
preserve_final_artifacts: boolean;
|
||||||
|
bake_subtitles: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Artifact {
|
||||||
|
id: number;
|
||||||
|
video_id: number | null;
|
||||||
|
clip_id: number | null;
|
||||||
|
artifact_type: string;
|
||||||
|
local_path: string;
|
||||||
|
webdav_url: string | null;
|
||||||
|
preserve: boolean;
|
||||||
|
artifact_metadata: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
__EVANESCERE_FRONTEND_CONFIG__?: {
|
||||||
|
apiBaseUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Initial schema.
|
||||||
|
|
||||||
|
Revision ID: 0001_initial
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0001_initial"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"videos",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("source_url", sa.Text(), nullable=False, unique=True),
|
||||||
|
sa.Column("filename", sa.Text(), nullable=False),
|
||||||
|
sa.Column("size_bytes", sa.BigInteger()),
|
||||||
|
sa.Column("size_samples", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("duration_sec", sa.Float()),
|
||||||
|
sa.Column("codec_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("ingest_status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("processing_status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_videos_ingest_status", "videos", ["ingest_status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"pipeline_runs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("video_id", sa.Integer(), sa.ForeignKey("videos.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("trigger", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("stage", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("error", sa.Text()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"artifacts",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("video_id", sa.Integer(), sa.ForeignKey("videos.id", ondelete="CASCADE")),
|
||||||
|
sa.Column("clip_id", sa.Integer()),
|
||||||
|
sa.Column("artifact_type", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("local_path", sa.Text(), nullable=False),
|
||||||
|
sa.Column("webdav_url", sa.Text()),
|
||||||
|
sa.Column("preserve", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"transcript_segments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("video_id", sa.Integer(), sa.ForeignKey("videos.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("start_sec", sa.Float(), nullable=False),
|
||||||
|
sa.Column("end_sec", sa.Float(), nullable=False),
|
||||||
|
sa.Column("text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("speaker", sa.Text()),
|
||||||
|
sa.Column("confidence", sa.Float()),
|
||||||
|
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_transcript_segments_video_id", "transcript_segments", ["video_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"clip_suggestions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("video_id", sa.Integer(), sa.ForeignKey("videos.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("start_sec", sa.Float(), nullable=False),
|
||||||
|
sa.Column("end_sec", sa.Float(), nullable=False),
|
||||||
|
sa.Column("title_zh", sa.Text(), nullable=False),
|
||||||
|
sa.Column("summary_zh", sa.Text(), nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=False),
|
||||||
|
sa.Column("score", sa.Float(), nullable=False),
|
||||||
|
sa.Column("tags", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("subtitle_priority", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("llm_raw", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("approval_status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("render_status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("upload_status", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"settings",
|
||||||
|
sa.Column("key", sa.String(length=128), primary_key=True),
|
||||||
|
sa.Column("value", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("settings")
|
||||||
|
op.drop_table("clip_suggestions")
|
||||||
|
op.drop_index("ix_transcript_segments_video_id", table_name="transcript_segments")
|
||||||
|
op.drop_table("transcript_segments")
|
||||||
|
op.drop_table("artifacts")
|
||||||
|
op.drop_table("pipeline_runs")
|
||||||
|
op.drop_index("ix_videos_ingest_status", table_name="videos")
|
||||||
|
op.drop_table("videos")
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[project]
|
||||||
|
name = "evanescere"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Livestream transcription, clip suggestion, rendering, and upload orchestration."
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"alembic>=1.13",
|
||||||
|
"dramatiq[redis]>=1.17",
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"openai>=1.55",
|
||||||
|
"pillow>=11.0",
|
||||||
|
"psycopg[binary]>=3.2",
|
||||||
|
"pydantic>=2.9",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
|
"typer>=0.12",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3",
|
||||||
|
"ruff>=0.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
evanescere = "evanescere.cli:app"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/evanescere"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from evanescere import __version__
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.db import get_db
|
||||||
|
from evanescere.jobs import enqueue_pipeline, enqueue_render, enqueue_upload
|
||||||
|
from evanescere.logging_config import configure_logging
|
||||||
|
from evanescere.models import Artifact, ClipSuggestion, PipelineRun, TranscriptSegment, Video
|
||||||
|
from evanescere.schemas import (
|
||||||
|
ArtifactRead,
|
||||||
|
ClipSuggestionRead,
|
||||||
|
RunRead,
|
||||||
|
SettingsPatch,
|
||||||
|
SettingsRead,
|
||||||
|
TranscriptSegmentRead,
|
||||||
|
VideoRead,
|
||||||
|
)
|
||||||
|
from evanescere.settings_store import get_pipeline_settings, patch_pipeline_settings
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
app = FastAPI(title="Evanescere", version=__version__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_requests(request: Request, call_next):
|
||||||
|
start = time.perf_counter()
|
||||||
|
response = await call_next(request)
|
||||||
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||||
|
logger.info(
|
||||||
|
"api request method=%s path=%s status=%s elapsed_ms=%.1f",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
response.status_code,
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root() -> dict[str, str]:
|
||||||
|
return {"service": "evanescere-api", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/videos", response_model=list[VideoRead])
|
||||||
|
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("/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)
|
||||||
|
if video is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
|
run = PipelineRun(video_id=video_id, trigger="manual", stage="queued", status="queued")
|
||||||
|
video.processing_status = "queued"
|
||||||
|
db.add(run)
|
||||||
|
db.flush()
|
||||||
|
enqueue_pipeline(video_id, run.id)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/videos/{video_id}/transcript", response_model=list[TranscriptSegmentRead])
|
||||||
|
def video_transcript(video_id: int, db: Session = Depends(get_db)) -> list[TranscriptSegment]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(TranscriptSegment)
|
||||||
|
.where(TranscriptSegment.video_id == video_id)
|
||||||
|
.order_by(TranscriptSegment.start_sec)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/videos/{video_id}/clips", response_model=list[ClipSuggestionRead])
|
||||||
|
def video_clips(video_id: int, db: Session = Depends(get_db)) -> list[ClipSuggestion]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(ClipSuggestion)
|
||||||
|
.where(ClipSuggestion.video_id == video_id)
|
||||||
|
.order_by(ClipSuggestion.score.desc(), ClipSuggestion.start_sec)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/videos/{video_id}/artifacts", response_model=list[ArtifactRead])
|
||||||
|
def video_artifacts(video_id: int, db: Session = Depends(get_db)) -> list[Artifact]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(Artifact)
|
||||||
|
.where(Artifact.video_id == video_id)
|
||||||
|
.order_by(Artifact.created_at.desc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/clips/{clip_id}/artifacts", response_model=list[ArtifactRead])
|
||||||
|
def clip_artifacts(clip_id: int, db: Session = Depends(get_db)) -> list[Artifact]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(Artifact)
|
||||||
|
.where(Artifact.clip_id == clip_id)
|
||||||
|
.order_by(Artifact.created_at.desc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/clips/{clip_id}/approve", response_model=ClipSuggestionRead)
|
||||||
|
def approve_clip(clip_id: int, db: Session = Depends(get_db)) -> ClipSuggestion:
|
||||||
|
clip = db.get(ClipSuggestion, clip_id)
|
||||||
|
if clip is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Clip not found")
|
||||||
|
clip.approval_status = "approved"
|
||||||
|
return clip
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/clips/{clip_id}/render", response_model=ClipSuggestionRead)
|
||||||
|
def render_clip(clip_id: int, db: Session = Depends(get_db)) -> ClipSuggestion:
|
||||||
|
clip = db.get(ClipSuggestion, clip_id)
|
||||||
|
if clip is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Clip not found")
|
||||||
|
clip.render_status = "queued"
|
||||||
|
enqueue_render(clip_id)
|
||||||
|
return clip
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/clips/{clip_id}/upload", response_model=ClipSuggestionRead)
|
||||||
|
def upload_clip(clip_id: int, db: Session = Depends(get_db)) -> ClipSuggestion:
|
||||||
|
clip = db.get(ClipSuggestion, clip_id)
|
||||||
|
if clip is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Clip not found")
|
||||||
|
clip.upload_status = "queued"
|
||||||
|
enqueue_upload(clip_id)
|
||||||
|
return clip
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/runs/{run_id}", response_model=RunRead)
|
||||||
|
def get_run(run_id: int, db: Session = Depends(get_db)) -> PipelineRun:
|
||||||
|
run = db.get(PipelineRun, run_id)
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Run not found")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/artifacts/{artifact_id}", response_model=ArtifactRead)
|
||||||
|
def get_artifact(artifact_id: int, db: Session = Depends(get_db)) -> Artifact:
|
||||||
|
artifact = db.get(Artifact, artifact_id)
|
||||||
|
if artifact is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/settings", response_model=SettingsRead)
|
||||||
|
def read_settings(db: Session = Depends(get_db)) -> SettingsRead:
|
||||||
|
return get_pipeline_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/settings", response_model=SettingsRead)
|
||||||
|
def update_settings(patch: SettingsPatch, db: Session = Depends(get_db)) -> SettingsRead:
|
||||||
|
return patch_pipeline_settings(db, patch)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import typer
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
app = typer.Typer(no_args_is_help=True)
|
||||||
|
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.")
|
||||||
|
|
||||||
|
|
||||||
|
@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.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("run-video")
|
||||||
|
def run_video(video_id: int) -> None:
|
||||||
|
with session_scope() as session:
|
||||||
|
video = session.get(Video, video_id)
|
||||||
|
if video is None:
|
||||||
|
raise typer.BadParameter("Video not found")
|
||||||
|
run = PipelineRun(video_id=video_id, trigger="manual", stage="queued", status="queued")
|
||||||
|
video.processing_status = "queued"
|
||||||
|
session.add(run)
|
||||||
|
session.flush()
|
||||||
|
enqueue_pipeline(video_id, run.id)
|
||||||
|
typer.echo(f"Queued pipeline for video {video_id}.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def transcribe(video_id: int) -> None:
|
||||||
|
enqueue_transcribe(video_id)
|
||||||
|
typer.echo(f"Queued transcription for video {video_id}.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def suggest(video_id: int) -> None:
|
||||||
|
enqueue_suggest(video_id)
|
||||||
|
typer.echo(f"Queued clip suggestion for video {video_id}.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def render(clip_id: int) -> None:
|
||||||
|
enqueue_render(clip_id)
|
||||||
|
typer.echo(f"Queued render for clip {clip_id}.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def upload(clip_id: int) -> None:
|
||||||
|
enqueue_upload(clip_id)
|
||||||
|
typer.echo(f"Queued upload for clip {clip_id}.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def videos() -> None:
|
||||||
|
with session_scope() as session:
|
||||||
|
rows = session.scalars(select(Video).order_by(Video.created_at.desc())).all()
|
||||||
|
for video in rows:
|
||||||
|
typer.echo(f"{video.id}\t{video.ingest_status}\t{video.processing_status}\t{video.filename}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import AnyHttpUrl, BaseModel, Field
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_PATHS = (
|
||||||
|
Path("/etc/evanescere/config.toml"),
|
||||||
|
Path("config.toml"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseModel):
|
||||||
|
app_env: str = "dev"
|
||||||
|
log_level: str = "INFO"
|
||||||
|
log_sql: bool = False
|
||||||
|
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"])
|
||||||
|
|
||||||
|
database_url: str = "postgresql+psycopg://evanescere:evanescere@localhost:5432/evanescere"
|
||||||
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
local_storage_root: Path = Path("./storage")
|
||||||
|
|
||||||
|
webdav_base_url: AnyHttpUrl | str = "http://localhost/webdav/recordings"
|
||||||
|
webdav_username: str | None = None
|
||||||
|
webdav_password: str | None = None
|
||||||
|
webdav_verify_tls: bool = True
|
||||||
|
webdav_poll_interval_seconds: int = Field(default=60, ge=5)
|
||||||
|
|
||||||
|
funasr_base_url: str = "http://localhost:10096/v1"
|
||||||
|
funasr_api_key: str | None = None
|
||||||
|
funasr_model: str = "paraformer-zh"
|
||||||
|
|
||||||
|
deepseek_base_url: str = "https://api.deepseek.com"
|
||||||
|
deepseek_api_key: str | None = None
|
||||||
|
deepseek_model: str = "deepseek-v4-pro"
|
||||||
|
deepseek_temperature: float = 0.2
|
||||||
|
|
||||||
|
default_suggest_enabled: bool = True
|
||||||
|
default_render_enabled: bool = True
|
||||||
|
default_upload_enabled: bool = True
|
||||||
|
default_preserve_final_artifacts: bool = True
|
||||||
|
default_bake_subtitles: bool = True
|
||||||
|
|
||||||
|
upload_adapter: str = "noop"
|
||||||
|
upload_command: str | None = None
|
||||||
|
|
||||||
|
clip_min_seconds: int = 30
|
||||||
|
clip_max_seconds: int = 360
|
||||||
|
transcript_chunk_seconds: int = 900
|
||||||
|
|
||||||
|
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_scale: float = Field(default=0.42, gt=0, le=1)
|
||||||
|
thumbnail_character_position: str = "bottom-right"
|
||||||
|
thumbnail_title_enabled: bool = True
|
||||||
|
thumbnail_command: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return self.cors_origins
|
||||||
|
|
||||||
|
|
||||||
|
def configured_path() -> Path | None:
|
||||||
|
for path in DEFAULT_CONFIG_PATHS:
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_toml_settings(path: Path) -> dict[str, Any]:
|
||||||
|
with path.open("rb") as config_file:
|
||||||
|
raw = tomllib.load(config_file)
|
||||||
|
return flatten_config(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def blank_to_none(value: Any) -> Any:
|
||||||
|
if value == "":
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
app = raw.get("app", {})
|
||||||
|
database = raw.get("database", {})
|
||||||
|
redis = raw.get("redis", {})
|
||||||
|
storage = raw.get("storage", {})
|
||||||
|
webdav = raw.get("webdav", {})
|
||||||
|
funasr = raw.get("funasr", {})
|
||||||
|
deepseek = raw.get("deepseek", {})
|
||||||
|
defaults = raw.get("defaults", {})
|
||||||
|
clip = raw.get("clip", {})
|
||||||
|
thumbnail = raw.get("thumbnail", {})
|
||||||
|
upload = raw.get("upload", {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"app_env": app.get("env", "dev"),
|
||||||
|
"log_level": app.get("log_level", "INFO"),
|
||||||
|
"log_sql": app.get("log_sql", False),
|
||||||
|
"cors_origins": app.get("cors_origins", ["http://localhost:3000", "http://localhost:5173"]),
|
||||||
|
"database_url": database.get("url", Settings.model_fields["database_url"].default),
|
||||||
|
"redis_url": redis.get("url", Settings.model_fields["redis_url"].default),
|
||||||
|
"local_storage_root": storage.get("local_root", Settings.model_fields["local_storage_root"].default),
|
||||||
|
"webdav_base_url": webdav.get("base_url", Settings.model_fields["webdav_base_url"].default),
|
||||||
|
"webdav_username": blank_to_none(webdav.get("username")),
|
||||||
|
"webdav_password": blank_to_none(webdav.get("password")),
|
||||||
|
"webdav_verify_tls": webdav.get("verify_tls", True),
|
||||||
|
"webdav_poll_interval_seconds": webdav.get("poll_interval_seconds", 60),
|
||||||
|
"funasr_base_url": funasr.get("base_url", Settings.model_fields["funasr_base_url"].default),
|
||||||
|
"funasr_api_key": blank_to_none(funasr.get("api_key")),
|
||||||
|
"funasr_model": funasr.get("model", "paraformer-zh"),
|
||||||
|
"deepseek_base_url": deepseek.get("base_url", "https://api.deepseek.com"),
|
||||||
|
"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),
|
||||||
|
"default_suggest_enabled": defaults.get("suggest_enabled", True),
|
||||||
|
"default_render_enabled": defaults.get("render_enabled", True),
|
||||||
|
"default_upload_enabled": defaults.get("upload_enabled", True),
|
||||||
|
"default_preserve_final_artifacts": defaults.get("preserve_final_artifacts", True),
|
||||||
|
"default_bake_subtitles": defaults.get("bake_subtitles", True),
|
||||||
|
"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),
|
||||||
|
"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_scale": thumbnail.get("character_scale", 0.42),
|
||||||
|
"thumbnail_character_position": thumbnail.get("character_position", "bottom-right"),
|
||||||
|
"thumbnail_title_enabled": thumbnail.get("title_enabled", True),
|
||||||
|
"thumbnail_command": blank_to_none(thumbnail.get("command")),
|
||||||
|
"upload_adapter": upload.get("adapter", "noop"),
|
||||||
|
"upload_command": blank_to_none(upload.get("command")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
path = configured_path()
|
||||||
|
values = load_toml_settings(path) if path else {}
|
||||||
|
settings = Settings(**values)
|
||||||
|
settings.local_storage_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return settings
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
engine = create_engine(settings.database_url, pool_pre_ping=True, echo=settings.log_sql)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope() -> Iterator[Session]:
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Iterator[Session]:
|
||||||
|
with session_scope() as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import dramatiq
|
||||||
|
from dramatiq.brokers.redis import RedisBroker
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.db import session_scope
|
||||||
|
from evanescere.logging_config import configure_logging
|
||||||
|
from evanescere.models import Video
|
||||||
|
from evanescere.pipeline import render_clip_by_id, run_pipeline, suggest_clips, transcribe_video, upload_clip_by_id
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
redis_broker = RedisBroker(url=get_settings().redis_url)
|
||||||
|
dramatiq.set_broker(redis_broker)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_pipeline(video_id: int, run_id: int | None = None) -> None:
|
||||||
|
logger.info("enqueue pipeline video_id=%s run_id=%s", video_id, run_id)
|
||||||
|
pipeline_job.send(video_id, run_id)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_transcribe(video_id: int) -> None:
|
||||||
|
logger.info("enqueue transcribe video_id=%s", video_id)
|
||||||
|
transcribe_job.send(video_id)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_suggest(video_id: int) -> None:
|
||||||
|
logger.info("enqueue suggest video_id=%s", video_id)
|
||||||
|
suggest_job.send(video_id)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_render(clip_id: int) -> None:
|
||||||
|
logger.info("enqueue render clip_id=%s", clip_id)
|
||||||
|
render_job.send(clip_id)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_upload(clip_id: int) -> None:
|
||||||
|
logger.info("enqueue upload clip_id=%s", clip_id)
|
||||||
|
upload_job.send(clip_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dramatiq.actor(max_retries=1)
|
||||||
|
def pipeline_job(video_id: int, run_id: int | None = None) -> None:
|
||||||
|
logger.info("job start pipeline video_id=%s run_id=%s", video_id, run_id)
|
||||||
|
with session_scope() as session:
|
||||||
|
run_pipeline(session, video_id, run_id)
|
||||||
|
logger.info("job done pipeline video_id=%s run_id=%s", video_id, run_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dramatiq.actor(max_retries=1)
|
||||||
|
def transcribe_job(video_id: int) -> None:
|
||||||
|
logger.info("job start transcribe video_id=%s", video_id)
|
||||||
|
with session_scope() as session:
|
||||||
|
video = session.get(Video, video_id)
|
||||||
|
if video is None:
|
||||||
|
raise RuntimeError(f"Video {video_id} not found.")
|
||||||
|
transcribe_video(session, video)
|
||||||
|
logger.info("job done transcribe video_id=%s", video_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dramatiq.actor(max_retries=1)
|
||||||
|
def suggest_job(video_id: int) -> None:
|
||||||
|
logger.info("job start suggest video_id=%s", video_id)
|
||||||
|
with session_scope() as session:
|
||||||
|
video = session.get(Video, video_id)
|
||||||
|
if video is None:
|
||||||
|
raise RuntimeError(f"Video {video_id} not found.")
|
||||||
|
suggest_clips(session, video)
|
||||||
|
logger.info("job done suggest video_id=%s", video_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dramatiq.actor(max_retries=1)
|
||||||
|
def render_job(clip_id: int) -> None:
|
||||||
|
logger.info("job start render clip_id=%s", clip_id)
|
||||||
|
with session_scope() as session:
|
||||||
|
render_clip_by_id(session, clip_id)
|
||||||
|
logger.info("job done render clip_id=%s", clip_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dramatiq.actor(max_retries=1)
|
||||||
|
def upload_job(clip_id: int) -> None:
|
||||||
|
logger.info("job start upload clip_id=%s", clip_id)
|
||||||
|
with session_scope() as session:
|
||||||
|
upload_clip_by_id(session, clip_id)
|
||||||
|
logger.info("job done upload clip_id=%s", clip_id)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
||||||
|
stream=sys.stdout,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
logging.getLogger("httpx").setLevel(logging.DEBUG if level <= logging.DEBUG else logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.INFO if level <= logging.DEBUG else logging.WARNING)
|
||||||
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO if settings.log_sql else logging.WARNING)
|
||||||
|
logging.getLogger("evanescere").debug(
|
||||||
|
"logging configured level=%s sql=%s env=%s",
|
||||||
|
settings.log_level,
|
||||||
|
settings.log_sql,
|
||||||
|
settings.app_env,
|
||||||
|
)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Video(Base, TimestampMixin):
|
||||||
|
__tablename__ = "videos"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
source_url: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
filename: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
size_samples: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, default=list)
|
||||||
|
duration_sec: Mapped[float | None] = mapped_column(Float)
|
||||||
|
codec_metadata: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
|
||||||
|
ingest_status: Mapped[str] = mapped_column(String(32), default="observing", index=True)
|
||||||
|
processing_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
|
|
||||||
|
runs: Mapped[list["PipelineRun"]] = relationship(back_populates="video")
|
||||||
|
transcript_segments: Mapped[list["TranscriptSegment"]] = relationship(back_populates="video")
|
||||||
|
clip_suggestions: Mapped[list["ClipSuggestion"]] = relationship(back_populates="video")
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineRun(Base, TimestampMixin):
|
||||||
|
__tablename__ = "pipeline_runs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"))
|
||||||
|
trigger: Mapped[str] = mapped_column(String(32), default="manual")
|
||||||
|
stage: Mapped[str] = mapped_column(String(64), default="created")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="queued")
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
video: Mapped[Video] = relationship(back_populates="runs")
|
||||||
|
|
||||||
|
|
||||||
|
class Artifact(Base):
|
||||||
|
__tablename__ = "artifacts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
video_id: Mapped[int | None] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"))
|
||||||
|
clip_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
artifact_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
local_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
webdav_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
preserve: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
artifact_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptSegment(Base):
|
||||||
|
__tablename__ = "transcript_segments"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
|
||||||
|
start_sec: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
end_sec: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
speaker: Mapped[str | None] = mapped_column(Text)
|
||||||
|
confidence: Mapped[float | None] = mapped_column(Float)
|
||||||
|
segment_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
video: Mapped[Video] = relationship(back_populates="transcript_segments")
|
||||||
|
|
||||||
|
|
||||||
|
class ClipSuggestion(Base, TimestampMixin):
|
||||||
|
__tablename__ = "clip_suggestions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"))
|
||||||
|
start_sec: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
end_sec: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
title_zh: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
summary_zh: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
tags: Mapped[list[str]] = mapped_column(JSONB, default=list)
|
||||||
|
subtitle_priority: Mapped[str] = mapped_column(String(32), default="normal")
|
||||||
|
llm_raw: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
|
||||||
|
approval_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
|
render_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
|
upload_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
|
|
||||||
|
video: Mapped[Video] = relationship(back_populates="clip_suggestions")
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base):
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
value: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||||
|
)
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.models import Artifact, ClipSuggestion, PipelineRun, TranscriptSegment, Video
|
||||||
|
from evanescere.services.artifacts import latest_artifact, register_artifact, video_work_dir
|
||||||
|
from evanescere.services.asr import FunAsrClient, normalize_segments
|
||||||
|
from evanescere.services.llm import DeepSeekClipClient, chunk_transcript
|
||||||
|
from evanescere.services.media import extract_audio, remux_to_mp4, render_clip
|
||||||
|
from evanescere.services.subtitles import generate_clip_subtitles
|
||||||
|
from evanescere.services.thumbnail import generate_thumbnail
|
||||||
|
from evanescere.services.uploader import choose_upload_artifact, upload_artifact
|
||||||
|
from evanescere.services.webdav import WebDavClient
|
||||||
|
from evanescere.settings_store import get_pipeline_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def update_run(run: PipelineRun | None, stage: str, status: str, error: str | None = None) -> None:
|
||||||
|
if run is not None:
|
||||||
|
logger.info("run update run_id=%s video_id=%s stage=%s status=%s", run.id, run.video_id, stage, status)
|
||||||
|
run.stage = stage
|
||||||
|
run.status = status
|
||||||
|
run.error = error
|
||||||
|
|
||||||
|
|
||||||
|
def source_artifact(session: Session, video: Video) -> Artifact | None:
|
||||||
|
return latest_artifact(session, video.id, "raw_source")
|
||||||
|
|
||||||
|
|
||||||
|
def download_source(session: Session, video: Video) -> Path:
|
||||||
|
existing = source_artifact(session, video)
|
||||||
|
if existing and Path(existing.local_path).exists():
|
||||||
|
logger.info("source already downloaded video_id=%s path=%s", video.id, existing.local_path)
|
||||||
|
return Path(existing.local_path)
|
||||||
|
destination = video_work_dir(video.id) / video.filename
|
||||||
|
logger.info("downloading source video_id=%s url=%s destination=%s", video.id, video.source_url, destination)
|
||||||
|
WebDavClient.from_settings().download(video.source_url, destination)
|
||||||
|
register_artifact(
|
||||||
|
session,
|
||||||
|
video_id=video.id,
|
||||||
|
artifact_type="raw_source",
|
||||||
|
local_path=destination,
|
||||||
|
preserve=False,
|
||||||
|
metadata={"source_url": video.source_url},
|
||||||
|
)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_media(session: Session, video: Video) -> tuple[Path, Path]:
|
||||||
|
logger.info("prepare media start video_id=%s file=%s", video.id, video.filename)
|
||||||
|
source = download_source(session, video)
|
||||||
|
mp4_artifact = latest_artifact(session, video.id, "remux_mp4")
|
||||||
|
if mp4_artifact and Path(mp4_artifact.local_path).exists():
|
||||||
|
logger.info("using existing remux video_id=%s path=%s", video.id, mp4_artifact.local_path)
|
||||||
|
mp4 = Path(mp4_artifact.local_path)
|
||||||
|
else:
|
||||||
|
mp4 = remux_to_mp4(session, video, source)
|
||||||
|
audio_artifact = latest_artifact(session, video.id, "audio_wav")
|
||||||
|
if audio_artifact and Path(audio_artifact.local_path).exists():
|
||||||
|
logger.info("using existing audio video_id=%s path=%s", video.id, audio_artifact.local_path)
|
||||||
|
audio = Path(audio_artifact.local_path)
|
||||||
|
else:
|
||||||
|
audio = extract_audio(session, video, mp4)
|
||||||
|
logger.info("prepare media done video_id=%s mp4=%s audio=%s", video.id, mp4, audio)
|
||||||
|
return mp4, audio
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_video(session: Session, video: Video) -> int:
|
||||||
|
logger.info("transcription start video_id=%s", video.id)
|
||||||
|
audio_artifact = latest_artifact(session, video.id, "audio_wav")
|
||||||
|
if audio_artifact is None:
|
||||||
|
raise RuntimeError("Cannot transcribe before audio_wav artifact exists.")
|
||||||
|
payload = FunAsrClient.from_settings().transcribe(Path(audio_artifact.local_path))
|
||||||
|
transcript_path = video_work_dir(video.id) / "funasr_transcript.json"
|
||||||
|
transcript_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
register_artifact(
|
||||||
|
session,
|
||||||
|
video_id=video.id,
|
||||||
|
artifact_type="transcript_json",
|
||||||
|
local_path=transcript_path,
|
||||||
|
metadata={"source": "funasr"},
|
||||||
|
)
|
||||||
|
session.query(TranscriptSegment).filter(TranscriptSegment.video_id == video.id).delete()
|
||||||
|
for item in normalize_segments(payload):
|
||||||
|
session.add(
|
||||||
|
TranscriptSegment(
|
||||||
|
video_id=video.id,
|
||||||
|
start_sec=item["start_sec"],
|
||||||
|
end_sec=item["end_sec"],
|
||||||
|
text=item["text"],
|
||||||
|
speaker=item["speaker"],
|
||||||
|
confidence=item["confidence"],
|
||||||
|
segment_metadata=item["metadata"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
count = session.query(TranscriptSegment).filter(TranscriptSegment.video_id == video.id).count()
|
||||||
|
logger.info("transcription done video_id=%s segments=%s transcript=%s", video.id, count, transcript_path)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
segments = list(
|
||||||
|
session.scalars(
|
||||||
|
select(TranscriptSegment)
|
||||||
|
.where(TranscriptSegment.video_id == video.id)
|
||||||
|
.order_by(TranscriptSegment.start_sec)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if not segments:
|
||||||
|
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)
|
||||||
|
session.query(ClipSuggestion).filter(ClipSuggestion.video_id == video.id).delete()
|
||||||
|
for candidate in candidates:
|
||||||
|
session.add(
|
||||||
|
ClipSuggestion(
|
||||||
|
video_id=video.id,
|
||||||
|
start_sec=candidate.start_sec,
|
||||||
|
end_sec=candidate.end_sec,
|
||||||
|
title_zh=candidate.title_zh,
|
||||||
|
summary_zh=candidate.summary_zh,
|
||||||
|
reason=candidate.reason,
|
||||||
|
score=candidate.score,
|
||||||
|
tags=candidate.tags,
|
||||||
|
subtitle_priority=candidate.subtitle_priority,
|
||||||
|
llm_raw=candidate.model_dump(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
logger.info("clip suggestion done video_id=%s candidates=%s", video.id, len(candidates))
|
||||||
|
return len(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def render_clip_by_id(session: Session, clip_id: int) -> None:
|
||||||
|
logger.info("render start clip_id=%s", clip_id)
|
||||||
|
clip = session.get(ClipSuggestion, clip_id)
|
||||||
|
if clip is None:
|
||||||
|
raise RuntimeError(f"Clip {clip_id} not found.")
|
||||||
|
video = session.get(Video, clip.video_id)
|
||||||
|
if video is None:
|
||||||
|
raise RuntimeError(f"Video {clip.video_id} not found.")
|
||||||
|
mp4_artifact = latest_artifact(session, video.id, "remux_mp4")
|
||||||
|
if mp4_artifact is None:
|
||||||
|
raise RuntimeError("Cannot render before remux_mp4 artifact exists.")
|
||||||
|
settings = get_pipeline_settings(session)
|
||||||
|
segments = list(
|
||||||
|
session.scalars(
|
||||||
|
select(TranscriptSegment)
|
||||||
|
.where(TranscriptSegment.video_id == video.id)
|
||||||
|
.order_by(TranscriptSegment.start_sec)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
clip.render_status = "running"
|
||||||
|
srt, ass = generate_clip_subtitles(
|
||||||
|
session,
|
||||||
|
video=video,
|
||||||
|
clip=clip,
|
||||||
|
segments=segments,
|
||||||
|
preserve=settings.preserve_final_artifacts,
|
||||||
|
)
|
||||||
|
render_clip(
|
||||||
|
session,
|
||||||
|
video=video,
|
||||||
|
clip=clip,
|
||||||
|
source_mp4=Path(mp4_artifact.local_path),
|
||||||
|
srt_path=srt,
|
||||||
|
ass_path=ass,
|
||||||
|
bake_subtitles=settings.bake_subtitles,
|
||||||
|
preserve=settings.preserve_final_artifacts,
|
||||||
|
)
|
||||||
|
generate_thumbnail(
|
||||||
|
session,
|
||||||
|
video=video,
|
||||||
|
clip=clip,
|
||||||
|
source_mp4=Path(mp4_artifact.local_path),
|
||||||
|
transcript_segments=segments,
|
||||||
|
)
|
||||||
|
clip.render_status = "done"
|
||||||
|
logger.info("render done clip_id=%s video_id=%s", clip.id, video.id)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_clip_by_id(session: Session, clip_id: int) -> None:
|
||||||
|
logger.info("upload start clip_id=%s", clip_id)
|
||||||
|
clip = session.get(ClipSuggestion, clip_id)
|
||||||
|
if clip is None:
|
||||||
|
raise RuntimeError(f"Clip {clip_id} not found.")
|
||||||
|
clip.upload_status = "running"
|
||||||
|
artifacts = (
|
||||||
|
session.query(Artifact)
|
||||||
|
.filter(Artifact.clip_id == clip_id)
|
||||||
|
.order_by(Artifact.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
artifact = choose_upload_artifact(artifacts)
|
||||||
|
if artifact is None:
|
||||||
|
raise RuntimeError("No rendered clip artifact found for upload.")
|
||||||
|
result = upload_artifact(session, clip, artifact)
|
||||||
|
artifact.artifact_metadata = artifact.artifact_metadata | {"upload": dict(result)}
|
||||||
|
clip.upload_status = "done"
|
||||||
|
logger.info("upload done clip_id=%s result=%s", clip.id, dict(result))
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline(session: Session, video_id: int, run_id: int | None = None) -> None:
|
||||||
|
logger.info("pipeline start video_id=%s run_id=%s", video_id, run_id)
|
||||||
|
video = session.get(Video, video_id)
|
||||||
|
if video is None:
|
||||||
|
raise RuntimeError(f"Video {video_id} not found.")
|
||||||
|
run = session.get(PipelineRun, run_id) if run_id is not None else None
|
||||||
|
settings = get_pipeline_settings(session)
|
||||||
|
try:
|
||||||
|
video.processing_status = "running"
|
||||||
|
update_run(run, "media", "running")
|
||||||
|
prepare_media(session, video)
|
||||||
|
|
||||||
|
update_run(run, "transcribe", "running")
|
||||||
|
transcribe_video(session, video)
|
||||||
|
|
||||||
|
if settings.suggest_enabled:
|
||||||
|
update_run(run, "suggest", "running")
|
||||||
|
suggest_clips(session, video)
|
||||||
|
|
||||||
|
if settings.render_enabled:
|
||||||
|
clips = session.scalars(
|
||||||
|
select(ClipSuggestion).where(ClipSuggestion.video_id == video.id)
|
||||||
|
).all()
|
||||||
|
logger.info("auto render stage video_id=%s clips=%s upload_enabled=%s", video.id, len(clips), settings.upload_enabled)
|
||||||
|
for clip in clips:
|
||||||
|
clip.approval_status = "auto_approved"
|
||||||
|
render_clip_by_id(session, clip.id)
|
||||||
|
if settings.upload_enabled:
|
||||||
|
upload_clip_by_id(session, clip.id)
|
||||||
|
|
||||||
|
video.processing_status = "done"
|
||||||
|
update_run(run, "done", "done")
|
||||||
|
logger.info("pipeline done video_id=%s run_id=%s", video_id, run_id)
|
||||||
|
except Exception as exc:
|
||||||
|
video.processing_status = "failed"
|
||||||
|
update_run(run, "failed", "failed", str(exc))
|
||||||
|
logger.exception("pipeline failed video_id=%s run_id=%s", video_id, run_id)
|
||||||
|
raise
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
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
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_stable_videos() -> int:
|
||||||
|
queued = 0
|
||||||
|
with session_scope() as session:
|
||||||
|
videos = session.scalars(
|
||||||
|
select(Video).where(Video.ingest_status == "stable", Video.processing_status == "pending")
|
||||||
|
).all()
|
||||||
|
for video in videos:
|
||||||
|
run = PipelineRun(video_id=video.id, trigger="automatic", stage="queued", status="queued")
|
||||||
|
video.ingest_status = "queued"
|
||||||
|
video.processing_status = "queued"
|
||||||
|
session.add(run)
|
||||||
|
session.flush()
|
||||||
|
logger.info("queueing stable video video_id=%s run_id=%s file=%s", video.id, run.id, video.filename)
|
||||||
|
enqueue_pipeline(video.id, run.id)
|
||||||
|
queued += 1
|
||||||
|
return queued
|
||||||
|
|
||||||
|
|
||||||
|
def run_forever() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
client = WebDavClient.from_settings()
|
||||||
|
logger.info(
|
||||||
|
"scheduler starting webdav_base=%s poll_interval_seconds=%s",
|
||||||
|
settings.webdav_base_url,
|
||||||
|
settings.webdav_poll_interval_seconds,
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with session_scope() as session:
|
||||||
|
observed, stable = scan_once(client, session)
|
||||||
|
queued = enqueue_stable_videos()
|
||||||
|
logger.info("scan observed=%s newly_stable=%s queued=%s", observed, stable, queued)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("scheduler scan failed")
|
||||||
|
time.sleep(settings.webdav_poll_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_forever()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class VideoRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
source_url: str
|
||||||
|
filename: str
|
||||||
|
size_bytes: int | None
|
||||||
|
duration_sec: float | None
|
||||||
|
codec_metadata: dict[str, Any]
|
||||||
|
ingest_status: str
|
||||||
|
processing_status: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RunRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
video_id: int
|
||||||
|
trigger: str
|
||||||
|
stage: str
|
||||||
|
status: str
|
||||||
|
error: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
video_id: int | None
|
||||||
|
clip_id: int | None
|
||||||
|
artifact_type: str
|
||||||
|
local_path: str
|
||||||
|
webdav_url: str | None
|
||||||
|
preserve: bool
|
||||||
|
artifact_metadata: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptSegmentRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
video_id: int
|
||||||
|
start_sec: float
|
||||||
|
end_sec: float
|
||||||
|
text: str
|
||||||
|
speaker: str | None
|
||||||
|
confidence: float | None
|
||||||
|
segment_metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ClipSuggestionRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
video_id: int
|
||||||
|
start_sec: float
|
||||||
|
end_sec: float
|
||||||
|
title_zh: str
|
||||||
|
summary_zh: str
|
||||||
|
reason: str
|
||||||
|
score: float
|
||||||
|
tags: list[str]
|
||||||
|
subtitle_priority: str
|
||||||
|
approval_status: str
|
||||||
|
render_status: str
|
||||||
|
upload_status: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsPatch(BaseModel):
|
||||||
|
suggest_enabled: bool | None = None
|
||||||
|
render_enabled: bool | None = None
|
||||||
|
upload_enabled: bool | None = None
|
||||||
|
preserve_final_artifacts: bool | None = None
|
||||||
|
bake_subtitles: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsRead(BaseModel):
|
||||||
|
suggest_enabled: bool = True
|
||||||
|
render_enabled: bool = True
|
||||||
|
upload_enabled: bool = True
|
||||||
|
preserve_final_artifacts: bool = True
|
||||||
|
bake_subtitles: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ClipCandidate(BaseModel):
|
||||||
|
start_sec: float = Field(ge=0)
|
||||||
|
end_sec: float = Field(gt=0)
|
||||||
|
title_zh: str
|
||||||
|
summary_zh: str
|
||||||
|
reason: str
|
||||||
|
score: float = Field(ge=0, le=1)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
subtitle_priority: str = "normal"
|
||||||
|
|
||||||
|
|
||||||
|
class ClipCandidateResponse(BaseModel):
|
||||||
|
clips: list[ClipCandidate]
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Service adapters for storage, AI, media, subtitles, and upload."""
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.models import Artifact
|
||||||
|
|
||||||
|
|
||||||
|
def video_work_dir(video_id: int) -> Path:
|
||||||
|
root = get_settings().local_storage_root / "videos" / str(video_id)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def clip_work_dir(video_id: int, clip_id: int) -> Path:
|
||||||
|
root = video_work_dir(video_id) / "clips" / str(clip_id)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def register_artifact(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
artifact_type: str,
|
||||||
|
local_path: Path,
|
||||||
|
video_id: int | None = None,
|
||||||
|
clip_id: int | None = None,
|
||||||
|
preserve: bool = True,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> Artifact:
|
||||||
|
artifact = Artifact(
|
||||||
|
video_id=video_id,
|
||||||
|
clip_id=clip_id,
|
||||||
|
artifact_type=artifact_type,
|
||||||
|
local_path=str(local_path),
|
||||||
|
preserve=preserve,
|
||||||
|
artifact_metadata=metadata or {},
|
||||||
|
)
|
||||||
|
session.add(artifact)
|
||||||
|
session.flush()
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
|
||||||
|
def latest_artifact(session: Session, video_id: int, artifact_type: str) -> Artifact | None:
|
||||||
|
return (
|
||||||
|
session.query(Artifact)
|
||||||
|
.filter(Artifact.video_id == video_id, Artifact.artifact_type == artifact_type)
|
||||||
|
.order_by(Artifact.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_clip_artifact(session: Session, clip_id: int, artifact_type: str) -> Artifact | None:
|
||||||
|
return (
|
||||||
|
session.query(Artifact)
|
||||||
|
.filter(Artifact.clip_id == clip_id, Artifact.artifact_type == artifact_type)
|
||||||
|
.order_by(Artifact.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FunAsrClient:
|
||||||
|
def __init__(self, base_url: str, api_key: str | None, model: str) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_settings(cls) -> FunAsrClient:
|
||||||
|
settings = get_settings()
|
||||||
|
return cls(settings.funasr_base_url, settings.funasr_api_key, settings.funasr_model)
|
||||||
|
|
||||||
|
def transcribe(self, audio_path: Path) -> dict[str, Any]:
|
||||||
|
logger.info("funasr request start base_url=%s model=%s audio=%s", self.base_url, self.model, audio_path)
|
||||||
|
headers = {}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
with open(audio_path, "rb") as audio_file:
|
||||||
|
response = httpx.post(
|
||||||
|
f"{self.base_url}/audio/transcriptions",
|
||||||
|
headers=headers,
|
||||||
|
data={
|
||||||
|
"model": self.model,
|
||||||
|
"language": "zh",
|
||||||
|
"response_format": "verbose_json",
|
||||||
|
"timestamp_granularities[]": "segment",
|
||||||
|
},
|
||||||
|
files={"file": (audio_path.name, audio_file, "audio/wav")},
|
||||||
|
timeout=None,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
logger.info("funasr request done audio=%s keys=%s", audio_path, sorted(payload.keys()))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_segments(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
raw_segments = payload.get("segments") or payload.get("sentence_info") or []
|
||||||
|
logger.debug("normalizing asr segments raw_count=%s", len(raw_segments))
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for item in raw_segments:
|
||||||
|
start = item.get("start") or item.get("start_sec") or item.get("timestamp", [0, 0])[0]
|
||||||
|
end = item.get("end") or item.get("end_sec") or item.get("timestamp", [start, start])[1]
|
||||||
|
if isinstance(start, int) and start > 10_000:
|
||||||
|
start = start / 1000
|
||||||
|
if isinstance(end, int) and end > 10_000:
|
||||||
|
end = end / 1000
|
||||||
|
text = item.get("text") or item.get("sentence") or item.get("raw_text") or ""
|
||||||
|
if not text.strip():
|
||||||
|
continue
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"start_sec": float(start),
|
||||||
|
"end_sec": float(end),
|
||||||
|
"text": text.strip(),
|
||||||
|
"speaker": item.get("speaker"),
|
||||||
|
"confidence": item.get("confidence"),
|
||||||
|
"metadata": item,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not normalized and payload.get("text"):
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"start_sec": 0.0,
|
||||||
|
"end_sec": 0.1,
|
||||||
|
"text": str(payload["text"]).strip(),
|
||||||
|
"speaker": None,
|
||||||
|
"confidence": None,
|
||||||
|
"metadata": payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info("normalized asr segments count=%s", len(normalized))
|
||||||
|
return normalized
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.models import TranscriptSegment
|
||||||
|
from evanescere.schemas import ClipCandidate, ClipCandidateResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TranscriptChunk:
|
||||||
|
start_sec: float
|
||||||
|
end_sec: float
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekClipClient:
|
||||||
|
def __init__(self, api_key: str, base_url: str, model: str, temperature: float) -> None:
|
||||||
|
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
self.model = model
|
||||||
|
self.temperature = temperature
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_settings(cls) -> DeepSeekClipClient:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.deepseek_api_key:
|
||||||
|
raise RuntimeError("[deepseek].api_key is required for clip suggestion.")
|
||||||
|
return cls(
|
||||||
|
settings.deepseek_api_key,
|
||||||
|
settings.deepseek_base_url,
|
||||||
|
settings.deepseek_model,
|
||||||
|
settings.deepseek_temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
def suggest(self, chunks: list[TranscriptChunk]) -> list[ClipCandidate]:
|
||||||
|
logger.info("deepseek suggestion start chunks=%s model=%s", len(chunks), self.model)
|
||||||
|
candidates: list[ClipCandidate] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
candidates.extend(self._suggest_for_chunk(chunk))
|
||||||
|
ranked = dedupe_and_rank_candidates(candidates)
|
||||||
|
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]:
|
||||||
|
logger.info(
|
||||||
|
"deepseek chunk request start start=%.1f end=%.1f chars=%s",
|
||||||
|
chunk.start_sec,
|
||||||
|
chunk.end_sec,
|
||||||
|
len(chunk.text),
|
||||||
|
)
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
temperature=self.temperature,
|
||||||
|
response_format={"type": "json_object"},
|
||||||
|
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."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 = response.choices[0].message.content or "{}"
|
||||||
|
candidates = parse_clip_response(content)
|
||||||
|
usage = getattr(response, "usage", None)
|
||||||
|
logger.info(
|
||||||
|
"deepseek chunk request done start=%.1f end=%.1f candidates=%s usage=%s",
|
||||||
|
chunk.start_sec,
|
||||||
|
chunk.end_sec,
|
||||||
|
len(candidates),
|
||||||
|
usage,
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def segment_line(segment: TranscriptSegment) -> str:
|
||||||
|
return f"[{segment.start_sec:.1f}-{segment.end_sec:.1f}] {segment.text}"
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_transcript(
|
||||||
|
segments: list[TranscriptSegment], chunk_seconds: int
|
||||||
|
) -> list[TranscriptChunk]:
|
||||||
|
chunks: list[TranscriptChunk] = []
|
||||||
|
current: list[str] = []
|
||||||
|
current_start: float | None = None
|
||||||
|
current_end: float | None = None
|
||||||
|
for segment in segments:
|
||||||
|
if current_start is None:
|
||||||
|
current_start = segment.start_sec
|
||||||
|
if current_end is not None and segment.end_sec - current_start > chunk_seconds:
|
||||||
|
chunks.append(
|
||||||
|
TranscriptChunk(current_start, current_end, "\n".join(current))
|
||||||
|
)
|
||||||
|
current = []
|
||||||
|
current_start = segment.start_sec
|
||||||
|
current.append(segment_line(segment))
|
||||||
|
current_end = segment.end_sec
|
||||||
|
if current and current_start is not None and current_end is not None:
|
||||||
|
chunks.append(TranscriptChunk(current_start, current_end, "\n".join(current)))
|
||||||
|
logger.debug("chunked transcript segments=%s chunk_seconds=%s chunks=%s", len(segments), chunk_seconds, len(chunks))
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def parse_clip_response(content: str) -> list[ClipCandidate]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(content)
|
||||||
|
parsed = ClipCandidateResponse.model_validate(payload)
|
||||||
|
except (json.JSONDecodeError, ValidationError) as exc:
|
||||||
|
logger.error("invalid clip json content_prefix=%s", content[:1000])
|
||||||
|
raise ValueError(f"Invalid clip JSON from LLM: {exc}") from exc
|
||||||
|
return parsed.clips
|
||||||
|
|
||||||
|
|
||||||
|
def overlap_ratio(left: ClipCandidate, right: ClipCandidate) -> float:
|
||||||
|
overlap = max(0, min(left.end_sec, right.end_sec) - max(left.start_sec, right.start_sec))
|
||||||
|
shortest = min(left.end_sec - left.start_sec, right.end_sec - right.start_sec)
|
||||||
|
if shortest <= 0:
|
||||||
|
return 0
|
||||||
|
return overlap / shortest
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_and_rank_candidates(candidates: list[ClipCandidate]) -> list[ClipCandidate]:
|
||||||
|
valid = [
|
||||||
|
candidate
|
||||||
|
for candidate in candidates
|
||||||
|
if candidate.end_sec > candidate.start_sec
|
||||||
|
and 30 <= candidate.end_sec - candidate.start_sec <= 360
|
||||||
|
]
|
||||||
|
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)
|
||||||
|
logger.debug("dedupe candidates input=%s valid=%s chosen=%s", len(candidates), len(valid), len(chosen))
|
||||||
|
return chosen
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from evanescere.models import ClipSuggestion, Video
|
||||||
|
from evanescere.services.artifacts import clip_work_dir, register_artifact, video_work_dir
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(args: list[str]) -> None:
|
||||||
|
logger.debug("command start args=%s", args)
|
||||||
|
completed = subprocess.run(args, check=False, text=True, capture_output=True)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
logger.error("command failed returncode=%s stderr=%s", completed.returncode, completed.stderr[-4000:])
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Command failed ({completed.returncode}): {' '.join(args)}\n{completed.stderr}"
|
||||||
|
)
|
||||||
|
logger.debug("command done args=%s stderr_tail=%s", args, completed.stderr[-1000:])
|
||||||
|
|
||||||
|
|
||||||
|
def ffprobe(path: Path) -> dict:
|
||||||
|
logger.debug("ffprobe start path=%s", path)
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
|
"-show_format",
|
||||||
|
"-show_streams",
|
||||||
|
str(path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
metadata = json.loads(completed.stdout)
|
||||||
|
logger.debug("ffprobe done path=%s streams=%s", path, len(metadata.get("streams", [])))
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def duration_from_probe(metadata: dict) -> float | None:
|
||||||
|
duration = metadata.get("format", {}).get("duration")
|
||||||
|
return float(duration) if duration else None
|
||||||
|
|
||||||
|
|
||||||
|
def remux_to_mp4(session: Session, video: Video, source_path: Path) -> Path:
|
||||||
|
output = video_work_dir(video.id) / f"{Path(video.filename).stem}.mp4"
|
||||||
|
logger.info("remux start video_id=%s source=%s output=%s", video.id, source_path, output)
|
||||||
|
try:
|
||||||
|
run_command(["ffmpeg", "-y", "-i", str(source_path), "-map", "0", "-c", "copy", str(output)])
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("remux copy failed, falling back to transcode video_id=%s", video.id)
|
||||||
|
run_command(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(source_path),
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"20",
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
str(output),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
metadata = ffprobe(output)
|
||||||
|
video.duration_sec = duration_from_probe(metadata)
|
||||||
|
video.codec_metadata = metadata
|
||||||
|
register_artifact(session, video_id=video.id, artifact_type="remux_mp4", local_path=output)
|
||||||
|
logger.info("remux done video_id=%s duration=%s output=%s", video.id, video.duration_sec, output)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def extract_audio(session: Session, video: Video, media_path: Path) -> Path:
|
||||||
|
output = video_work_dir(video.id) / "audio_16k_mono.wav"
|
||||||
|
logger.info("audio extraction start video_id=%s source=%s output=%s", video.id, media_path, output)
|
||||||
|
run_command(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(media_path),
|
||||||
|
"-vn",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-c:a",
|
||||||
|
"pcm_s16le",
|
||||||
|
str(output),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
register_artifact(session, video_id=video.id, artifact_type="audio_wav", local_path=output)
|
||||||
|
logger.info("audio extraction done video_id=%s output=%s", video.id, output)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def render_clip(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
video: Video,
|
||||||
|
clip: ClipSuggestion,
|
||||||
|
source_mp4: Path,
|
||||||
|
srt_path: Path,
|
||||||
|
ass_path: Path,
|
||||||
|
bake_subtitles: bool,
|
||||||
|
preserve: bool,
|
||||||
|
) -> list[Path]:
|
||||||
|
output_dir = clip_work_dir(video.id, clip.id)
|
||||||
|
logger.info(
|
||||||
|
"render clip media start video_id=%s clip_id=%s start=%.3f end=%.3f bake=%s",
|
||||||
|
video.id,
|
||||||
|
clip.id,
|
||||||
|
clip.start_sec,
|
||||||
|
clip.end_sec,
|
||||||
|
bake_subtitles,
|
||||||
|
)
|
||||||
|
base_args = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-ss",
|
||||||
|
f"{clip.start_sec:.3f}",
|
||||||
|
"-to",
|
||||||
|
f"{clip.end_sec:.3f}",
|
||||||
|
"-i",
|
||||||
|
str(source_mp4),
|
||||||
|
]
|
||||||
|
rendered: list[Path] = []
|
||||||
|
|
||||||
|
soft_path = output_dir / "clip_soft_sub.mp4"
|
||||||
|
run_command(base_args + ["-c", "copy", str(soft_path)])
|
||||||
|
register_artifact(
|
||||||
|
session,
|
||||||
|
video_id=video.id,
|
||||||
|
clip_id=clip.id,
|
||||||
|
artifact_type="clip_soft_sub_mp4",
|
||||||
|
local_path=soft_path,
|
||||||
|
preserve=preserve,
|
||||||
|
metadata={"srt": str(srt_path), "ass": str(ass_path)},
|
||||||
|
)
|
||||||
|
rendered.append(soft_path)
|
||||||
|
|
||||||
|
if bake_subtitles:
|
||||||
|
baked_path = output_dir / "clip_baked_sub.mp4"
|
||||||
|
run_command(
|
||||||
|
base_args
|
||||||
|
+ [
|
||||||
|
"-vf",
|
||||||
|
f"ass={ass_path}",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"20",
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
str(baked_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
register_artifact(
|
||||||
|
session,
|
||||||
|
video_id=video.id,
|
||||||
|
clip_id=clip.id,
|
||||||
|
artifact_type="clip_baked_sub_mp4",
|
||||||
|
local_path=baked_path,
|
||||||
|
preserve=preserve,
|
||||||
|
metadata={"srt": str(srt_path), "ass": str(ass_path)},
|
||||||
|
)
|
||||||
|
rendered.append(baked_path)
|
||||||
|
|
||||||
|
logger.info("render clip media done video_id=%s clip_id=%s outputs=%s", video.id, clip.id, rendered)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
def extract_thumbnail_frame(session: Session, video: Video, clip: ClipSuggestion, source_mp4: Path) -> Path:
|
||||||
|
output = clip_work_dir(video.id, clip.id) / "thumbnail_base.jpg"
|
||||||
|
timestamp = max(clip.start_sec, (clip.start_sec + clip.end_sec) / 2)
|
||||||
|
logger.info("thumbnail extraction start video_id=%s clip_id=%s timestamp=%.3f", video.id, clip.id, timestamp)
|
||||||
|
run_command(["ffmpeg", "-y", "-ss", f"{timestamp:.3f}", "-i", str(source_mp4), "-frames:v", "1", str(output)])
|
||||||
|
register_artifact(
|
||||||
|
session,
|
||||||
|
video_id=video.id,
|
||||||
|
clip_id=clip.id,
|
||||||
|
artifact_type="thumbnail_base",
|
||||||
|
local_path=output,
|
||||||
|
)
|
||||||
|
logger.info("thumbnail extraction done video_id=%s clip_id=%s output=%s", video.id, clip.id, output)
|
||||||
|
return output
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from evanescere.models import ClipSuggestion, TranscriptSegment, Video
|
||||||
|
from evanescere.services.artifacts import clip_work_dir, register_artifact
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def format_srt_time(seconds: float) -> str:
|
||||||
|
millis = round(seconds * 1000)
|
||||||
|
hours, remainder = divmod(millis, 3_600_000)
|
||||||
|
minutes, remainder = divmod(remainder, 60_000)
|
||||||
|
secs, millis = divmod(remainder, 1000)
|
||||||
|
return f"{hours:02}:{minutes:02}:{secs:02},{millis:03}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_ass_time(seconds: float) -> str:
|
||||||
|
centis = round(seconds * 100)
|
||||||
|
hours, remainder = divmod(centis, 360_000)
|
||||||
|
minutes, remainder = divmod(remainder, 6_000)
|
||||||
|
secs, centis = divmod(remainder, 100)
|
||||||
|
return f"{hours}:{minutes:02}:{secs:02}.{centis:02}"
|
||||||
|
|
||||||
|
|
||||||
|
def clip_segments(segments: list[TranscriptSegment], clip: ClipSuggestion) -> list[TranscriptSegment]:
|
||||||
|
return [segment for segment in segments if segment.end_sec > clip.start_sec and segment.start_sec < clip.end_sec]
|
||||||
|
|
||||||
|
|
||||||
|
def write_srt(path: Path, segments: list[TranscriptSegment], offset_sec: float = 0) -> None:
|
||||||
|
lines: list[str] = []
|
||||||
|
for index, segment in enumerate(segments, start=1):
|
||||||
|
start = max(0, segment.start_sec - offset_sec)
|
||||||
|
end = max(start + 0.1, segment.end_sec - offset_sec)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
str(index),
|
||||||
|
f"{format_srt_time(start)} --> {format_srt_time(end)}",
|
||||||
|
segment.text.strip(),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def write_ass(path: Path, segments: list[TranscriptSegment], offset_sec: float = 0) -> None:
|
||||||
|
header = """[Script Info]
|
||||||
|
ScriptType: v4.00+
|
||||||
|
WrapStyle: 0
|
||||||
|
ScaledBorderAndShadow: yes
|
||||||
|
|
||||||
|
[V4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Default,Noto Sans CJK SC,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,3,1,2,80,80,60,1
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
"""
|
||||||
|
events = []
|
||||||
|
for segment in segments:
|
||||||
|
start = max(0, segment.start_sec - offset_sec)
|
||||||
|
end = max(start + 0.1, segment.end_sec - offset_sec)
|
||||||
|
text = segment.text.replace("\n", r"\N").replace(",", ",")
|
||||||
|
events.append(
|
||||||
|
f"Dialogue: 0,{format_ass_time(start)},{format_ass_time(end)},Default,,0,0,0,,{text}"
|
||||||
|
)
|
||||||
|
path.write_text(header + "\n".join(events) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_clip_subtitles(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
video: Video,
|
||||||
|
clip: ClipSuggestion,
|
||||||
|
segments: list[TranscriptSegment],
|
||||||
|
preserve: bool,
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
selected = clip_segments(segments, clip)
|
||||||
|
logger.info(
|
||||||
|
"subtitle generation start video_id=%s clip_id=%s selected_segments=%s",
|
||||||
|
video.id,
|
||||||
|
clip.id,
|
||||||
|
len(selected),
|
||||||
|
)
|
||||||
|
output_dir = clip_work_dir(video.id, clip.id)
|
||||||
|
srt = output_dir / "clip.srt"
|
||||||
|
ass = output_dir / "clip.ass"
|
||||||
|
write_srt(srt, selected, offset_sec=clip.start_sec)
|
||||||
|
write_ass(ass, selected, offset_sec=clip.start_sec)
|
||||||
|
register_artifact(
|
||||||
|
session,
|
||||||
|
video_id=video.id,
|
||||||
|
clip_id=clip.id,
|
||||||
|
artifact_type="subtitle_srt",
|
||||||
|
local_path=srt,
|
||||||
|
preserve=preserve,
|
||||||
|
)
|
||||||
|
register_artifact(
|
||||||
|
session,
|
||||||
|
video_id=video.id,
|
||||||
|
clip_id=clip.id,
|
||||||
|
artifact_type="subtitle_ass",
|
||||||
|
local_path=ass,
|
||||||
|
preserve=preserve,
|
||||||
|
)
|
||||||
|
logger.info("subtitle generation done video_id=%s clip_id=%s srt=%s ass=%s", video.id, clip.id, srt, ass)
|
||||||
|
return srt, ass
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.models import ClipSuggestion, TranscriptSegment, Video
|
||||||
|
from evanescere.services.artifacts import clip_work_dir, register_artifact
|
||||||
|
from evanescere.services.media import extract_thumbnail_frame
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FONT_CANDIDATES = [
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
video: Video,
|
||||||
|
clip: ClipSuggestion,
|
||||||
|
source_mp4: Path,
|
||||||
|
transcript_segments: list[TranscriptSegment],
|
||||||
|
) -> Path | None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.thumbnail_enabled:
|
||||||
|
logger.info("thumbnail disabled video_id=%s clip_id=%s", video.id, clip.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
output_dir = clip_work_dir(video.id, clip.id)
|
||||||
|
base_frame = extract_thumbnail_frame(session, video, clip, source_mp4)
|
||||||
|
provider = settings.thumbnail_provider.lower()
|
||||||
|
logger.info("thumbnail generation start video_id=%s clip_id=%s provider=%s", video.id, clip.id, provider)
|
||||||
|
|
||||||
|
if provider == "frame_overlay":
|
||||||
|
background = base_frame
|
||||||
|
elif provider == "command":
|
||||||
|
background = run_thumbnail_command(
|
||||||
|
output_dir=output_dir,
|
||||||
|
source_frame=base_frame,
|
||||||
|
video=video,
|
||||||
|
clip=clip,
|
||||||
|
transcript_segments=transcript_segments,
|
||||||
|
)
|
||||||
|
register_artifact(
|
||||||
|
session,
|
||||||
|
video_id=video.id,
|
||||||
|
clip_id=clip.id,
|
||||||
|
artifact_type="thumbnail_generated",
|
||||||
|
local_path=background,
|
||||||
|
metadata={"provider": "command"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
compose_thumbnail(
|
||||||
|
background_path=background,
|
||||||
|
output_path=final_path,
|
||||||
|
title=clip.title_zh,
|
||||||
|
character_overlay_path=character_path,
|
||||||
|
character_scale=settings.thumbnail_character_scale,
|
||||||
|
character_position=settings.thumbnail_character_position,
|
||||||
|
title_enabled=settings.thumbnail_title_enabled,
|
||||||
|
width=settings.thumbnail_width,
|
||||||
|
height=settings.thumbnail_height,
|
||||||
|
)
|
||||||
|
register_artifact(
|
||||||
|
session,
|
||||||
|
video_id=video.id,
|
||||||
|
clip_id=clip.id,
|
||||||
|
artifact_type="thumbnail_final",
|
||||||
|
local_path=final_path,
|
||||||
|
preserve=True,
|
||||||
|
metadata={
|
||||||
|
"provider": provider,
|
||||||
|
"source_frame": str(base_frame),
|
||||||
|
"background": str(background),
|
||||||
|
"character_overlay_path": settings.thumbnail_character_overlay_path or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info("thumbnail generation done video_id=%s clip_id=%s output=%s", video.id, clip.id, final_path)
|
||||||
|
return final_path
|
||||||
|
|
||||||
|
|
||||||
|
def run_thumbnail_command(
|
||||||
|
*,
|
||||||
|
output_dir: Path,
|
||||||
|
source_frame: Path,
|
||||||
|
video: Video,
|
||||||
|
clip: ClipSuggestion,
|
||||||
|
transcript_segments: list[TranscriptSegment],
|
||||||
|
) -> Path:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.thumbnail_command:
|
||||||
|
raise RuntimeError('[thumbnail].command must be set when [thumbnail].provider = "command"')
|
||||||
|
|
||||||
|
output_path = output_dir / "thumbnail_generated.png"
|
||||||
|
nearby_transcript = [
|
||||||
|
{
|
||||||
|
"start_sec": segment.start_sec,
|
||||||
|
"end_sec": segment.end_sec,
|
||||||
|
"text": segment.text,
|
||||||
|
}
|
||||||
|
for segment in transcript_segments
|
||||||
|
if segment.end_sec > clip.start_sec and segment.start_sec < clip.end_sec
|
||||||
|
][:80]
|
||||||
|
payload = {
|
||||||
|
"video_id": video.id,
|
||||||
|
"clip_id": clip.id,
|
||||||
|
"source_frame": str(source_frame),
|
||||||
|
"output_path": str(output_path),
|
||||||
|
"width": settings.thumbnail_width,
|
||||||
|
"height": settings.thumbnail_height,
|
||||||
|
"title_zh": clip.title_zh,
|
||||||
|
"summary_zh": clip.summary_zh,
|
||||||
|
"reason": clip.reason,
|
||||||
|
"tags": clip.tags,
|
||||||
|
"transcript": nearby_transcript,
|
||||||
|
}
|
||||||
|
logger.info("thumbnail command start clip_id=%s command=%s output=%s", clip.id, settings.thumbnail_command, output_path)
|
||||||
|
completed = subprocess.run(
|
||||||
|
shlex.split(settings.thumbnail_command),
|
||||||
|
input=json.dumps(payload, ensure_ascii=False),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
logger.error("thumbnail command failed clip_id=%s stderr=%s", clip.id, completed.stderr[-4000:])
|
||||||
|
raise RuntimeError(completed.stderr)
|
||||||
|
if not output_path.exists():
|
||||||
|
raise RuntimeError(f"Thumbnail command did not create {output_path}")
|
||||||
|
logger.info("thumbnail command done clip_id=%s stdout=%s", clip.id, completed.stdout[-1000:])
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def compose_thumbnail(
|
||||||
|
*,
|
||||||
|
background_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
title: str,
|
||||||
|
character_overlay_path: Path | None,
|
||||||
|
character_scale: float,
|
||||||
|
character_position: str,
|
||||||
|
title_enabled: bool,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> None:
|
||||||
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
||||||
|
|
||||||
|
background = Image.open(background_path).convert("RGB")
|
||||||
|
canvas = ImageOps.fit(background, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
canvas = ImageEnhance.Color(canvas).enhance(1.12)
|
||||||
|
canvas = ImageEnhance.Contrast(canvas).enhance(1.08)
|
||||||
|
rgba = canvas.convert("RGBA")
|
||||||
|
|
||||||
|
if title_enabled and title.strip():
|
||||||
|
draw_title(rgba, title)
|
||||||
|
|
||||||
|
if character_overlay_path:
|
||||||
|
overlay_character(rgba, character_overlay_path, character_scale, character_position)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rgba.convert("RGB").save(output_path, quality=92, optimize=True)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_title(image, title: str) -> None:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
width, height = image.size
|
||||||
|
font = load_font(max(48, width // 18))
|
||||||
|
max_text_width = int(width * 0.62)
|
||||||
|
lines = wrap_text(draw, title, font, max_text_width, max_lines=3)
|
||||||
|
line_boxes = [draw.textbbox((0, 0), line, font=font, stroke_width=2) for line in lines]
|
||||||
|
line_height = max((box[3] - box[1] for box in line_boxes), default=font.size)
|
||||||
|
block_height = line_height * len(lines) + 18 * max(0, len(lines) - 1)
|
||||||
|
left = int(width * 0.045)
|
||||||
|
top = int(height * 0.075)
|
||||||
|
padding_x = 34
|
||||||
|
padding_y = 26
|
||||||
|
block_width = min(max_text_width + padding_x * 2, int(width * 0.72))
|
||||||
|
|
||||||
|
panel = Image.new("RGBA", (block_width, block_height + padding_y * 2), (0, 0, 0, 150))
|
||||||
|
image.alpha_composite(panel, (left - padding_x, top - padding_y))
|
||||||
|
|
||||||
|
y = top
|
||||||
|
for line in lines:
|
||||||
|
draw.text(
|
||||||
|
(left, y),
|
||||||
|
line,
|
||||||
|
font=font,
|
||||||
|
fill=(255, 255, 255, 255),
|
||||||
|
stroke_width=3,
|
||||||
|
stroke_fill=(0, 0, 0, 210),
|
||||||
|
)
|
||||||
|
y += line_height + 18
|
||||||
|
|
||||||
|
|
||||||
|
def overlay_character(image, overlay_path: Path, scale: float, position: str) -> None:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if not overlay_path.exists():
|
||||||
|
logger.warning("thumbnail character overlay missing path=%s", overlay_path)
|
||||||
|
return
|
||||||
|
overlay = Image.open(overlay_path).convert("RGBA")
|
||||||
|
width, height = image.size
|
||||||
|
target_height = int(height * scale)
|
||||||
|
ratio = target_height / overlay.height
|
||||||
|
target_size = (max(1, int(overlay.width * ratio)), target_height)
|
||||||
|
overlay = overlay.resize(target_size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
margin_x = int(width * 0.035)
|
||||||
|
margin_y = int(height * 0.02)
|
||||||
|
positions = {
|
||||||
|
"bottom-right": (width - overlay.width - margin_x, height - overlay.height - margin_y),
|
||||||
|
"bottom-left": (margin_x, height - overlay.height - margin_y),
|
||||||
|
"center-right": (width - overlay.width - margin_x, (height - overlay.height) // 2),
|
||||||
|
"center-left": (margin_x, (height - overlay.height) // 2),
|
||||||
|
}
|
||||||
|
x, y = positions.get(position, positions["bottom-right"])
|
||||||
|
image.alpha_composite(overlay, (max(0, x), max(0, y)))
|
||||||
|
|
||||||
|
|
||||||
|
def load_font(size: int):
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
for candidate in FONT_CANDIDATES:
|
||||||
|
path = Path(candidate)
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(str(path), size=size)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return ImageFont.load_default(size=size)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_text(draw, text: str, font, max_width: int, max_lines: int) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
current = ""
|
||||||
|
for char in text.strip():
|
||||||
|
candidate = current + char
|
||||||
|
bbox = draw.textbbox((0, 0), candidate, font=font, stroke_width=2)
|
||||||
|
if bbox[2] - bbox[0] <= max_width or not current:
|
||||||
|
current = candidate
|
||||||
|
continue
|
||||||
|
lines.append(current)
|
||||||
|
current = char
|
||||||
|
if len(lines) == max_lines:
|
||||||
|
break
|
||||||
|
if current and len(lines) < max_lines:
|
||||||
|
lines.append(current)
|
||||||
|
if len(lines) == max_lines and len("".join(lines)) < len(text.strip()):
|
||||||
|
lines[-1] = lines[-1].rstrip(",。,. ") + "..."
|
||||||
|
return lines or [text.strip()]
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.models import Artifact, ClipSuggestion
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UploadResult(dict):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def upload_artifact(session: Session, clip: ClipSuggestion, artifact: Artifact) -> UploadResult:
|
||||||
|
settings = get_settings()
|
||||||
|
thumbnail = (
|
||||||
|
session.query(Artifact)
|
||||||
|
.filter(Artifact.clip_id == clip.id, Artifact.artifact_type == "thumbnail_final")
|
||||||
|
.order_by(Artifact.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if settings.upload_adapter == "noop":
|
||||||
|
logger.info(
|
||||||
|
"upload noop clip_id=%s artifact_id=%s path=%s thumbnail=%s",
|
||||||
|
clip.id,
|
||||||
|
artifact.id,
|
||||||
|
artifact.local_path,
|
||||||
|
thumbnail.local_path if thumbnail else None,
|
||||||
|
)
|
||||||
|
return UploadResult(
|
||||||
|
{
|
||||||
|
"adapter": "noop",
|
||||||
|
"uploaded": False,
|
||||||
|
"path": artifact.local_path,
|
||||||
|
"thumbnail_path": thumbnail.local_path if thumbnail else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if settings.upload_adapter != "command":
|
||||||
|
raise RuntimeError(f"Unknown upload adapter: {settings.upload_adapter}")
|
||||||
|
if not settings.upload_command:
|
||||||
|
raise RuntimeError('[upload].command must be set when [upload].adapter = "command"')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"clip_id": clip.id,
|
||||||
|
"video_id": clip.video_id,
|
||||||
|
"title": clip.title_zh,
|
||||||
|
"summary": clip.summary_zh,
|
||||||
|
"tags": clip.tags,
|
||||||
|
"file": artifact.local_path,
|
||||||
|
"thumbnail": thumbnail.local_path if thumbnail else None,
|
||||||
|
}
|
||||||
|
logger.info("upload command start clip_id=%s artifact_id=%s command=%s", clip.id, artifact.id, settings.upload_command)
|
||||||
|
completed = subprocess.run(
|
||||||
|
settings.upload_command.split(),
|
||||||
|
input=json.dumps(payload, ensure_ascii=False),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
logger.error("upload command failed clip_id=%s stderr=%s", clip.id, completed.stderr)
|
||||||
|
raise RuntimeError(completed.stderr)
|
||||||
|
logger.info("upload command done clip_id=%s stdout=%s", clip.id, completed.stdout[-1000:])
|
||||||
|
return UploadResult(
|
||||||
|
{
|
||||||
|
"adapter": "command",
|
||||||
|
"uploaded": True,
|
||||||
|
"path": artifact.local_path,
|
||||||
|
"stdout": completed.stdout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def choose_upload_artifact(artifacts: list[Artifact]) -> Artifact | None:
|
||||||
|
by_type = {artifact.artifact_type: artifact for artifact in artifacts}
|
||||||
|
return by_type.get("clip_baked_sub_mp4") or by_type.get("clip_soft_sub_mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_path(artifact: Artifact) -> Path:
|
||||||
|
return Path(artifact.local_path)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
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 xml.etree import ElementTree
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.models import Video
|
||||||
|
|
||||||
|
VIDEO_SUFFIXES = {".flv", ".mp4", ".mkv", ".mov"}
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WebDavFile:
|
||||||
|
url: str
|
||||||
|
filename: str
|
||||||
|
size_bytes: int
|
||||||
|
modified_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebDavClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = str(base_url).rstrip("/") + "/"
|
||||||
|
self.auth = (username, password) if username and password else None
|
||||||
|
self.verify_tls = verify_tls
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_settings(cls) -> WebDavClient:
|
||||||
|
settings = get_settings()
|
||||||
|
return cls(
|
||||||
|
str(settings.webdav_base_url),
|
||||||
|
settings.webdav_username,
|
||||||
|
settings.webdav_password,
|
||||||
|
settings.webdav_verify_tls,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_files(self) -> list[WebDavFile]:
|
||||||
|
logger.debug("webdav propfind start base_url=%s", self.base_url)
|
||||||
|
body = """<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<D:propfind xmlns:D="DAV:">
|
||||||
|
<D:prop>
|
||||||
|
<D:getcontentlength/>
|
||||||
|
<D:getlastmodified/>
|
||||||
|
<D:resourcetype/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>"""
|
||||||
|
with httpx.Client(auth=self.auth, verify=self.verify_tls, timeout=30) as client:
|
||||||
|
response = client.request("PROPFIND", self.base_url, headers={"Depth": "1"}, content=body)
|
||||||
|
response.raise_for_status()
|
||||||
|
files = parse_propfind_response(response.text, self.base_url)
|
||||||
|
logger.info("webdav propfind done base_url=%s files=%s", self.base_url, len(files))
|
||||||
|
return files
|
||||||
|
|
||||||
|
def download(self, url: str, destination) -> None:
|
||||||
|
logger.info("webdav download start url=%s destination=%s", url, destination)
|
||||||
|
with httpx.stream("GET", url, auth=self.auth, verify=self.verify_tls, timeout=None) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
with open(destination, "wb") as out_file:
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
out_file.write(chunk)
|
||||||
|
logger.info("webdav download done url=%s destination=%s", url, destination)
|
||||||
|
|
||||||
|
def upload(self, source, relative_path: str) -> str:
|
||||||
|
destination = urljoin(self.base_url, quote(relative_path.lstrip("/")))
|
||||||
|
logger.info("webdav upload start source=%s destination=%s", source, destination)
|
||||||
|
with open(source, "rb") as in_file:
|
||||||
|
response = httpx.put(
|
||||||
|
destination,
|
||||||
|
content=in_file,
|
||||||
|
auth=self.auth,
|
||||||
|
verify=self.verify_tls,
|
||||||
|
timeout=None,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info("webdav upload done source=%s destination=%s", source, destination)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def parse_propfind_response(xml_text: str, base_url: str) -> list[WebDavFile]:
|
||||||
|
ns = {"d": "DAV:"}
|
||||||
|
root = ElementTree.fromstring(xml_text)
|
||||||
|
base_path = urlparse(base_url).path.rstrip("/")
|
||||||
|
files: list[WebDavFile] = []
|
||||||
|
for response in root.findall("d:response", ns):
|
||||||
|
href = response.findtext("d:href", default="", namespaces=ns)
|
||||||
|
href_path = urlparse(href).path
|
||||||
|
if href_path.rstrip("/") == base_path:
|
||||||
|
continue
|
||||||
|
filename = PurePosixPath(href_path).name
|
||||||
|
if not filename or PurePosixPath(filename).suffix.lower() not in VIDEO_SUFFIXES:
|
||||||
|
continue
|
||||||
|
resource_type = response.find(".//d:resourcetype", ns)
|
||||||
|
if resource_type is not None and list(resource_type):
|
||||||
|
continue
|
||||||
|
size_text = response.findtext(".//d:getcontentlength", default="0", namespaces=ns)
|
||||||
|
modified = response.findtext(".//d:getlastmodified", default=None, namespaces=ns)
|
||||||
|
file_url = urljoin(base_url.rstrip("/") + "/", quote(filename))
|
||||||
|
files.append(WebDavFile(file_url, filename, int(size_text or 0), modified))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def add_size_sample(video: Video, size_bytes: int) -> None:
|
||||||
|
samples: list[dict[str, Any]] = list(video.size_samples or [])
|
||||||
|
samples.append({"at": datetime.now(UTC).isoformat(), "size_bytes": size_bytes})
|
||||||
|
video.size_samples = samples[-10:]
|
||||||
|
video.size_bytes = size_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def has_stable_size(video: Video, required_equal_samples: int = 2) -> bool:
|
||||||
|
samples = video.size_samples or []
|
||||||
|
if len(samples) < required_equal_samples:
|
||||||
|
return False
|
||||||
|
recent = samples[-required_equal_samples:]
|
||||||
|
sizes = {sample["size_bytes"] for sample in recent}
|
||||||
|
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():
|
||||||
|
video = session.query(Video).filter(Video.source_url == file.url).one_or_none()
|
||||||
|
if video is None:
|
||||||
|
video = Video(
|
||||||
|
source_url=file.url,
|
||||||
|
filename=file.filename,
|
||||||
|
size_bytes=file.size_bytes,
|
||||||
|
size_samples=[{"at": datetime.now(UTC).isoformat(), "size_bytes": file.size_bytes}],
|
||||||
|
ingest_status="existing_done",
|
||||||
|
processing_status="done",
|
||||||
|
)
|
||||||
|
session.add(video)
|
||||||
|
count += 1
|
||||||
|
logger.debug("bootstrap existing file=%s size=%s", file.filename, file.size_bytes)
|
||||||
|
session.flush()
|
||||||
|
logger.info("bootstrap existing done inserted=%s", count)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def scan_once(client: WebDavClient, session: Session) -> tuple[int, int]:
|
||||||
|
observed = 0
|
||||||
|
newly_stable = 0
|
||||||
|
for file in client.list_files():
|
||||||
|
observed += 1
|
||||||
|
video = session.query(Video).filter(Video.source_url == file.url).one_or_none()
|
||||||
|
if video is None:
|
||||||
|
logger.info("new webdav file observed file=%s size=%s", file.filename, file.size_bytes)
|
||||||
|
video = Video(
|
||||||
|
source_url=file.url,
|
||||||
|
filename=file.filename,
|
||||||
|
ingest_status="observing",
|
||||||
|
processing_status="pending",
|
||||||
|
)
|
||||||
|
session.add(video)
|
||||||
|
if video.ingest_status in {"existing_done", "stable", "queued"}:
|
||||||
|
continue
|
||||||
|
add_size_sample(video, file.size_bytes)
|
||||||
|
logger.debug("webdav size sample video_id=%s file=%s size=%s", video.id, file.filename, file.size_bytes)
|
||||||
|
if has_stable_size(video):
|
||||||
|
video.ingest_status = "stable"
|
||||||
|
newly_stable += 1
|
||||||
|
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
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from evanescere.config import get_settings
|
||||||
|
from evanescere.models import Setting
|
||||||
|
from evanescere.schemas import SettingsPatch, SettingsRead
|
||||||
|
|
||||||
|
SETTINGS_KEY = "pipeline"
|
||||||
|
|
||||||
|
|
||||||
|
def default_settings() -> SettingsRead:
|
||||||
|
settings = get_settings()
|
||||||
|
return SettingsRead(
|
||||||
|
suggest_enabled=settings.default_suggest_enabled,
|
||||||
|
render_enabled=settings.default_render_enabled,
|
||||||
|
upload_enabled=settings.default_upload_enabled,
|
||||||
|
preserve_final_artifacts=settings.default_preserve_final_artifacts,
|
||||||
|
bake_subtitles=settings.default_bake_subtitles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pipeline_settings(session: Session) -> SettingsRead:
|
||||||
|
row = session.get(Setting, SETTINGS_KEY)
|
||||||
|
if row is None:
|
||||||
|
return default_settings()
|
||||||
|
return SettingsRead(**(default_settings().model_dump() | row.value))
|
||||||
|
|
||||||
|
|
||||||
|
def patch_pipeline_settings(session: Session, patch: SettingsPatch) -> SettingsRead:
|
||||||
|
current = get_pipeline_settings(session).model_dump()
|
||||||
|
current.update({k: v for k, v in patch.model_dump().items() if v is not None})
|
||||||
|
row = session.get(Setting, SETTINGS_KEY)
|
||||||
|
if row is None:
|
||||||
|
row = Setting(key=SETTINGS_KEY, value=current)
|
||||||
|
session.add(row)
|
||||||
|
else:
|
||||||
|
row.value = current
|
||||||
|
session.flush()
|
||||||
|
return SettingsRead(**current)
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from evanescere.schemas import ClipCandidate
|
||||||
|
from evanescere.services.llm import dedupe_and_rank_candidates, parse_clip_response
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_clip_response():
|
||||||
|
candidates = parse_clip_response(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"clips": [
|
||||||
|
{
|
||||||
|
"start_sec": 10,
|
||||||
|
"end_sec": 60,
|
||||||
|
"title_zh": "标题",
|
||||||
|
"summary_zh": "摘要",
|
||||||
|
"reason": "有趣",
|
||||||
|
"score": 0.9,
|
||||||
|
"tags": ["反应"],
|
||||||
|
"subtitle_priority": "high"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert candidates[0].title_zh == "标题"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_and_rank_candidates_filters_short_and_overlapping():
|
||||||
|
candidates = [
|
||||||
|
ClipCandidate(
|
||||||
|
start_sec=0,
|
||||||
|
end_sec=20,
|
||||||
|
title_zh="too short",
|
||||||
|
summary_zh="",
|
||||||
|
reason="",
|
||||||
|
score=1,
|
||||||
|
),
|
||||||
|
ClipCandidate(
|
||||||
|
start_sec=0,
|
||||||
|
end_sec=60,
|
||||||
|
title_zh="best",
|
||||||
|
summary_zh="",
|
||||||
|
reason="",
|
||||||
|
score=0.9,
|
||||||
|
),
|
||||||
|
ClipCandidate(
|
||||||
|
start_sec=10,
|
||||||
|
end_sec=65,
|
||||||
|
title_zh="overlap",
|
||||||
|
summary_zh="",
|
||||||
|
reason="",
|
||||||
|
score=0.8,
|
||||||
|
),
|
||||||
|
ClipCandidate(
|
||||||
|
start_sec=120,
|
||||||
|
end_sec=180,
|
||||||
|
title_zh="second",
|
||||||
|
summary_zh="",
|
||||||
|
reason="",
|
||||||
|
score=0.7,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
ranked = dedupe_and_rank_candidates(candidates)
|
||||||
|
assert [candidate.title_zh for candidate in ranked] == ["best", "second"]
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from evanescere.services.subtitles import format_ass_time, format_srt_time
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_srt_time():
|
||||||
|
assert format_srt_time(3661.234) == "01:01:01,234"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_ass_time():
|
||||||
|
assert format_ass_time(3661.23) == "1:01:01.23"
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from evanescere.services.webdav import WebDavFile, has_stable_size, parse_propfind_response
|
||||||
|
|
||||||
|
|
||||||
|
class DummyVideo:
|
||||||
|
def __init__(self, samples):
|
||||||
|
self.size_samples = samples
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_stable_size_requires_two_equal_positive_samples():
|
||||||
|
assert not has_stable_size(DummyVideo([]))
|
||||||
|
assert not has_stable_size(DummyVideo([{"size_bytes": 100}]))
|
||||||
|
assert not has_stable_size(DummyVideo([{"size_bytes": 100}, {"size_bytes": 101}]))
|
||||||
|
assert has_stable_size(DummyVideo([{"size_bytes": 100}, {"size_bytes": 100}]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_propfind_response_filters_video_files():
|
||||||
|
xml = """<?xml version="1.0"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/webdav/recordings/</D:href>
|
||||||
|
<D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat>
|
||||||
|
</D:response>
|
||||||
|
<D:response>
|
||||||
|
<D:href>/webdav/recordings/stream.flv</D:href>
|
||||||
|
<D:propstat><D:prop><D:getcontentlength>123</D:getcontentlength></D:prop></D:propstat>
|
||||||
|
</D:response>
|
||||||
|
<D:response>
|
||||||
|
<D:href>/webdav/recordings/readme.txt</D:href>
|
||||||
|
<D:propstat><D:prop><D:getcontentlength>12</D:getcontentlength></D:prop></D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>"""
|
||||||
|
assert parse_propfind_response(xml, "https://host/webdav/recordings") == [
|
||||||
|
WebDavFile("https://host/webdav/recordings/stream.flv", "stream.flv", 123, None)
|
||||||
|
]
|
||||||
|
|
||||||
Reference in New Issue
Block a user