diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..09fef91 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + node: + name: Node builds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + docs/package-lock.json + + - name: Install app dependencies + run: npm ci + + - name: Install docs dependencies + run: npm --prefix docs ci + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm run test + + - name: Build app + run: npm run build + + - name: Build render bundle + run: npm run build:render + + - name: Build docs + run: npm run docs:build + + rust: + name: Rust check + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + crate: + - backend + - render + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ${{ matrix.crate }}/target + key: ${{ runner.os }}-cargo-${{ matrix.crate }}-${{ hashFiles(format('{0}/Cargo.lock', matrix.crate)) }} + + - name: Check ${{ matrix.crate }} + run: cargo check --manifest-path ${{ matrix.crate }}/Cargo.toml diff --git a/.gitignore b/.gitignore index 63fd77c..e17ea1d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ dist-render docs/.docusaurus docs/build docs/.cache +.playwright-mcp target frames diff --git a/.prettierignore b/.prettierignore index 4735c04..cb48119 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,7 @@ frames bin docs/build docs/.docusaurus +.playwright-mcp output.mp4 output.gif output_youtube.mp4 diff --git a/AGENTS.md b/AGENTS.md index a459d89..84b6351 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ ## Instructions for AI agents This project is a bundle of the FrameScript video editor & motion graphics tool and its project. -The files the user should mainly edit are in the `project/` directory and `project/project.tsx`, while the FrameScript core and libraries are in `src/`. +The files the user should mainly edit are in the `project/` directory. `project/project.vue` is the primary Vue SFC authoring surface, and `project/project.tsx` is the React/FrameScript entrypoint that mounts it. Please read the code under `src/` carefully and comprehensively. As a rule, it is preferable not to edit code under `src/` except for contributing to FrameScript. (If the user explicitly wants edits, follow that.) diff --git a/README.ja.md b/README.ja.md index d727120..4ea2d79 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,101 +1,89 @@ ![](./frame-script.gif) -FrameScriptはReact + CSSで描画・編集する動画編集 & モーショングラフィックスソフトです。 +FrameScript.vue は Vue SFC + CSS で動画を記述できる FrameScript の fork です。 Discord -## FrameScriptの特徴 +## FrameScript の特徴 -- React+CSSに代表されるWebのフロントエンド技術を用いて動画を構築 -- `useAnimation`のAPIを用いた細かなアニメーション制御 -- Rustによって構築された効率的なレンダリングシステム +- Vue SFC + CSS に代表される Web のフロントエンド技術を用いて動画を構築 +- フレーム値を使った Vue の `computed` による細かなアニメーション制御 +- Rust によって構築された効率的なレンダリングシステム -## Reactで動画を構成 +## Vue SFC で動画を構成 -```tsx -import { Clip } from "../src/lib/clip" -import { Project, type ProjectSettings } from "../src/lib/project" -import { TimeLine } from "../src/lib/timeline" -import { Video } from "../src/lib/video/video" +Vue SFC で書く場合は `project/project.vue` を編集します。`project/project.tsx` は Vue プロジェクトをマウントする React/FrameScript 側のエントリポイントで、高度な統合が必要な場合に編集します。 -// プロジェクトの設定 -export const PROJECT_SETTINGS: ProjectSettings = { - name: "framescript-minimal", - width: 1920, - height: 1080, - fps: 60, -} +```vue + -// プロジェクトの定義 -// ここに要素を付け足していくことで動画を構築する -export const PROJECT = () => { - return ( - - - {/* はタイムラインに表示される要素 */} - {/* タイムライン上の長さは - - ) -} + ``` -## アニメーションAPI +## アニメーション API -`useAnimation`を使うと`async/await`を用いてアニメーションの細かな操作を実現できます +Vue SFC ではフレーム値を reactive に読み、`computed` と scoped CSS でアニメーションを書けます。 -```tsx -import { useAnimation, useVariable } from "../src/lib/animation" +```vue + + + + + ``` circle_move ## QuickStart -(実行にはNode.jsが必要です) +実行には Node.js `^20.19.0 || >=22.12.0` と npm `>=10` が必要です。 ```bash npm init @frame-script/latest @@ -103,6 +91,12 @@ cd npm run start ``` +`npm run start` はクリーン checkout から動く source/dev モードを起動します。Rust バイナリと render アセットを含む production 近い経路は `npm run start:bin` を使います。 + ## ドキュメント -- [FrameScript Docs](https://frame-script.github.io/FrameScript/ja) +- [FrameScript.vue docs](./docs/docs/index.md) +- [Production runbook](./docs/docs/production.md) +- [Third-party binary notices](./THIRD_PARTY_NOTICES.md) + +FrameScript.vue は upstream FrameScript の考え方を引き継ぎつつ、このリポジトリでは Vue SFC + CSS によるプロジェクト記述を主軸にしています。 diff --git a/README.md b/README.md index 24ece91..666c2cc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![](./frame-script.gif) -FrameScript is a video editing & motion graphics tool built with React + CSS. +FrameScript.vue is a fork of FrameScript for authoring video projects with Vue SFC + CSS. Discord @@ -10,94 +10,82 @@ FrameScript is a video editing & motion graphics tool built with React + CSS. ## FrameScript features -- Build videos with web front-end technologies such as React + CSS -- Fine-grained animation control with the `useAnimation` API +- Build videos with web front-end technologies such as Vue SFC + CSS +- Fine-grained animation control with reactive frame composables - Efficient rendering system built in Rust -## Build videos with React +## Build videos with Vue SFC -```tsx -import { Clip } from "../src/lib/clip" -import { Project, type ProjectSettings } from "../src/lib/project" -import { TimeLine } from "../src/lib/timeline" -import { Video } from "../src/lib/video/video" +Edit `project/project.vue` for Vue SFC authoring. `project/project.tsx` is the React/FrameScript entrypoint that mounts the Vue project and can be edited for advanced integration. -// Project settings -export const PROJECT_SETTINGS: ProjectSettings = { - name: "framescript-minimal", - width: 1920, - height: 1080, - fps: 60, -} +```vue + -// Project definition -// Add elements here to build the video -export const PROJECT = () => { - return ( - - - {/* is an element displayed on the timeline */} - {/* The timeline length reflects the - - ) -} + ``` ## Animation API -With `useAnimation`, you can control animations in detail using `async/await`. +In Vue SFCs, use reactive frame values and scoped CSS. -```tsx -import { useAnimation, useVariable } from "../src/lib/animation" +```vue + + + + + ``` circle_move ## QuickStart -(Requires Node.js) +Requires Node.js `^20.19.0 || >=22.12.0` and npm `>=10`. ```bash npm init @frame-script/latest @@ -105,6 +93,12 @@ cd npm run start ``` +`npm run start` launches source/dev mode from a clean checkout. Use `npm run start:bin` for the production-like path that builds Rust binaries and packaged render assets. + ## Documentation -- [FrameScript Docs](https://frame-script.github.io/FrameScript/) +- [FrameScript.vue docs](./docs/docs/index.md) +- [Production runbook](./docs/docs/production.md) +- [Third-party binary notices](./THIRD_PARTY_NOTICES.md) + +FrameScript.vue tracks the upstream FrameScript ideas, but this repository focuses on Vue SFC + CSS project authoring. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..958a33c --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,20 @@ +# Third-Party Binary Notices + +FrameScript.vue is MIT licensed, but release artifacts may include third-party binaries with separate redistribution terms. + +## Bundled Runtime Components + +- Electron: includes Chromium and Node.js runtime components. Preserve Electron/Chromium notices in packaged app artifacts. +- Puppeteer Chromium: if `FRAMESCRIPT_CHROMIUM_PATH` or Puppeteer-managed Chromium is bundled, include Chromium notices. +- `@ffmpeg-installer/ffmpeg`: provides ffmpeg binaries. Include the license and source/redistribution notes shipped by the package. +- `@ffprobe-installer/ffprobe`: provides ffprobe binaries. Include the license and source/redistribution notes shipped by the package. + +## Release Checklist + +- Include this file and `LICENSE` in packaged artifacts. +- Include the license/notice files from bundled binary packages. +- Record exact binary versions used for ffmpeg, ffprobe, Electron, and Chromium. +- Verify platform-specific artifacts contain `backend`, `render`, `dist/`, `dist-render/`, and `dist-electron/`. +- Re-check redistribution obligations whenever binary package versions change. + +This file is a release engineering checklist, not legal advice. diff --git a/backend/src/decoder.rs b/backend/src/decoder.rs index 4dc8e6d..86ef8d5 100644 --- a/backend/src/decoder.rs +++ b/backend/src/decoder.rs @@ -16,7 +16,7 @@ use crate::{ }; use tracing::warn; -pub static DECODER: LazyLock = LazyLock::new(|| Decoder::new()); +pub static DECODER: LazyLock = LazyLock::new(Decoder::new); static FPS_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -179,6 +179,9 @@ impl CachedDecoder { tokio::spawn(async move { loop { + if self_clone.inner.closed.load(Ordering::Relaxed) { + break; + } if ENTIRE_CACHE_SIZE.load(Ordering::Relaxed) >= MAX_CACHE_SIZE.load(Ordering::Relaxed) { @@ -231,10 +234,7 @@ impl CachedDecoder { pub async fn get_frame(&self, frame_index: u32) -> Arc> { let future = { let mut frames = self.inner.frames.write().unwrap(); - frames - .entry(frame_index) - .or_insert_with(|| SharedManualFuture::new()) - .clone() + frames.entry(frame_index).or_default().clone() }; if let Some(frame) = future.get_now() { @@ -330,10 +330,10 @@ impl CachedDecoder { } if let Some(drop_index) = drop_index { let removed = self.inner.frames.write().unwrap().remove(&drop_index); - if let Some(future) = removed { - if let Some(cached) = future.get_now() { - ENTIRE_CACHE_SIZE.fetch_sub(cached.len(), Ordering::Relaxed); - } + if let Some(future) = removed + && let Some(cached) = future.get_now() + { + ENTIRE_CACHE_SIZE.fetch_sub(cached.len(), Ordering::Relaxed); } } } @@ -342,9 +342,33 @@ impl CachedDecoder { } fn close(&self) { - self.inner.closed.store(true, Ordering::Relaxed); + if self.inner.closed.swap(true, Ordering::Relaxed) { + return; + } + self.clear_cached_frames(); self.inner.stream_notify.notify_one(); } + + fn clear_cached_frames(&self) { + { + let mut pending = self.inner.pending_frames.lock().unwrap(); + pending.clear(); + } + { + let mut recent = self.inner.recent_frames.lock().unwrap(); + recent.clear(); + } + { + let mut pinned = self.inner.pinned_frame.lock().unwrap(); + *pinned = None; + } + let mut frames = self.inner.frames.write().unwrap(); + for future in frames.drain().map(|(_, future)| future) { + if let Some(cached) = future.get_now() { + ENTIRE_CACHE_SIZE.fetch_sub(cached.len(), Ordering::Relaxed); + } + } + } } struct FrameStream { @@ -520,10 +544,9 @@ async fn run_stream_loop(inner: Arc) { if let Some(min_pending) = { let pending = inner.pending_frames.lock().unwrap(); pending.iter().next().cloned() - } { - if min_pending < current_frame { - break; - } + } && min_pending < current_frame + { + break; } let frame = match stream_ref.read_next().await { @@ -581,11 +604,11 @@ async fn run_stream_loop(inner: Arc) { frames.get(¤t_frame).cloned() }; - if let Some(future) = future { - if !future.is_completed() { - ENTIRE_CACHE_SIZE.fetch_add(frame.len(), Ordering::Relaxed); - future.complete(Arc::new(frame)).await; - } + if let Some(future) = future + && !future.is_completed() + { + ENTIRE_CACHE_SIZE.fetch_add(frame.len(), Ordering::Relaxed); + future.complete(Arc::new(frame)).await; } } diff --git a/backend/src/ffmpeg.rs b/backend/src/ffmpeg.rs index 37844e7..a07922f 100644 --- a/backend/src/ffmpeg.rs +++ b/backend/src/ffmpeg.rs @@ -1,7 +1,7 @@ +pub(crate) mod bin; +pub(crate) mod command; pub mod hw_decoder; pub mod sw_decoder; -pub(crate) mod command; -pub(crate) mod bin; use serde::Deserialize; use std::process::Command; @@ -115,7 +115,9 @@ pub fn probe_video_duration_ms(path: &str) -> Result { .as_ref() .and_then(|format| parse_duration_seconds(format.duration.as_deref())); - let seconds = stream_duration.or(format_duration).ok_or_else(|| "failed to read duration".to_string())?; + let seconds = stream_duration + .or(format_duration) + .ok_or_else(|| "failed to read duration".to_string())?; Ok((seconds * 1000.0).round().max(0.0) as u64) } @@ -136,16 +138,18 @@ pub fn probe_video_frames(path: &str) -> Result { .nb_read_frames .as_deref() .and_then(|value| value.parse::().ok()) + && frames > 0 { - if frames > 0 { - return Ok(frames); - } + return Ok(frames); } - if let Some(frames) = stream.nb_frames.as_deref().and_then(|value| value.parse::().ok()) { - if frames > 0 { - return Ok(frames); - } + if let Some(frames) = stream + .nb_frames + .as_deref() + .and_then(|value| value.parse::().ok()) + && frames > 0 + { + return Ok(frames); } let duration = parse_duration_seconds(stream.duration.as_deref()); @@ -158,7 +162,12 @@ pub fn probe_video_frames(path: &str) -> Result { } pub fn probe_video_fps(path: &str) -> Result { - let output = run_ffprobe(path, Some("v:0"), "stream=avg_frame_rate,r_frame_rate", false)?; + let output = run_ffprobe( + path, + Some("v:0"), + "stream=avg_frame_rate,r_frame_rate", + false, + )?; let stream = output .streams .as_ref() diff --git a/backend/src/ffmpeg/bin.rs b/backend/src/ffmpeg/bin.rs index 42ff43b..a3cca87 100644 --- a/backend/src/ffmpeg/bin.rs +++ b/backend/src/ffmpeg/bin.rs @@ -1,6 +1,7 @@ -use std::io; use std::process::Command; use std::sync::{Mutex, OnceLock}; +use std::{fs, io}; +use tracing::info; static FFMPEG_PATH: OnceLock>> = OnceLock::new(); static FFPROBE_PATH: OnceLock>> = OnceLock::new(); @@ -26,26 +27,40 @@ fn resolve_with_cache( return Ok(path.clone()); } + if let Some(path) = read_env_path(env_var) { + validate_binary(&path, name)?; + info!("selected {name} binary: {path}"); + *cached = Some(path.clone()); + return Ok(path); + } + match Command::new(name).arg("-version").output() { Ok(_) => { let path = name.to_string(); + info!("selected {name} binary: {path}"); *cached = Some(path.clone()); Ok(path) } Err(error) if error.kind() == io::ErrorKind::NotFound => { - if let Some(path) = read_env_path(env_var) { - *cached = Some(path.clone()); - Ok(path) - } else { - Err(format!( - "{name} not found on PATH and {env_var} is not set" - )) - } + Err(format!("{name} not found on PATH and {env_var} is not set")) } Err(error) => Err(format!("failed to run {name}: {error}")), } } +fn validate_binary(path: &str, name: &str) -> Result<(), String> { + let metadata = fs::metadata(path) + .map_err(|error| format!("{name} binary does not exist at {path}: {error}"))?; + if !metadata.is_file() { + return Err(format!("{name} binary path is not a file: {path}")); + } + Command::new(path) + .arg("-version") + .output() + .map_err(|error| format!("failed to execute {name} at {path}: {error}"))?; + Ok(()) +} + pub(crate) fn ffmpeg_path() -> Result { resolve_with_cache(&FFMPEG_PATH, "ffmpeg", "FRAMESCRIPT_FFMPEG_PATH") } diff --git a/backend/src/ffmpeg/sw_decoder.rs b/backend/src/ffmpeg/sw_decoder.rs index a3cf3f1..77b3731 100644 --- a/backend/src/ffmpeg/sw_decoder.rs +++ b/backend/src/ffmpeg/sw_decoder.rs @@ -6,8 +6,14 @@ pub fn extract_frame_sw_rgba( dst_width: u32, dst_height: u32, ) -> Result, String> { - let frames = - extract_frames_rgba(path, target_frame, target_frame + 1, dst_width, dst_height, false)?; + let frames = extract_frames_rgba( + path, + target_frame, + target_frame + 1, + dst_width, + dst_height, + false, + )?; if let Some(frame) = frames.into_iter().next() { Ok(frame) } else { diff --git a/backend/src/future.rs b/backend/src/future.rs index 43ae83d..f5d4b94 100644 --- a/backend/src/future.rs +++ b/backend/src/future.rs @@ -5,9 +5,11 @@ use std::{ use manual_future::{ManualFuture, ManualFutureCompleter}; +type SharedManualFutureState = (Option>, Vec>>); + #[derive(Debug)] pub struct SharedManualFuture { - value: Arc>, Vec>>)>>, + value: Arc>>, } impl SharedManualFuture { @@ -66,6 +68,12 @@ impl SharedManualFuture { } } +impl Default for SharedManualFuture { + fn default() -> Self { + Self::new() + } +} + impl Clone for SharedManualFuture { fn clone(&self) -> Self { Self { diff --git a/backend/src/main.rs b/backend/src/main.rs index dca42d1..e8c7b33 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,12 +1,13 @@ pub mod decoder; pub mod ffmpeg; pub mod future; +pub mod security; pub mod util; use std::{ net::SocketAddr, ops::Bound, - sync::atomic::AtomicBool, + sync::{Arc, atomic::AtomicBool}, time::{SystemTime, UNIX_EPOCH}, }; @@ -37,7 +38,7 @@ use crate::{ probe_audio_duration_ms, probe_video_dimensions, probe_video_duration_ms, probe_video_fps, probe_video_frames, }, - util::resolve_path_to_string, + security::SecurityConfig, }; #[derive(Deserialize)] @@ -56,7 +57,9 @@ struct FileQuery { } #[derive(Clone)] -struct AppState; +struct AppState { + security: Arc, +} #[derive(Deserialize, Debug)] struct FrameRequest { @@ -187,7 +190,9 @@ async fn main() { tracing_subscriber::fmt::init(); - let app_state = AppState; + let app_state = AppState { + security: Arc::new(SecurityConfig::from_env()), + }; let app = Router::new() .route("/ws", get(ws_handler)) .route("/video", get(video_handler).options(options_handler)) @@ -235,7 +240,10 @@ async fn main() { .route("/healthz", get(healthz_handler).options(options_handler)) .with_state(app_state); - let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); + let addr = std::env::var("FRAMESCRIPT_BACKEND_ADDR") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 3000))); let listener = TcpListener::bind(addr).await.unwrap(); info!("listening on {addr}"); println!("[backend ready] listening on {addr}"); @@ -243,16 +251,26 @@ async fn main() { serve(listener, app).await.unwrap(); } -async fn ws_handler(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_socket(socket, state)) +async fn ws_handler( + headers: HeaderMap, + ws: WebSocketUpgrade, + State(state): State, +) -> Result { + state.security.authorize_origin(&headers)?; + Ok(ws.on_upgrade(move |socket| handle_socket(socket, state))) } async fn video_handler( - State(_state): State, + request_headers: HeaderMap, + State(state): State, Query(VideoQuery { path }): Query, range: Option>, ) -> Result { - let resolved_path = resolve_path_to_string(&path).map_err(|_| StatusCode::BAD_REQUEST)?; + state.security.authorize_media(&request_headers)?; + let resolved_path = state + .security + .resolve_media_path(&path) + .map_err(|_| StatusCode::BAD_REQUEST)?; let mut file = tokio::fs::File::open(&resolved_path) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -309,36 +327,37 @@ async fn video_handler( let mut resp = axum::response::Response::new(axum::body::Body::from_stream(body)); *resp.status_mut() = status; - let headers = resp.headers_mut(); - headers.insert( - header::ACCEPT_RANGES, - header::HeaderValue::from_static("bytes"), - ); - if let Ok(v) = header::HeaderValue::from_str(&content_length.to_string()) { - headers.insert(header::CONTENT_LENGTH, v); + let response_headers = resp.headers_mut(); + response_headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); + if let Ok(v) = HeaderValue::from_str(&content_length.to_string()) { + response_headers.insert(header::CONTENT_LENGTH, v); } - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static("video/mp4"), - ); + response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("video/mp4")); if let Some(range_str) = content_range { - headers.insert( + response_headers.insert( header::CONTENT_RANGE, - header::HeaderValue::from_str(&range_str) - .unwrap_or_else(|_| header::HeaderValue::from_static("bytes */*")), + HeaderValue::from_str(&range_str) + .unwrap_or_else(|_| HeaderValue::from_static("bytes */*")), ); } - apply_cors(headers); + state + .security + .apply_cors(&request_headers, response_headers); Ok(resp) } async fn audio_handler( - State(_state): State, + request_headers: HeaderMap, + State(state): State, Query(AudioQuery { path }): Query, range: Option>, ) -> Result { - let resolved_path = resolve_path_to_string(&path).map_err(|_| StatusCode::BAD_REQUEST)?; + state.security.authorize_media(&request_headers)?; + let resolved_path = state + .security + .resolve_media_path(&path) + .map_err(|_| StatusCode::BAD_REQUEST)?; let mut file = tokio::fs::File::open(&resolved_path) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -395,52 +414,72 @@ async fn audio_handler( let mut resp = axum::response::Response::new(axum::body::Body::from_stream(body)); *resp.status_mut() = status; - let headers = resp.headers_mut(); - headers.insert( - header::ACCEPT_RANGES, - header::HeaderValue::from_static("bytes"), - ); - if let Ok(v) = header::HeaderValue::from_str(&content_length.to_string()) { - headers.insert(header::CONTENT_LENGTH, v); + let response_headers = resp.headers_mut(); + response_headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); + if let Ok(v) = HeaderValue::from_str(&content_length.to_string()) { + response_headers.insert(header::CONTENT_LENGTH, v); } - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static("audio/mp4"), - ); + response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("audio/mp4")); if let Some(range_str) = content_range { - headers.insert( + response_headers.insert( header::CONTENT_RANGE, - header::HeaderValue::from_str(&range_str) - .unwrap_or_else(|_| header::HeaderValue::from_static("bytes */*")), + HeaderValue::from_str(&range_str) + .unwrap_or_else(|_| HeaderValue::from_static("bytes */*")), ); } - apply_cors(headers); + state + .security + .apply_cors(&request_headers, response_headers); Ok(resp) } -fn cors_status(status: StatusCode) -> axum::response::Response { +fn cors_status( + security: &SecurityConfig, + request_headers: &HeaderMap, + status: StatusCode, +) -> axum::response::Response { let mut resp = axum::response::Response::new(axum::body::Body::empty()); *resp.status_mut() = status; - apply_cors(resp.headers_mut()); + security.apply_cors(request_headers, resp.headers_mut()); resp } async fn file_handler( - State(_state): State, + request_headers: HeaderMap, + State(state): State, Query(FileQuery { path }): Query, ) -> Result { - let resolved_path = match resolve_path_to_string(&path) { + state.security.authorize_media(&request_headers)?; + let resolved_path = match state.security.resolve_media_path(&path) { Ok(value) => value, - Err(_) => return Ok(cors_status(StatusCode::BAD_REQUEST)), + Err(_) => { + return Ok(cors_status( + &state.security, + &request_headers, + StatusCode::BAD_REQUEST, + )); + } }; let file = match tokio::fs::File::open(&resolved_path).await { Ok(value) => value, - Err(_) => return Ok(cors_status(StatusCode::NOT_FOUND)), + Err(_) => { + return Ok(cors_status( + &state.security, + &request_headers, + StatusCode::NOT_FOUND, + )); + } }; let metadata = match file.metadata().await { Ok(value) => value, - Err(_) => return Ok(cors_status(StatusCode::INTERNAL_SERVER_ERROR)), + Err(_) => { + return Ok(cors_status( + &state.security, + &request_headers, + StatusCode::INTERNAL_SERVER_ERROR, + )); + } }; let len = metadata.len(); @@ -448,22 +487,27 @@ async fn file_handler( let mut resp = axum::response::Response::new(axum::body::Body::from_stream(stream)); *resp.status_mut() = StatusCode::OK; - let headers = resp.headers_mut(); - headers.insert( + let response_headers = resp.headers_mut(); + response_headers.insert( header::CONTENT_TYPE, - header::HeaderValue::from_static("application/octet-stream"), + HeaderValue::from_static("application/octet-stream"), ); - if let Ok(v) = header::HeaderValue::from_str(&len.to_string()) { - headers.insert(header::CONTENT_LENGTH, v); + if let Ok(v) = HeaderValue::from_str(&len.to_string()) { + response_headers.insert(header::CONTENT_LENGTH, v); } - apply_cors(headers); + state + .security + .apply_cors(&request_headers, response_headers); Ok(resp) } -async fn healthz_handler() -> impl IntoResponse { +async fn healthz_handler( + request_headers: HeaderMap, + State(state): State, +) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); (headers, StatusCode::OK) } @@ -477,10 +521,15 @@ struct VideoMetadataResponse { } async fn video_meta_handler( - State(_state): State, + request_headers: HeaderMap, + State(state): State, Query(VideoQuery { path }): Query, ) -> Result { - let resolved_path = resolve_path_to_string(&path).map_err(|_| StatusCode::BAD_REQUEST)?; + state.security.authorize_media(&request_headers)?; + let resolved_path = state + .security + .resolve_media_path(&path) + .map_err(|_| StatusCode::BAD_REQUEST)?; let duration_ms = probe_video_duration_ms(&resolved_path).map_err(|_| StatusCode::BAD_REQUEST)?; @@ -497,7 +546,9 @@ async fn video_meta_handler( height, }) .into_response(); - apply_cors(resp.headers_mut()); + state + .security + .apply_cors(&request_headers, resp.headers_mut()); Ok(resp) } @@ -507,19 +558,26 @@ struct AudioMetadataResponse { } async fn audio_meta_handler( - State(_state): State, + request_headers: HeaderMap, + State(state): State, Query(AudioQuery { path }): Query, ) -> Result { - let resolved_path = resolve_path_to_string(&path).map_err(|_| StatusCode::BAD_REQUEST)?; + state.security.authorize_media(&request_headers)?; + let resolved_path = state + .security + .resolve_media_path(&path) + .map_err(|_| StatusCode::BAD_REQUEST)?; let duration_ms = probe_audio_duration_ms(&resolved_path).map_err(|_| StatusCode::BAD_REQUEST)?; let mut resp = Json(AudioMetadataResponse { duration_ms }).into_response(); - apply_cors(resp.headers_mut()); + state + .security + .apply_cors(&request_headers, resp.headers_mut()); Ok(resp) } -async fn handle_socket(mut socket: WebSocket, _state: AppState) { +async fn handle_socket(mut socket: WebSocket, state: AppState) { let session_id = NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed) as u64; info!("client connected"); @@ -546,7 +604,10 @@ async fn handle_socket(mut socket: WebSocket, _state: AppState) { let height = req.height; let target_frame = req.frame; - let path = resolve_path_to_string(&req.video).unwrap_or_default(); + let path = state + .security + .resolve_media_path(&req.video) + .unwrap_or_default(); let decoder = DECODER .cached_decoder(DecoderKey { @@ -588,32 +649,43 @@ async fn handle_socket(mut socket: WebSocket, _state: AppState) { info!("client disconnected"); } -async fn options_handler() -> impl IntoResponse { +async fn options_handler( + request_headers: HeaderMap, + State(state): State, +) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); (headers, StatusCode::NO_CONTENT) } async fn set_cache_size_handler( - State(_state): State, + request_headers: HeaderMap, + State(state): State, Json(payload): Json, ) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status); + } - let gib = payload.gib.max(1).min(128); // clamp to a sane range - let bytes = gib as usize * 1024 * 1024 * 1024; + let gib = payload.gib.clamp(1, 128); + let bytes = gib * 1024 * 1024 * 1024; set_max_cache_size(bytes); (headers, StatusCode::OK) } async fn set_progress_handler( - State(_state): State, + request_headers: HeaderMap, + State(state): State, Json(payload): Json, ) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status); + } if let Some(total) = payload.total { RENDER_TOTAL.store(total, Ordering::Relaxed); @@ -628,16 +700,22 @@ async fn set_progress_handler( (headers, StatusCode::OK) } -async fn get_progress_handler(State(_state): State) -> impl IntoResponse { +async fn get_progress_handler( + request_headers: HeaderMap, + State(state): State, +) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status).into_response(); + } let response = ProgressResponse { completed: RENDER_COMPLETED.load(Ordering::Relaxed), total: RENDER_TOTAL.load(Ordering::Relaxed), }; - (headers, Json(response)) + (headers, Json(response)).into_response() } fn now_ms() -> u64 { @@ -648,11 +726,15 @@ fn now_ms() -> u64 { } async fn render_log_handler( - State(_state): State, + request_headers: HeaderMap, + State(state): State, Json(payload): Json, ) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status); + } let entry = RenderLogEntry { timestamp_ms: now_ms(), @@ -685,31 +767,55 @@ async fn render_log_handler( (headers, StatusCode::OK) } -async fn get_render_log_handler(State(_state): State) -> impl IntoResponse { +async fn get_render_log_handler( + request_headers: HeaderMap, + State(state): State, +) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status).into_response(); + } let logs = RENDER_LOGS.lock().unwrap().clone(); - (headers, Json(logs)) + (headers, Json(logs)).into_response() } -async fn render_cancel_handler(State(_state): State) -> impl IntoResponse { +async fn render_cancel_handler( + request_headers: HeaderMap, + State(state): State, +) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status); + } RENDER_CANCEL.store(true, Ordering::Relaxed); (headers, StatusCode::OK) } -async fn is_canceled_handler(State(_state): State) -> impl IntoResponse { +async fn is_canceled_handler( + request_headers: HeaderMap, + State(state): State, +) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status).into_response(); + } let canceled = RENDER_CANCEL.load(Ordering::Relaxed); - (headers, Json(serde_json::json!({ "canceled": canceled }))) + (headers, Json(serde_json::json!({ "canceled": canceled }))).into_response() } -async fn reset_handler(State(_state): State) -> impl IntoResponse { +async fn reset_handler( + request_headers: HeaderMap, + State(state): State, +) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status); + } DECODER.clear().await; RENDER_CANCEL.store(false, Ordering::Relaxed); *RENDER_AUDIO_PLAN.lock().unwrap() = None; @@ -718,11 +824,15 @@ async fn reset_handler(State(_state): State) -> impl IntoResponse { } async fn set_audio_plan_handler( - State(_state): State, + request_headers: HeaderMap, + State(state): State, Json(payload): Json, ) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status); + } let fps = if payload.fps.is_finite() && payload.fps > 0.0 { payload.fps @@ -741,10 +851,14 @@ async fn set_audio_plan_handler( let source_start_frame = seg.source_start_frame.max(0); let resolved_source = match seg.source { - AudioSourceRef::Video { path } => resolve_path_to_string(&path) + AudioSourceRef::Video { path } => state + .security + .resolve_media_path(&path) .ok() .map(|p| AudioSourceResolved::Video { path: p }), - AudioSourceRef::Sound { path } => resolve_path_to_string(&path) + AudioSourceRef::Sound { path } => state + .security + .resolve_media_path(&path) .ok() .map(|p| AudioSourceResolved::Sound { path: p }), }; @@ -772,11 +886,7 @@ async fn set_audio_plan_handler( } let fade_in_frames = seg.fade_in_frames.unwrap_or(0).max(0).min(duration_frames); - let fade_out_frames = seg - .fade_out_frames - .unwrap_or(0) - .max(0) - .min(duration_frames); + let fade_out_frames = seg.fade_out_frames.unwrap_or(0).max(0).min(duration_frames); let volume = match seg.volume { Some(value) if value.is_finite() => value.max(0.0), _ => 1.0, @@ -803,9 +913,15 @@ async fn set_audio_plan_handler( (headers, StatusCode::OK) } -async fn get_audio_plan_handler(State(_state): State) -> impl IntoResponse { +async fn get_audio_plan_handler( + request_headers: HeaderMap, + State(state): State, +) -> impl IntoResponse { let mut headers = HeaderMap::new(); - apply_cors(&mut headers); + state.security.apply_cors(&request_headers, &mut headers); + if let Err(status) = state.security.authorize_capability(&request_headers) { + return (headers, status).into_response(); + } let plan = RENDER_AUDIO_PLAN .lock() @@ -817,20 +933,5 @@ async fn get_audio_plan_handler(State(_state): State) -> impl IntoResp loudness: None, }); - (headers, Json(plan)) -} - -fn apply_cors(headers: &mut HeaderMap) { - headers.insert( - header::ACCESS_CONTROL_ALLOW_ORIGIN, - HeaderValue::from_static("*"), - ); - headers.insert( - header::ACCESS_CONTROL_ALLOW_METHODS, - HeaderValue::from_static("GET, OPTIONS, POST"), - ); - headers.insert( - header::ACCESS_CONTROL_ALLOW_HEADERS, - HeaderValue::from_static("*"), - ); + (headers, Json(plan)).into_response() } diff --git a/backend/src/security.rs b/backend/src/security.rs new file mode 100644 index 0000000..d24f319 --- /dev/null +++ b/backend/src/security.rs @@ -0,0 +1,272 @@ +use std::{ + env, + error::Error, + path::{Path, PathBuf}, +}; + +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; + +use crate::util::resolve_path_to_path_buf; + +#[derive(Clone, Debug)] +pub struct SecurityConfig { + allowed_origins: Vec, + session_token: Option, + media_roots: Vec, +} + +impl SecurityConfig { + pub fn from_env() -> Self { + let allowed_origins = env::var("FRAMESCRIPT_ALLOWED_ORIGINS") + .ok() + .map(parse_comma_list) + .filter(|items| !items.is_empty()) + .unwrap_or_else(default_allowed_origins); + + let session_token = env::var("FRAMESCRIPT_BACKEND_TOKEN") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let media_roots = env::var_os("FRAMESCRIPT_MEDIA_ROOTS") + .map(|value| env::split_paths(&value).collect::>()) + .filter(|items| !items.is_empty()) + .unwrap_or_else(default_media_roots) + .into_iter() + .filter_map(|path| canonicalize_existing_root(&path)) + .collect::>(); + + Self { + allowed_origins, + session_token, + media_roots, + } + } + + pub fn is_origin_trusted(&self, origin: &str) -> bool { + self.allowed_origins + .iter() + .any(|allowed| allowed == "*" || allowed == origin) + } + + pub fn origin_is_allowed(&self, headers: &HeaderMap) -> bool { + let Some(origin) = origin_header(headers) else { + return true; + }; + self.is_origin_trusted(origin) + } + + pub fn has_valid_token(&self, headers: &HeaderMap) -> bool { + let Some(expected) = self.session_token.as_deref() else { + return false; + }; + + if let Some(value) = headers + .get("x-framescript-token") + .and_then(|value| value.to_str().ok()) + && constant_time_eq(value.trim().as_bytes(), expected.as_bytes()) + { + return true; + } + + let Some(auth) = headers.get(header::AUTHORIZATION) else { + return false; + }; + let Ok(auth) = auth.to_str() else { + return false; + }; + let Some(token) = auth.trim().strip_prefix("Bearer ") else { + return false; + }; + constant_time_eq(token.trim().as_bytes(), expected.as_bytes()) + } + + pub fn authorize_origin(&self, headers: &HeaderMap) -> Result<(), StatusCode> { + if self.origin_is_allowed(headers) || self.has_valid_token(headers) { + Ok(()) + } else { + Err(StatusCode::FORBIDDEN) + } + } + + pub fn authorize_media(&self, headers: &HeaderMap) -> Result<(), StatusCode> { + if self.origin_is_allowed(headers) || self.has_valid_token(headers) { + Ok(()) + } else { + Err(StatusCode::FORBIDDEN) + } + } + + pub fn authorize_capability(&self, headers: &HeaderMap) -> Result<(), StatusCode> { + self.authorize_origin(headers)?; + if self.session_token.is_some() && !self.has_valid_token(headers) { + return Err(StatusCode::UNAUTHORIZED); + } + Ok(()) + } + + pub fn resolve_media_path(&self, input: &str) -> Result> { + let path = resolve_path_to_path_buf(input)?; + let canonical = dunce::canonicalize(&path)?; + + if self.media_roots.is_empty() || self.path_is_in_media_roots(&canonical) { + Ok(canonical.to_string_lossy().into_owned()) + } else { + Err("path is outside allowed media roots".into()) + } + } + + fn path_is_in_media_roots(&self, path: &Path) -> bool { + self.media_roots.iter().any(|root| path.starts_with(root)) + } + + pub fn apply_cors(&self, request_headers: &HeaderMap, response_headers: &mut HeaderMap) { + if let Some(origin) = origin_header(request_headers) + && self.is_origin_trusted(origin) + { + if let Ok(value) = HeaderValue::from_str(origin) { + response_headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, value); + } + response_headers.insert( + header::VARY, + HeaderValue::from_static("origin, access-control-request-headers"), + ); + } + + response_headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, OPTIONS, POST"), + ); + response_headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("content-type, authorization, x-framescript-token"), + ); + } +} + +fn origin_header(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn parse_comma_list(value: String) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn default_allowed_origins() -> Vec { + vec![ + "http://localhost:5173".to_string(), + "http://127.0.0.1:5173".to_string(), + "http://localhost:5174".to_string(), + "http://127.0.0.1:5174".to_string(), + "file://".to_string(), + "null".to_string(), + ] +} + +fn default_media_roots() -> Vec { + let mut roots = Vec::new(); + if let Ok(root) = env::var("FRAMESCRIPT_PROJECT_ROOT") { + roots.push(PathBuf::from(root)); + } + if let Ok(cwd) = env::current_dir() { + roots.push(cwd); + } + if let Ok(home) = env::var("HOME") { + roots.push(PathBuf::from(home)); + } + roots +} + +fn canonicalize_existing_root(path: &Path) -> Option { + dunce::canonicalize(path).ok() +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter() + .zip(right.iter()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_untrusted_origin() { + let config = SecurityConfig { + allowed_origins: vec!["http://localhost:5173".to_string()], + session_token: Some("secret".to_string()), + media_roots: vec![PathBuf::from("/")], + }; + let mut headers = HeaderMap::new(); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("https://bad.example"), + ); + + assert_eq!( + config.authorize_origin(&headers), + Err(StatusCode::FORBIDDEN), + ); + } + + #[test] + fn accepts_bearer_token_for_native_requests() { + let config = SecurityConfig { + allowed_origins: Vec::new(), + session_token: Some("secret".to_string()), + media_roots: vec![PathBuf::from("/")], + }; + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer secret"), + ); + + assert!(config.authorize_capability(&headers).is_ok()); + } + + #[test] + fn rejects_mutation_without_configured_token() { + let config = SecurityConfig { + allowed_origins: vec!["http://localhost:5173".to_string()], + session_token: Some("secret".to_string()), + media_roots: vec![PathBuf::from("/")], + }; + let mut headers = HeaderMap::new(); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("http://localhost:5173"), + ); + + assert_eq!( + config.authorize_capability(&headers), + Err(StatusCode::UNAUTHORIZED), + ); + } + + #[test] + fn checks_path_roots() { + let root = dunce::canonicalize(env::temp_dir()).unwrap(); + let config = SecurityConfig { + allowed_origins: Vec::new(), + session_token: None, + media_roots: vec![root.clone()], + }; + assert!(config.path_is_in_media_roots(&root.join("example.mp4"))); + assert!(!config.path_is_in_media_roots(Path::new("/definitely-not-the-temp-root"))); + } +} diff --git a/backend/src/util.rs b/backend/src/util.rs index 01d019f..00157fe 100644 --- a/backend/src/util.rs +++ b/backend/src/util.rs @@ -1,6 +1,6 @@ use std::{env, error::Error, path::PathBuf}; -pub fn resolve_path_to_string(input: &str) -> Result> { +pub fn resolve_path_to_path_buf(input: &str) -> Result> { let env_expanded = shellexpand::env(input)?; // -> Cow let tilde_expanded = shellexpand::tilde(&env_expanded); @@ -8,7 +8,10 @@ pub fn resolve_path_to_string(input: &str) -> Result> { let mut path = PathBuf::from(tilde_expanded.as_ref()); if !path.is_absolute() { - path = env::current_dir()?.join(path); + let base = env::var("FRAMESCRIPT_PROJECT_ROOT") + .map(PathBuf::from) + .unwrap_or(env::current_dir()?); + path = base.join(path); } path = match dunce::canonicalize(&path) { @@ -16,5 +19,10 @@ pub fn resolve_path_to_string(input: &str) -> Result> { Err(_) => path, }; + Ok(path) +} + +pub fn resolve_path_to_string(input: &str) -> Result> { + let path = resolve_path_to_path_buf(input)?; Ok(path.to_string_lossy().into_owned()) } diff --git a/docs/docs/basic.md b/docs/docs/basic.md index a6e6970..101f958 100644 --- a/docs/docs/basic.md +++ b/docs/docs/basic.md @@ -5,37 +5,42 @@ sidebar_position: 2 ## Basic structure -Your project lives in `project/project.tsx`. -You build the video by adding elements here. +Write your video in `project/project.vue`. +`project/project.tsx` is only a compatibility wrapper that mounts the SFC into FrameScript Studio and the renderer. + +```vue + + + +``` + +Project settings still live in `project/project.tsx` because FrameScript core modules read them at build time. ```tsx -import { Clip } from "../src/lib/clip" -import { Project, type ProjectSettings } from "../src/lib/project" -import { TimeLine } from "../src/lib/timeline" -import { Video } from "../src/lib/video/video" +import { VueProjectRoot } from "../src/lib/vue" +import type { ProjectSettings } from "../src/lib/project" +import ProjectVue from "./project.vue" -// Project settings export const PROJECT_SETTINGS: ProjectSettings = { - name: "framescript-minimal", + name: "framescript-vue-template", width: 1920, height: 1080, fps: 60, } -// Project definition -// Add elements here to build the video export const PROJECT = () => { return ( - - - {/* is a timeline segment */} - {/* Timeline length follows - + ) } ``` diff --git a/docs/docs/index.md b/docs/docs/index.md index 2e139f9..d54a59d 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -3,8 +3,8 @@ title: FrameScript Docs sidebar_position: 1 --- -FrameScript is a code-first motion graphics and video editing toolkit built with React and CSS. -You author scenes as React components, preview them in Studio, and render video through headless Chromium. +FrameScript.vue is a code-first motion graphics and video editing toolkit built for Vue SFC and CSS. +You author scenes in `.vue` files, preview them in Studio, and render video through headless Chromium. ## Quick start @@ -40,4 +40,6 @@ FrameScript Studio should launch. ### Edit the project -Your project lives in `project/project.tsx`. +Your main editable Vue project lives in `project/project.vue`. The `project/project.tsx` file is the React/FrameScript entrypoint that mounts the Vue project and is available for advanced integration. + +For production builds and render troubleshooting, see the [Production Runbook](./production.md). diff --git a/docs/docs/lib/media.md b/docs/docs/lib/media.md index c0147d9..5fdb8a3 100644 --- a/docs/docs/lib/media.md +++ b/docs/docs/lib/media.md @@ -79,6 +79,8 @@ import { Character } from "../src/lib/sound/character" /> ``` +When the same audio file is reused in multiple clips, lip-sync and waveform lookups are scoped by the clip/audio segment identity first, then fall back to the single matching source path. This keeps repeated voice files aligned to the clip instance that owns them. + ### `` Controls animations such as lip-sync using a PSD file. diff --git a/docs/docs/production.md b/docs/docs/production.md new file mode 100644 index 0000000..46b2e78 --- /dev/null +++ b/docs/docs/production.md @@ -0,0 +1,100 @@ +--- +title: Production Runbook +sidebar_position: 6 +--- + +This runbook covers production-style builds, local rendering, packaged artifact layout, and first-response checks for render failures. + +## Supported toolchain + +- Node.js `^20.19.0 || >=22.12.0` +- npm `>=10` +- Rust stable toolchain with Cargo +- macOS, Linux x64, or Windows x64 for the bundled `backend` and `render` binaries + +Use `npm ci` from a clean checkout. Docs have their own lockfile, so use `npm --prefix docs ci` before docs builds. + +## Development startup + +`npm run start` launches source/dev mode: + +- Vite Studio on `http://localhost:5173` +- Vite render page on `http://localhost:5174/render` +- Electron in source mode, which starts the Rust backend with `cargo run` + +This path does not require prebuilt files under `bin/`. + +## Production-style local run + +Use: + +```bash +npm run start:bin +``` + +It runs the full web build, builds Rust binaries, and starts Electron in binary mode. The default production build creates: + +- `dist/` for the Studio app +- `dist-render/` for the headless render page +- `dist-electron/` for Electron main/preload code +- `bin//backend` and `bin//render` + +`npm run start:bin:skip-build` starts from existing outputs only. + +## Packaged artifact layout + +Packaged apps should keep the same runtime contract: + +- Electron main/preload JavaScript in `dist-electron/` +- Studio assets in `dist/` +- render page assets in `dist-render/` +- Rust binaries in `resources/bin//backend` and `resources/bin//render` +- third-party binary notices shipped next to app license notices + +Electron also checks `process.resourcesPath/bin//` and explicit environment overrides: + +- `FRAMESCRIPT_BACKEND_BIN` +- `FRAMESCRIPT_RENDER_BIN` +- `FRAMESCRIPT_FFMPEG_PATH` +- `FRAMESCRIPT_FFPROBE_PATH` +- `FRAMESCRIPT_CHROMIUM_PATH` + +The render settings preload currently runs with `sandbox: false` so it can use Electron IPC through `contextBridge`. Renderer pages keep `nodeIntegration: false` and `contextIsolation: true`. + +## Backend security configuration + +The Electron app generates `FRAMESCRIPT_BACKEND_TOKEN` per app session and passes it to backend/render child processes. Browser-origin requests are restricted to trusted origins. + +Useful overrides: + +- `FRAMESCRIPT_BACKEND_URL`, default `http://127.0.0.1:3000` +- `FRAMESCRIPT_BACKEND_ADDR`, default `127.0.0.1:3000` +- `FRAMESCRIPT_ALLOWED_ORIGINS`, comma-separated +- `FRAMESCRIPT_MEDIA_ROOTS`, path-delimited allowed file roots +- `FRAMESCRIPT_PROJECT_ROOT`, base for relative media paths + +## Render outputs + +The render binary writes intermediate segments into a per-render temp directory. The final output is promoted to `FRAMESCRIPT_OUTPUT_PATH` or `output.mp4` only after concat and audio mux succeed. Canceled renders keep partial files out of the final output path. + +## Smoke checks + +Run: + +```bash +npm run typecheck +npm run test +npm run smoke:backend +cargo check --manifest-path backend/Cargo.toml +cargo check --manifest-path render/Cargo.toml +``` + +`smoke:backend` starts the backend on an alternate port, verifies `/healthz`, and checks that mutation APIs reject missing session tokens. + +## Failure triage + +- Backend does not start: verify Rust is installed, `FRAMESCRIPT_BACKEND_ADDR` is free, and the backend binary exists in binary mode. +- Render exits before frames: verify `dist-render/render.html`, Chromium path, and render page URL. +- Media fails to load: verify the path is inside `FRAMESCRIPT_MEDIA_ROOTS` and the request origin is trusted. +- ffmpeg mismatch: check logs for the selected `FRAMESCRIPT_FFMPEG_PATH` / `FRAMESCRIPT_FFPROBE_PATH`. +- Canceled render left no output: expected. Re-run render after the backend reset at the start of the next render. diff --git a/docs/docs/vue-sfc.md b/docs/docs/vue-sfc.md new file mode 100644 index 0000000..65b1557 --- /dev/null +++ b/docs/docs/vue-sfc.md @@ -0,0 +1,79 @@ +--- +title: Vue SFC +sidebar_position: 3 +--- + +FrameScript.vue follows the shape of [`ubugeeei/style-guide.vue`](https://github.com/ubugeeei/style-guide.vue): use SFC, TypeScript, explicit imports, ` +``` + +- `` creates the project root. +- `` groups clips. +- `` registers a timeline segment. Pass `duration` when the clip has no media that can report duration. +- `