Publish editor updates

This commit is contained in:
haixin.yang
2026-07-20 10:29:42 +08:00
parent c1e5719e1f
commit 47d5f6315d
25 changed files with 584 additions and 33 deletions
+43
View File
@@ -0,0 +1,43 @@
# Timeline Studio — KI-Videoeditor im Browser
[English](README.md) | [中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | **Deutsch** | [Português](README.pt-BR.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Русский](README.ru.md)
Timeline Studio ist ein lokaler KI-Videoeditor für den Browser. Er verbindet eine mehrspurige Timeline im CapCut-Stil mit KI-Sprachausgabe, automatischen Untertiteln, Bildanalyse, sprechenden Avataren und deterministischem Offline-Export.
[Editor öffnen](https://video-editor.ai-creator.top/) · [Demo ansehen](https://youtu.be/mUXduGpBmwE) · [Hugging Face Space](https://huggingface.co/spaces/haixin/timeline-studio)
![Timeline-Studio-Editor](docs/screenshots/editor-timeline.png)
## Hauptfunktionen
- Mehrsprachige Sprachausgabe mit Piper/VITS ONNX und Kokoro 82M.
- Automatische Untertitel mit Whisper small q8 ONNX.
- Intelligenter Bildausschnitt mit YOLOS tiny und MODNet.
- Gesangs-/Musiktrennung und Avatare mit JoyVASA und LivePortrait.
- Mehrspurbearbeitung mit Overlays, Masken, Filtern, Animationen und Keyframes.
- MP4/WebM-Export im Browser mit WebCodecs und Audiomischung.
- Installierbare PWA, lokaler Modellcache und `.timeline`-Projektdateien.
## Schnellstart
Benötigt Node.js 20+ und einen modernen Chromium-Browser. WebGPU wird empfohlen.
```bash
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
```
## Prüfung
```bash
npm test
npm run build
npm run check
```
## Lizenz
[MIT](LICENSE)
+43
View File
@@ -0,0 +1,43 @@
# Timeline Studio — Editor de vídeo con IA en el navegador
[English](README.md) | [中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Español** | [Français](README.fr.md) | [Deutsch](README.de.md) | [Português](README.pt-BR.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Русский](README.ru.md)
Timeline Studio es un editor de vídeo con IA, local y ejecutado en el navegador. Combina una línea de tiempo multipista al estilo CapCut con locuciones de IA, subtítulos automáticos, herramientas de visión, avatares parlantes y exportación offline determinista.
[Abrir el editor](https://video-editor.ai-creator.top/) · [Ver la demo](https://youtu.be/mUXduGpBmwE) · [Hugging Face Space](https://huggingface.co/spaces/haixin/timeline-studio)
![Editor Timeline Studio](docs/screenshots/editor-timeline.png)
## Funciones principales
- Locución multilingüe con Piper/VITS ONNX y Kokoro 82M.
- Subtítulos automáticos con Whisper small q8 ONNX.
- Encuadre inteligente con YOLOS tiny y MODNet.
- Separación de voz y música, y creación de avatares con JoyVASA y LivePortrait.
- Edición multipista con superposiciones, máscaras, filtros, animaciones y fotogramas clave.
- Exportación MP4/WebM en el navegador con WebCodecs y mezcla de audio.
- PWA instalable, caché local de modelos y archivos de proyecto `.timeline`.
## Inicio rápido
Requiere Node.js 20+ y un navegador Chromium moderno. Se recomienda WebGPU.
```bash
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
```
## Validación
```bash
npm test
npm run build
npm run check
```
## Licencia
[MIT](LICENSE)
+43
View File
@@ -0,0 +1,43 @@
# Timeline Studio — Éditeur vidéo IA dans le navigateur
[English](README.md) | [中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | **Français** | [Deutsch](README.de.md) | [Português](README.pt-BR.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Русский](README.ru.md)
Timeline Studio est un éditeur vidéo IA local qui fonctionne dans le navigateur. Il associe une timeline multipiste inspirée de CapCut à la synthèse vocale, aux sous-titres automatiques, aux outils de vision, aux avatars parlants et à un export hors ligne déterministe.
[Ouvrir l’éditeur](https://video-editor.ai-creator.top/) · [Voir la démo](https://youtu.be/mUXduGpBmwE) · [Hugging Face Space](https://huggingface.co/spaces/haixin/timeline-studio)
![Éditeur Timeline Studio](docs/screenshots/editor-timeline.png)
## Fonctionnalités principales
- Voix multilingues avec Piper/VITS ONNX et Kokoro 82M.
- Sous-titres automatiques avec Whisper small q8 ONNX.
- Cadrage intelligent avec YOLOS tiny et MODNet.
- Séparation voix/musique et avatars via JoyVASA et LivePortrait.
- Montage multipiste avec incrustations, masques, filtres, animations et images clés.
- Export MP4/WebM dans le navigateur avec WebCodecs et mixage audio.
- PWA installable, cache local des modèles et projets `.timeline`.
## Démarrage rapide
Node.js 20+ et un navigateur Chromium récent sont requis. WebGPU est recommandé.
```bash
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
```
## Validation
```bash
npm test
npm run build
npm run check
```
## Licence
[MIT](LICENSE)
+43
View File
@@ -0,0 +1,43 @@
# Timeline Studio — ブラウザ AI 動画エディター
[English](README.md) | [中文](README.zh-CN.md) | **日本語** | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Deutsch](README.de.md) | [Português](README.pt-BR.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Русский](README.ru.md)
Timeline Studio はブラウザで動作するローカルファーストの AI 動画エディターです。CapCut のようなマルチトラックタイムラインに、AI 音声、字幕自動生成、画像解析、トーキングアバター、決定論的なオフライン書き出しを統合しています。
[エディターを開く](https://video-editor.ai-creator.top/) · [デモを見る](https://youtu.be/mUXduGpBmwE) · [Hugging Face Space](https://huggingface.co/spaces/haixin/timeline-studio)
![Timeline Studio エディター](docs/screenshots/editor-timeline.png)
## 主な機能
- Piper/VITS ONNX と Kokoro 82M による多言語音声。
- Whisper small q8 ONNX による字幕自動生成。
- YOLOS tiny と MODNet によるスマートフレーミング。
- ボーカル分離、JoyVASA と LivePortrait によるアバター生成。
- オーバーレイ、マスク、フィルター、アニメーション、キーフレーム対応のマルチトラック編集。
- WebCodecs と音声ミックスを使ったブラウザ内 MP4/WebM 書き出し。
- インストール可能な PWA、モデルのローカルキャッシュ、`.timeline` プロジェクト。
## クイックスタート
Node.js 20+ と最新の Chromium ブラウザが必要です。WebGPU を推奨します。
```bash
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
```
## 検証
```bash
npm test
npm run build
npm run check
```
## ライセンス
[MIT](LICENSE)
+43
View File
@@ -0,0 +1,43 @@
# Timeline Studio — 브라우저 AI 동영상 편집기
[English](README.md) | [中文](README.zh-CN.md) | [日本語](README.ja.md) | **한국어** | [Español](README.es.md) | [Français](README.fr.md) | [Deutsch](README.de.md) | [Português](README.pt-BR.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Русский](README.ru.md)
Timeline Studio는 브라우저에서 실행되는 로컬 우선 AI 동영상 편집기입니다. CapCut 스타일의 멀티트랙 타임라인에 AI 음성, 자동 자막, 비전 도구, 말하는 아바타, 결정적 오프라인 내보내기를 결합합니다.
[편집기 열기](https://video-editor.ai-creator.top/) · [데모 보기](https://youtu.be/mUXduGpBmwE) · [Hugging Face Space](https://huggingface.co/spaces/haixin/timeline-studio)
![Timeline Studio 편집기](docs/screenshots/editor-timeline.png)
## 주요 기능
- Piper/VITS ONNX와 Kokoro 82M을 이용한 다국어 음성.
- Whisper small q8 ONNX 자동 자막.
- YOLOS tiny와 MODNet 스마트 프레이밍.
- 보컬 분리 및 JoyVASA/LivePortrait 아바타 생성.
- 오버레이, 마스크, 필터, 애니메이션, 키프레임을 지원하는 멀티트랙 편집.
- WebCodecs와 오디오 믹싱을 이용한 브라우저 MP4/WebM 내보내기.
- 설치형 PWA, 로컬 모델 캐시, `.timeline` 프로젝트.
## 빠른 시작
Node.js 20+와 최신 Chromium 브라우저가 필요합니다. WebGPU를 권장합니다.
```bash
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
```
## 검증
```bash
npm test
npm run build
npm run check
```
## 라이선스
[MIT](LICENSE)
+1 -1
View File
@@ -1,6 +1,6 @@
# Timeline Studio — Browser AI Video Editor # Timeline Studio — Browser AI Video Editor
**English** | [中文](README.zh-CN.md) **English** | [中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Deutsch](README.de.md) | [Português](README.pt-BR.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Русский](README.ru.md)
[![Live Demo](https://img.shields.io/badge/Live_Demo-Timeline_Studio-35ead9?style=flat-square)](https://video-editor.ai-creator.top/) [![Live Demo](https://img.shields.io/badge/Live_Demo-Timeline_Studio-35ead9?style=flat-square)](https://video-editor.ai-creator.top/)
[![GitHub release](https://img.shields.io/github/v/release/MartinDelophy/ai-video-editor?style=flat-square)](https://github.com/MartinDelophy/ai-video-editor/releases) [![GitHub release](https://img.shields.io/github/v/release/MartinDelophy/ai-video-editor?style=flat-square)](https://github.com/MartinDelophy/ai-video-editor/releases)
+43
View File
@@ -0,0 +1,43 @@
# Timeline Studio — Editor de vídeo com IA no navegador
[English](README.md) | [中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Deutsch](README.de.md) | **Português** | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Русский](README.ru.md)
O Timeline Studio é um editor de vídeo com IA, local e executado no navegador. Ele combina uma linha do tempo multifaixa no estilo CapCut com narração por IA, legendas automáticas, visão computacional, avatares falantes e exportação offline determinística.
[Abrir o editor](https://video-editor.ai-creator.top/) · [Ver a demonstração](https://youtu.be/mUXduGpBmwE) · [Hugging Face Space](https://huggingface.co/spaces/haixin/timeline-studio)
![Editor Timeline Studio](docs/screenshots/editor-timeline.png)
## Principais recursos
- Narração multilíngue com Piper/VITS ONNX e Kokoro 82M.
- Legendas automáticas com Whisper small q8 ONNX.
- Enquadramento inteligente com YOLOS tiny e MODNet.
- Separação de voz e música e avatares com JoyVASA e LivePortrait.
- Edição multifaixa com sobreposições, máscaras, filtros, animações e quadros-chave.
- Exportação MP4/WebM no navegador com WebCodecs e mixagem de áudio.
- PWA instalável, cache local de modelos e projetos `.timeline`.
## Início rápido
Requer Node.js 20+ e um navegador Chromium moderno. WebGPU é recomendado.
```bash
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
```
## Validação
```bash
npm test
npm run build
npm run check
```
## Licença
[MIT](LICENSE)
+43
View File
@@ -0,0 +1,43 @@
# Timeline Studio — ИИ-видеоредактор в браузере
[English](README.md) | [中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Deutsch](README.de.md) | [Português](README.pt-BR.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | **Русский**
Timeline Studio — локальный ИИ-видеоредактор, работающий в браузере. Он объединяет многодорожечную временную шкалу в стиле CapCut, ИИ-озвучивание, автоматические субтитры, инструменты компьютерного зрения, говорящие аватары и детерминированный офлайн-экспорт.
[Открыть редактор](https://video-editor.ai-creator.top/) · [Посмотреть демо](https://youtu.be/mUXduGpBmwE) · [Hugging Face Space](https://huggingface.co/spaces/haixin/timeline-studio)
![Редактор Timeline Studio](docs/screenshots/editor-timeline.png)
## Основные возможности
- Многоязычная озвучка с Piper/VITS ONNX и Kokoro 82M.
- Автоматические субтитры на базе Whisper small q8 ONNX.
- Умное кадрирование с YOLOS tiny и MODNet.
- Разделение вокала и музыки, аватары JoyVASA и LivePortrait.
- Многодорожечный монтаж с наложениями, масками, фильтрами, анимацией и ключевыми кадрами.
- Экспорт MP4/WebM в браузере с WebCodecs и сведением звука.
- Устанавливаемое PWA, локальный кэш моделей и проекты `.timeline`.
## Быстрый старт
Требуются Node.js 20+ и современный браузер Chromium. Рекомендуется WebGPU.
```bash
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
```
## Проверка
```bash
npm test
npm run build
npm run check
```
## Лицензия
[MIT](LICENSE)
+43
View File
@@ -0,0 +1,43 @@
# Timeline Studio — โปรแกรมตัดต่อวิดีโอ AI บนเบราว์เซอร์
[English](README.md) | [中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Deutsch](README.de.md) | [Português](README.pt-BR.md) | **ไทย** | [Tiếng Việt](README.vi.md) | [Русский](README.ru.md)
Timeline Studio คือโปรแกรมตัดต่อวิดีโอ AI แบบเน้นการทำงานในเครื่อง ซึ่งทำงานบนเบราว์เซอร์ รวมไทม์ไลน์หลายแทร็กแบบ CapCut เข้ากับเสียงพากย์ AI คำบรรยายอัตโนมัติ เครื่องมือวิเคราะห์ภาพ อวตารพูดได้ และการส่งออกแบบออฟไลน์ที่ให้ผลแน่นอน
[เปิดโปรแกรมตัดต่อ](https://video-editor.ai-creator.top/) · [ชมเดโม](https://youtu.be/mUXduGpBmwE) · [Hugging Face Space](https://huggingface.co/spaces/haixin/timeline-studio)
![โปรแกรมตัดต่อ Timeline Studio](docs/screenshots/editor-timeline.png)
## ความสามารถหลัก
- เสียงพากย์หลายภาษาด้วย Piper/VITS ONNX และ Kokoro 82M
- คำบรรยายอัตโนมัติด้วย Whisper small q8 ONNX
- การจัดเฟรมอัจฉริยะด้วย YOLOS tiny และ MODNet
- แยกเสียงร้องและดนตรี พร้อมอวตาร JoyVASA และ LivePortrait
- การตัดต่อหลายแทร็ก พร้อมโอเวอร์เลย์ มาสก์ ฟิลเตอร์ แอนิเมชัน และคีย์เฟรม
- ส่งออก MP4/WebM ในเบราว์เซอร์ด้วย WebCodecs และการมิกซ์เสียง
- PWA ที่ติดตั้งได้ แคชโมเดลในเครื่อง และโปรเจกต์ `.timeline`
## เริ่มต้นใช้งาน
ต้องใช้ Node.js 20+ และเบราว์เซอร์ Chromium รุ่นใหม่ แนะนำให้ใช้ WebGPU
```bash
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
```
## การตรวจสอบ
```bash
npm test
npm run build
npm run check
```
## สัญญาอนุญาต
[MIT](LICENSE)
+43
View File
@@ -0,0 +1,43 @@
# Timeline Studio — Trình chỉnh sửa video AI trên trình duyệt
[English](README.md) | [中文](README.zh-CN.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Deutsch](README.de.md) | [Português](README.pt-BR.md) | [ไทย](README.th.md) | **Tiếng Việt** | [Русский](README.ru.md)
Timeline Studio là trình chỉnh sửa video AI ưu tiên xử lý cục bộ và chạy trong trình duyệt. Ứng dụng kết hợp dòng thời gian nhiều rãnh kiểu CapCut với lồng tiếng AI, phụ đề tự động, công cụ thị giác, avatar biết nói và quy trình xuất ngoại tuyến xác định.
[Mở trình chỉnh sửa](https://video-editor.ai-creator.top/) · [Xem bản demo](https://youtu.be/mUXduGpBmwE) · [Hugging Face Space](https://huggingface.co/spaces/haixin/timeline-studio)
![Trình chỉnh sửa Timeline Studio](docs/screenshots/editor-timeline.png)
## Tính năng chính
- Lồng tiếng đa ngôn ngữ với Piper/VITS ONNX và Kokoro 82M.
- Phụ đề tự động bằng Whisper small q8 ONNX.
- Căn khung thông minh với YOLOS tiny và MODNet.
- Tách giọng hát/nhạc và tạo avatar bằng JoyVASA cùng LivePortrait.
- Chỉnh sửa nhiều rãnh với lớp phủ, mặt nạ, bộ lọc, hoạt ảnh và khung hình chính.
- Xuất MP4/WebM trong trình duyệt bằng WebCodecs và trộn âm thanh.
- PWA có thể cài đặt, bộ nhớ đệm mô hình cục bộ và dự án `.timeline`.
## Khởi động nhanh
Yêu cầu Node.js 20+ và trình duyệt Chromium hiện đại. Khuyến nghị WebGPU.
```bash
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
```
## Kiểm tra
```bash
npm test
npm run build
npm run check
```
## Giấy phép
[MIT](LICENSE)
+1 -1
View File
@@ -1,6 +1,6 @@
# Timeline Studio — 浏览器 AI 视频编辑器 # Timeline Studio — 浏览器 AI 视频编辑器
[English](README.md) | **中文** [English](README.md) | **中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Deutsch](README.de.md) | [Português](README.pt-BR.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Русский](README.ru.md)
[![在线体验](https://img.shields.io/badge/在线体验-Timeline_Studio-35ead9?style=flat-square)](https://video-editor.ai-creator.top/) [![在线体验](https://img.shields.io/badge/在线体验-Timeline_Studio-35ead9?style=flat-square)](https://video-editor.ai-creator.top/)
[![GitHub Release](https://img.shields.io/github/v/release/MartinDelophy/ai-video-editor?style=flat-square)](https://github.com/MartinDelophy/ai-video-editor/releases) [![GitHub Release](https://img.shields.io/github/v/release/MartinDelophy/ai-video-editor?style=flat-square)](https://github.com/MartinDelophy/ai-video-editor/releases)
+2 -2
View File
@@ -47,8 +47,8 @@
"@context":"https://schema.org", "@context":"https://schema.org",
"@graph":[ "@graph":[
{"@type":"Organization","@id":"https://video-editor.ai-creator.top/#organization","name":"Timeline Studio","url":"https://video-editor.ai-creator.top/","logo":"https://video-editor.ai-creator.top/icons/timeline-studio-icon-512.png","sameAs":["https://github.com/MartinDelophy/ai-video-editor","https://huggingface.co/spaces/haixin/timeline-studio","https://youtu.be/mUXduGpBmwE"]}, {"@type":"Organization","@id":"https://video-editor.ai-creator.top/#organization","name":"Timeline Studio","url":"https://video-editor.ai-creator.top/","logo":"https://video-editor.ai-creator.top/icons/timeline-studio-icon-512.png","sameAs":["https://github.com/MartinDelophy/ai-video-editor","https://huggingface.co/spaces/haixin/timeline-studio","https://youtu.be/mUXduGpBmwE"]},
{"@type":"WebSite","@id":"https://video-editor.ai-creator.top/#website","url":"https://video-editor.ai-creator.top/","name":"Timeline Studio","description":"Free local-first browser AI video editor and voiceover generator.","publisher":{"@id":"https://video-editor.ai-creator.top/#organization"},"inLanguage":["en","zh-CN","ja","ko","es","fr","de","pt-BR","th","vi"]}, {"@type":"WebSite","@id":"https://video-editor.ai-creator.top/#website","url":"https://video-editor.ai-creator.top/","name":"Timeline Studio","description":"Free local-first browser AI video editor and voiceover generator.","publisher":{"@id":"https://video-editor.ai-creator.top/#organization"},"inLanguage":["en","zh-CN","ja","ko","es","fr","de","pt-BR","th","vi","ru"]},
{"@type":["SoftwareApplication","WebApplication"],"@id":"https://video-editor.ai-creator.top/#app","name":"Timeline Studio","url":"https://video-editor.ai-creator.top/","applicationCategory":"MultimediaApplication","applicationSubCategory":"VideoEditor","browserRequirements":"Requires a modern browser with WebAssembly and MediaRecorder; WebGPU is required for the current talking-avatar renderer.","operatingSystem":"Web browser","description":"A free, open-source, local-first browser AI video editor for multilingual voiceovers, Whisper automatic captions, talking portraits, browser vision, multi-track timeline editing, visual keyframes and MP4 or WebM export.","image":"https://video-editor.ai-creator.top/icons/timeline-studio-icon-512.png","screenshot":"https://raw.githubusercontent.com/MartinDelophy/ai-video-editor/main/docs/screenshots/editor-timeline.png","softwareVersion":"0.3.1","dateModified":"2026-07-16","inLanguage":["en","zh-CN","ja","ko","es","fr","de","pt-BR","th","vi"],"license":"https://opensource.org/license/mit","isAccessibleForFree":true,"featureList":["Multilingual browser AI voiceover generation","Whisper small q8 automatic captions","JoyVASA and LivePortrait talking portraits","YOLOS tiny subject detection and MODNet portrait matting","Multi-track timeline editing","Transform keyframes and shape masks","Browser-side MP4 and WebM export","Installable progressive web app"],"offers":{"@type":"Offer","price":"0","priceCurrency":"USD","availability":"https://schema.org/InStock"},"publisher":{"@id":"https://video-editor.ai-creator.top/#organization"},"softwareHelp":"https://video-editor.ai-creator.top/how-it-works/","privacyPolicy":"https://video-editor.ai-creator.top/privacy/","sameAs":"https://github.com/MartinDelophy/ai-video-editor"} {"@type":["SoftwareApplication","WebApplication"],"@id":"https://video-editor.ai-creator.top/#app","name":"Timeline Studio","url":"https://video-editor.ai-creator.top/","applicationCategory":"MultimediaApplication","applicationSubCategory":"VideoEditor","browserRequirements":"Requires a modern browser with WebAssembly and MediaRecorder; WebGPU is required for the current talking-avatar renderer.","operatingSystem":"Web browser","description":"A free, open-source, local-first browser AI video editor for multilingual voiceovers, Whisper automatic captions, talking portraits, browser vision, multi-track timeline editing, visual keyframes and MP4 or WebM export.","image":"https://video-editor.ai-creator.top/icons/timeline-studio-icon-512.png","screenshot":"https://raw.githubusercontent.com/MartinDelophy/ai-video-editor/main/docs/screenshots/editor-timeline.png","softwareVersion":"0.3.1","dateModified":"2026-07-16","inLanguage":["en","zh-CN","ja","ko","es","fr","de","pt-BR","th","vi","ru"],"license":"https://opensource.org/license/mit","isAccessibleForFree":true,"featureList":["Multilingual browser AI voiceover generation","Whisper small q8 automatic captions","JoyVASA and LivePortrait talking portraits","YOLOS tiny subject detection and MODNet portrait matting","Multi-track timeline editing","Transform keyframes and shape masks","Browser-side MP4 and WebM export","Installable progressive web app"],"offers":{"@type":"Offer","price":"0","priceCurrency":"USD","availability":"https://schema.org/InStock"},"publisher":{"@id":"https://video-editor.ai-creator.top/#organization"},"softwareHelp":"https://video-editor.ai-creator.top/how-it-works/","privacyPolicy":"https://video-editor.ai-creator.top/privacy/","sameAs":"https://github.com/MartinDelophy/ai-video-editor"}
] ]
}</script> }</script>
</head> </head>
+3 -2
View File
@@ -507,10 +507,11 @@ export function App() {
}); });
const { handleCutTrack } = createTimelineCutActions({ const { handleCutTrack } = createTimelineCutActions({
captionSegments, commitCaptionSegments, commitStickerSegments, commitVisualSegments, audioSegments, captionSegments, commitCaptionSegments, commitStickerSegments, commitVisualSegments,
currentStickerSegmentIndex, currentTime, focusedSegmentIndex, currentStickerSegmentIndex, currentTime, focusedSegmentIndex,
getCurrentVisualAssetSnapshot, imageDuration, imageSrc, notify, getCurrentVisualAssetSnapshot, imageDuration, imageSrc, notify,
selectedSegmentId, selectedSegmentIndex, selectedStickerSegmentId, selectedAudioSegmentId, selectedSegmentId, selectedSegmentIndex, selectedStickerSegmentId,
setAudioSegments, setCaptionSegments, setSelectedAudioSegmentId,
selectedTrack, stickerSegments, trackLocks, visualSegments, selectedTrack, stickerSegments, trackLocks, visualSegments,
}); });
+14 -13
View File
@@ -21,6 +21,7 @@ import { getStickerBaseSize } from "../lib/stickerGeometry.js";
import { CaptionOverlay } from "./CaptionOverlay.jsx"; import { CaptionOverlay } from "./CaptionOverlay.jsx";
import { IconButton } from "./ui.jsx"; import { IconButton } from "./ui.jsx";
import { getVisualOverlayPixelBox, snapVisualOverlayTransform } from "../lib/visualOverlayTimeline.js"; import { getVisualOverlayPixelBox, snapVisualOverlayTransform } from "../lib/visualOverlayTimeline.js";
import { getAnchoredResize } from "../lib/anchoredResize.js";
export function PreviewStage({ export function PreviewStage({
t, t,
@@ -216,7 +217,6 @@ export function PreviewStage({
const initial = { ...visualTransform }; const initial = { ...visualTransform };
const centerX = rect.left + rect.width * (0.5 + initial.x / 100); const centerX = rect.left + rect.width * (0.5 + initial.x / 100);
const centerY = rect.top + rect.height * (0.5 + initial.y / 100); const centerY = rect.top + rect.height * (0.5 + initial.y / 100);
const startDistance = Math.max(1, Math.hypot(startX - centerX, startY - centerY));
const startAngle = Math.atan2(startY - centerY, startX - centerX) * 180 / Math.PI; const startAngle = Math.atan2(startY - centerY, startX - centerX) * 180 / Math.PI;
const round = (value) => Math.round(value * 100) / 100; const round = (value) => Math.round(value * 100) / 100;
const move = (moveEvent) => { const move = (moveEvent) => {
@@ -225,14 +225,14 @@ export function PreviewStage({
x: round(initial.x + (moveEvent.clientX - startX) / Math.max(1, rect.width) * 100), x: round(initial.x + (moveEvent.clientX - startX) / Math.max(1, rect.width) * 100),
y: round(initial.y + (moveEvent.clientY - startY) / Math.max(1, rect.height) * 100), y: round(initial.y + (moveEvent.clientY - startY) / Math.max(1, rect.height) * 100),
}); });
if (mode === "scale") { if (mode.startsWith("scale-")) {
const candidate = { const handle = mode.slice(6);
...initial, let candidate = getAnchoredResize({ handle, pointer: { x: moveEvent.clientX, y: moveEvent.clientY }, frame: rect, box: { width: rect.width, height: rect.height }, transform: initial });
scale: round(Math.max(0.1, Math.min(4, initial.scale * Math.hypot(moveEvent.clientX - centerX, moveEvent.clientY - centerY) / startDistance))), candidate = { ...candidate, scale: Math.max(0.1, Math.min(4, candidate.scale)) };
};
const snapped = snapVisualScaleToFrameEdges(candidate, { width: rect.width, height: rect.height }); const snapped = snapVisualScaleToFrameEdges(candidate, { width: rect.width, height: rect.height });
candidate = getAnchoredResize({ handle, pointer: { x: moveEvent.clientX, y: moveEvent.clientY }, frame: rect, box: { width: rect.width, height: rect.height }, transform: initial, scale: snapped.transform.scale });
setOverlaySnapGuides(snapped.guides); setOverlaySnapGuides(snapped.guides);
onUpdateVisualTransform({ ...snapped.transform, scale: round(snapped.transform.scale) }); onUpdateVisualTransform({ ...candidate, x: round(candidate.x), y: round(candidate.y), scale: round(candidate.scale) });
} }
if (mode === "rotate") { if (mode === "rotate") {
const angle = Math.atan2(moveEvent.clientY - centerY, moveEvent.clientX - centerX) * 180 / Math.PI; const angle = Math.atan2(moveEvent.clientY - centerY, moveEvent.clientX - centerX) * 180 / Math.PI;
@@ -254,7 +254,6 @@ export function PreviewStage({
const startX = event.clientX; const startY = event.clientY; const startX = event.clientX; const startY = event.clientY;
const centerX = rect.left + rect.width * (0.5 + initial.x / 100); const centerX = rect.left + rect.width * (0.5 + initial.x / 100);
const centerY = rect.top + rect.height * (0.5 + initial.y / 100); const centerY = rect.top + rect.height * (0.5 + initial.y / 100);
const startDistance = Math.max(1, Math.hypot(startX - centerX, startY - centerY));
const startAngle = Math.atan2(startY - centerY, startX - centerX) * 180 / Math.PI; const startAngle = Math.atan2(startY - centerY, startX - centerX) * 180 / Math.PI;
const round = (value) => Math.round(value * 100) / 100; const round = (value) => Math.round(value * 100) / 100;
const move = (moveEvent) => { const move = (moveEvent) => {
@@ -265,10 +264,12 @@ export function PreviewStage({
onUpdateVisualOverlay(snapped.transform); onUpdateVisualOverlay(snapped.transform);
} }
if (mode.startsWith("scale")) { if (mode.startsWith("scale")) {
const horizontal = Math.abs(moveEvent.clientX - centerX) / Math.max(1, Math.abs(startX - centerX)); const handle = mode.slice(6);
const vertical = Math.abs(moveEvent.clientY - centerY) / Math.max(1, Math.abs(startY - centerY)); const box = getVisualOverlayPixelBox(overlay, activePreviewFrameSize);
const ratio = mode === "scale-e" || mode === "scale-w" ? horizontal : mode === "scale-n" || mode === "scale-s" ? vertical : Math.hypot(moveEvent.clientX - centerX, moveEvent.clientY - centerY) / startDistance; let candidate = getAnchoredResize({ handle, pointer: { x: moveEvent.clientX, y: moveEvent.clientY }, frame: rect, box, transform: initial });
onUpdateVisualOverlay({ ...initial, scale: round(Math.max(0.08, Math.min(4, initial.scale * ratio))) }); const clampedScale = Math.max(0.08, Math.min(4, candidate.scale));
candidate = getAnchoredResize({ handle, pointer: { x: moveEvent.clientX, y: moveEvent.clientY }, frame: rect, box, transform: initial, scale: clampedScale });
onUpdateVisualOverlay({ ...candidate, x: round(candidate.x), y: round(candidate.y), scale: round(candidate.scale) });
} }
if (mode === "rotate") { if (mode === "rotate") {
const angle = Math.atan2(moveEvent.clientY - centerY, moveEvent.clientX - centerX) * 180 / Math.PI; const angle = Math.atan2(moveEvent.clientY - centerY, moveEvent.clientX - centerX) * 180 / Math.PI;
@@ -452,7 +453,7 @@ export function PreviewStage({
<div className="visual-transform-box" style={visualTransformStyle} onPointerDown={(event) => startVisualTransform(event, "move")}> <div className="visual-transform-box" style={visualTransformStyle} onPointerDown={(event) => startVisualTransform(event, "move")}>
<span className="visual-transform-label">{t("visualBasic", "画面")}</span> <span className="visual-transform-label">{t("visualBasic", "画面")}</span>
<button className="visual-transform-rotate" type="button" aria-label={t("visualRotation", "旋转")} onPointerDown={(event) => startVisualTransform(event, "rotate")} /> <button className="visual-transform-rotate" type="button" aria-label={t("visualRotation", "旋转")} onPointerDown={(event) => startVisualTransform(event, "rotate")} />
{['nw', 'ne', 'sw', 'se'].map((corner) => <button key={corner} className={`visual-transform-handle is-${corner}`} type="button" aria-label={t("visualScale", "缩放")} onPointerDown={(event) => startVisualTransform(event, "scale")} />)} {['nw', 'ne', 'sw', 'se'].map((corner) => <button key={corner} className={`visual-transform-handle is-${corner}`} type="button" aria-label={t("visualScale", "缩放")} onPointerDown={(event) => startVisualTransform(event, `scale-${corner}`)} />)}
</div> </div>
) : null} ) : null}
{previewTransition?.next?.src && trackVisibility.image ? ( {previewTransition?.next?.src && trackVisibility.image ? (
+1 -1
View File
@@ -9,7 +9,7 @@ export function useMediaSync(d) {
d.audioSegments.forEach((s) => { d.audioSegments.forEach((s) => {
const a = d.audioSegmentRefs.current.get(s.id); if (!a) return; const a = d.audioSegmentRefs.current.get(s.id); if (!a) return;
const active = isTimelineTimeInsideTrack(d.currentTime, s.start, s.duration); const active = isTimelineTimeInsideTrack(d.currentTime, s.start, s.duration);
if (active) { const expected = getTimelineTrackLocalTime(d.currentTime, s.start, s.duration); if (Math.abs(a.currentTime - expected) > 0.2) a.currentTime = expected; if (a.paused) a.play().catch(() => {}); } if (active) { const expected = Math.max(0, Number(s.sourceStart) || 0) + getTimelineTrackLocalTime(d.currentTime, s.start, s.duration); if (Math.abs(a.currentTime - expected) > 0.2) a.currentTime = expected; if (a.paused) a.play().catch(() => {}); }
else if (!a.paused) a.pause(); else if (!a.paused) a.pause();
}); });
}, [d.audioSegments, d.currentTime, d.isPlaying, d.trackVisibility.audio]); }, [d.audioSegments, d.currentTime, d.isPlaying, d.trackVisibility.audio]);
+2
View File
@@ -11,6 +11,7 @@ export const APP_LANGUAGES = [
{ id: "pt", name: "Portuguese", nativeName: "Português", hint: "Portuguese" }, { id: "pt", name: "Portuguese", nativeName: "Português", hint: "Portuguese" },
{ id: "th", name: "Thai", nativeName: "ไทย", hint: "Thai" }, { id: "th", name: "Thai", nativeName: "ไทย", hint: "Thai" },
{ id: "vi", name: "Vietnamese", nativeName: "Tiếng Việt", hint: "Vietnamese" }, { id: "vi", name: "Vietnamese", nativeName: "Tiếng Việt", hint: "Vietnamese" },
{ id: "ru", name: "Russian", nativeName: "Русский", hint: "Russian" },
]; ];
const EXPORT_RENDER_COPY = { const EXPORT_RENDER_COPY = {
@@ -1714,6 +1715,7 @@ export const LANGUAGE_FALLBACKS = {
pt: "en", pt: "en",
th: "en", th: "en",
vi: "en", vi: "en",
ru: "en",
}; };
export function getStoredLanguage() { export function getStoredLanguage() {
+38
View File
@@ -0,0 +1,38 @@
const HANDLE_DIRECTIONS = {
nw: [-1, -1], n: [0, -1], ne: [1, -1], e: [1, 0],
se: [1, 1], s: [0, 1], sw: [-1, 1], w: [-1, 0],
};
const rotate = (x, y, radians) => ({
x: x * Math.cos(radians) - y * Math.sin(radians),
y: x * Math.sin(radians) + y * Math.cos(radians),
});
export function getAnchoredResize({ handle, pointer, frame, box, transform, scale }) {
const direction = HANDLE_DIRECTIONS[handle];
if (!direction) return transform;
const initialScale = Math.max(0.0001, Number(transform.scale) || 1);
const radians = (Number(transform.rotation) || 0) * Math.PI / 180;
const center = {
x: frame.left + frame.width * (0.5 + (Number(transform.x) || 0) / 100),
y: frame.top + frame.height * (0.5 + (Number(transform.y) || 0) / 100),
};
const diagonal = rotate(direction[0] * box.width, direction[1] * box.height, radians);
const anchor = {
x: center.x - diagonal.x * initialScale / 2,
y: center.y - diagonal.y * initialScale / 2,
};
const projectedScale = ((pointer.x - anchor.x) * diagonal.x + (pointer.y - anchor.y) * diagonal.y)
/ Math.max(1, diagonal.x ** 2 + diagonal.y ** 2);
const nextScale = Number.isFinite(scale) ? scale : projectedScale;
const nextCenter = {
x: anchor.x + diagonal.x * nextScale / 2,
y: anchor.y + diagonal.y * nextScale / 2,
};
return {
...transform,
scale: nextScale,
x: (nextCenter.x - frame.left - frame.width / 2) / Math.max(1, frame.width) * 100,
y: (nextCenter.y - frame.top - frame.height / 2) / Math.max(1, frame.height) * 100,
};
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { getAnchoredResize } from "./anchoredResize.js";
describe("getAnchoredResize", () => {
it("keeps the opposite corner fixed while resizing", () => {
const result = getAnchoredResize({
handle: "se", pointer: { x: 300, y: 300 },
frame: { left: 0, top: 0, width: 200, height: 200 },
box: { width: 200, height: 200 }, transform: { x: 0, y: 0, scale: 1, rotation: 0 },
});
expect(result).toMatchObject({ x: 25, y: 25, scale: 1.5 });
});
it("uses the opposite edge as the anchor for an edge handle", () => {
const result = getAnchoredResize({
handle: "e", pointer: { x: 250, y: 100 },
frame: { left: 0, top: 0, width: 200, height: 200 },
box: { width: 100, height: 80 }, transform: { x: 0, y: 0, scale: 1, rotation: 0 },
});
expect(result).toMatchObject({ x: 25, y: 0, scale: 2 });
});
});
+4 -1
View File
@@ -880,6 +880,9 @@ export async function exportBrowserVideo({
volume: segment.volume ?? 1, volume: segment.volume ?? 1,
role: "voice", role: "voice",
start: Math.max(0, segment.start || 0), start: Math.max(0, segment.start || 0),
sourceOffset: Math.max(0, segment.sourceStart || 0),
sourceDuration: Math.max(0, segment.duration || 0),
outputDuration: Math.max(0, segment.duration || 0),
fadeIn: Math.max(0, segment.fadeIn || 0), fadeIn: Math.max(0, segment.fadeIn || 0),
fadeOut: Math.max(0, segment.fadeOut || 0), fadeOut: Math.max(0, segment.fadeOut || 0),
})), })),
@@ -936,7 +939,7 @@ export async function exportBrowserVideo({
}), }),
); );
decodedDuration = Math.max(0, ...decodedInputs.filter((input) => input.role === "voice").map((input) => input.start + input.decoded.duration)); decodedDuration = Math.max(0, ...decodedInputs.filter((input) => input.role === "voice").map((input) => input.start + input.outputDuration));
decodedInputs.forEach((input) => { decodedInputs.forEach((input) => {
const source = audioContext.createBufferSource(); const source = audioContext.createBufferSource();
+1 -1
View File
@@ -92,7 +92,7 @@ export async function mixOfflineAudio({
const inputs = [ const inputs = [
...voiceAudioSegments.filter((item) => item.blob).map((item) => ({ ...voiceAudioSegments.filter((item) => item.blob).map((item) => ({
blob: item.blob, start: Math.max(0, item.start || 0), volume: item.volume ?? 1, blob: item.blob, start: Math.max(0, item.start || 0), volume: item.volume ?? 1,
sourceOffset: 0, sourceDuration: 0, playbackRate: 1, sourceOffset: Math.max(0, item.sourceStart || 0), sourceDuration: Math.max(0, item.duration || 0), playbackRate: 1,
fadeIn: Math.max(0, item.fadeIn || 0), fadeOut: Math.max(0, item.fadeOut || 0), fadeIn: Math.max(0, item.fadeIn || 0), fadeOut: Math.max(0, item.fadeOut || 0),
})), })),
...(sourceAudioBlob && sourceAudioSegments.length ? sourceAudioSegments.map((item) => ({ ...(sourceAudioBlob && sourceAudioSegments.length ? sourceAudioSegments.map((item) => ({
+2 -2
View File
@@ -21,7 +21,7 @@ export function createPlaybackControls(deps) {
deps.currentTimeRef.current = clamped; deps.setCurrentTime(clamped); deps.currentTimeRef.current = clamped; deps.setCurrentTime(clamped);
deps.audioSegments.forEach((segment) => { deps.audioSegments.forEach((segment) => {
const audio = deps.audioSegmentRefs.current.get(segment.id); const audio = deps.audioSegmentRefs.current.get(segment.id);
if (audio) audio.currentTime = getTimelineTrackLocalTime(clamped, segment.start, segment.duration); if (audio) audio.currentTime = Math.max(0, Number(segment.sourceStart) || 0) + getTimelineTrackLocalTime(clamped, segment.start, segment.duration);
}); });
if (deps.sourceAudioRef.current) deps.sourceAudioRef.current.currentTime = getSourceState(clamped).sourceTime; if (deps.sourceAudioRef.current) deps.sourceAudioRef.current.currentTime = getSourceState(clamped).sourceTime;
if (deps.musicRef.current) deps.musicRef.current.currentTime = getTimelineTrackLocalTime(clamped, deps.musicStart, deps.musicDuration); if (deps.musicRef.current) deps.musicRef.current.currentTime = getTimelineTrackLocalTime(clamped, deps.musicStart, deps.musicDuration);
@@ -55,7 +55,7 @@ export function createPlaybackControls(deps) {
const playIf = (media, ready) => ready ? media?.play().catch(() => {}) : media?.pause(); const playIf = (media, ready) => ready ? media?.play().catch(() => {}) : media?.pause();
voices.forEach(({ segment, audio }) => { voices.forEach(({ segment, audio }) => {
const active = isTimelineTimeInsideTrack(timelineTime, segment.start, segment.duration); const active = isTimelineTimeInsideTrack(timelineTime, segment.start, segment.duration);
audio.currentTime = getTimelineTrackLocalTime(timelineTime, segment.start, segment.duration); audio.currentTime = Math.max(0, Number(segment.sourceStart) || 0) + getTimelineTrackLocalTime(timelineTime, segment.start, segment.duration);
audio.volume = getAudioSegmentPreviewVolume(segment, timelineTime); audio.playbackRate = 1; playIf(audio, active); audio.volume = getAudioSegmentPreviewVolume(segment, timelineTime); audio.playbackRate = 1; playIf(audio, active);
}); });
if (source && deps.sourceAudioUrl) { if (source && deps.sourceAudioUrl) {
+47
View File
@@ -37,9 +37,56 @@ export function createTimelineCutActions(d) {
{ ...source, id: makeId("caption"), text: source.text.slice(splitAt), weight: Math.max(0.7, (source.weight ?? 1) / 2), ...(splitTime ? { start: splitTime } : {}) }); { ...source, id: makeId("caption"), text: source.text.slice(splitAt), weight: Math.max(0.7, (source.weight ?? 1) / 2), ...(splitTime ? { start: splitTime } : {}) });
d.commitCaptionSegments(next, "已把当前字幕片段拆成两段", index + 1); d.commitCaptionSegments(next, "已把当前字幕片段拆成两段", index + 1);
}; };
const handleCutAudioSegment = () => {
if (d.trackLocks.audio) return void d.notify("配音轨已锁定,无法剪切");
const index = d.audioSegments.findIndex((segment) => segment.id === d.selectedAudioSegmentId);
const source = d.audioSegments[index];
if (!source) return void d.notify("请先选择一个配音片段");
const time = Math.max(source.start, Math.min(source.start + source.duration, d.currentTime));
if (time <= source.start + 0.05 || time >= source.start + source.duration - 0.05) {
return void d.notify("请把播放头放在配音片段中间再剪切");
}
const firstDuration = time - source.start;
const secondDuration = source.duration - firstDuration;
const peakSplit = Math.max(1, Math.min((source.peaks?.length || 1) - 1, Math.round((firstDuration / source.duration) * (source.peaks?.length || 0))));
const firstId = makeId("voice");
const secondId = makeId("voice");
const firstUrl = source.blob ? URL.createObjectURL(source.blob) : source.url;
const secondUrl = source.blob ? URL.createObjectURL(source.blob) : source.url;
const first = {
...source,
id: firstId,
url: firstUrl,
duration: firstDuration,
peaks: source.peaks?.slice(0, peakSplit) || [],
fadeOut: 0,
};
const second = {
...source,
id: secondId,
url: secondUrl,
start: time,
duration: secondDuration,
sourceStart: Math.max(0, Number(source.sourceStart) || 0) + firstDuration,
peaks: source.peaks?.slice(peakSplit) || [],
fadeIn: 0,
};
d.setAudioSegments((segments) => {
const next = [...segments];
next.splice(index, 1, first, second);
return next;
});
if (source.blob && source.url) URL.revokeObjectURL(source.url);
d.setCaptionSegments((captions) => captions.map((caption) => caption.audioSegmentId === source.id
? { ...caption, audioSegmentId: firstId, end: Math.min(caption.end, time) }
: caption));
d.setSelectedAudioSegmentId(secondId);
d.notify("已在播放头位置切开配音片段");
};
const handleCutTrack = () => { const handleCutTrack = () => {
if (d.selectedTrack === "image") return void handleCutVisualSegment(); if (d.selectedTrack === "image") return void handleCutVisualSegment();
if (d.selectedTrack === "caption") return void handleCutCaption(); if (d.selectedTrack === "caption") return void handleCutCaption();
if (d.selectedTrack === "audio") return void handleCutAudioSegment();
if (d.selectedTrack === "sticker") { if (d.selectedTrack === "sticker") {
if (d.trackLocks.sticker) return void d.notify("贴纸轨已锁定,无法剪切"); if (d.trackLocks.sticker) return void d.notify("贴纸轨已锁定,无法剪切");
const selected = d.selectedStickerSegmentId && d.stickerSegments.findIndex((segment) => segment.id === d.selectedStickerSegmentId); const selected = d.selectedStickerSegmentId && d.stickerSegments.findIndex((segment) => segment.id === d.selectedStickerSegmentId);
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { createTimelineCutActions } from "./timelineCutActions.js";
describe("timeline cut actions", () => {
it("cuts a selected voiceover at the playhead and offsets the second source", () => {
const source = {
id: "voice-original",
blob: new Blob(["voice"]),
url: "blob:voice-original",
start: 2,
duration: 4,
sourceStart: 1,
peaks: [0.1, 0.2, 0.3, 0.4],
fadeIn: 0.2,
fadeOut: 0.3,
};
let audioSegments = [source];
let captions = [{ id: "caption", audioSegmentId: source.id, start: 2, end: 6 }];
let selectedId = source.id;
const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValueOnce("blob:first").mockReturnValueOnce("blob:second");
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const actions = createTimelineCutActions({
audioSegments,
currentTime: 3.5,
notify: vi.fn(),
selectedAudioSegmentId: source.id,
selectedTrack: "audio",
setAudioSegments: (updater) => { audioSegments = updater(audioSegments); },
setCaptionSegments: (updater) => { captions = updater(captions); },
setSelectedAudioSegmentId: (id) => { selectedId = id; },
trackLocks: {},
});
actions.handleCutTrack();
expect(audioSegments).toHaveLength(2);
expect(audioSegments[0]).toMatchObject({ start: 2, duration: 1.5, sourceStart: 1, url: "blob:first", fadeOut: 0 });
expect(audioSegments[1]).toMatchObject({ start: 3.5, duration: 2.5, sourceStart: 2.5, url: "blob:second", fadeIn: 0 });
expect(audioSegments[0].peaks).toEqual([0.1, 0.2]);
expect(audioSegments[1].peaks).toEqual([0.3, 0.4]);
expect(captions[0]).toMatchObject({ audioSegmentId: audioSegments[0].id, end: 3.5 });
expect(selectedId).toBe(audioSegments[1].id);
expect(createObjectURL).toHaveBeenCalledTimes(2);
expect(revokeObjectURL).toHaveBeenCalledWith(source.url);
});
});
+10 -8
View File
@@ -37,6 +37,14 @@ export function getVisualOverlayPixelBox(overlay, frameSize) {
export function createVisualOverlaySegment(asset, start = 0, options = {}) { export function createVisualOverlaySegment(asset, start = 0, options = {}) {
const duration = Math.max(0.1, Number(options.duration ?? asset?.duration) || DEFAULT_OVERLAY_SECONDS); const duration = Math.max(0.1, Number(options.duration ?? asset?.duration) || DEFAULT_OVERLAY_SECONDS);
const baseTransform = {
x: 27,
y: -24,
scale: 0.34,
rotation: 0,
opacity: 1,
...(options.baseTransform || {}),
};
return { return {
id: options.id || `overlay-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: options.id || `overlay-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
assetId: asset?.id || "", assetId: asset?.id || "",
@@ -54,14 +62,8 @@ export function createVisualOverlaySegment(asset, start = 0, options = {}) {
duration, duration,
muted: options.muted === true, muted: options.muted === true,
layer: Math.max(1, Number(options.layer) || 1), layer: Math.max(1, Number(options.layer) || 1),
keyframes: normalizeVisualKeyframes(options.keyframes?.length ? options.keyframes : [{ baseTransform,
time: 0, keyframes: normalizeVisualKeyframes(options.keyframes || []),
x: 27,
y: -24,
scale: 0.34,
rotation: 0,
opacity: 1,
}]),
}; };
} }
+2 -1
View File
@@ -13,7 +13,8 @@ describe("visual overlay timeline", () => {
it("creates a picture-in-picture clip at the requested time", () => { it("creates a picture-in-picture clip at the requested time", () => {
const clip = createVisualOverlaySegment({ id: "asset", name: "camera", type: "video", src: "blob:test", duration: 3, trackFrames: ["frame-a", "frame-b"] }, 4, { id: "pip" }); const clip = createVisualOverlaySegment({ id: "asset", name: "camera", type: "video", src: "blob:test", duration: 3, trackFrames: ["frame-a", "frame-b"] }, 4, { id: "pip" });
expect(clip).toMatchObject({ id: "pip", start: 4, duration: 3, type: "video", layer: 1, muted: false, trackFrames: ["frame-a", "frame-b"] }); expect(clip).toMatchObject({ id: "pip", start: 4, duration: 3, type: "video", layer: 1, muted: false, trackFrames: ["frame-a", "frame-b"] });
expect(clip.keyframes[0]).toMatchObject({ scale: 0.34, x: 27, y: -24 }); expect(clip.baseTransform).toMatchObject({ scale: 0.34, x: 27, y: -24 });
expect(clip.keyframes).toEqual([]);
}); });
it("preserves a square overlay box inside portrait and landscape frames", () => { it("preserves a square overlay box inside portrait and landscape frames", () => {