Publish editor updates
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 主な機能
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- 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,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)
|
||||||
|
|
||||||
[](https://video-editor.ai-creator.top/)
|
[](https://video-editor.ai-creator.top/)
|
||||||
[](https://github.com/MartinDelophy/ai-video-editor/releases)
|
[](https://github.com/MartinDelophy/ai-video-editor/releases)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Основные возможности
|
||||||
|
|
||||||
|
- Многоязычная озвучка с 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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## ความสามารถหลัก
|
||||||
|
|
||||||
|
- เสียงพากย์หลายภาษาด้วย 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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
@@ -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://video-editor.ai-creator.top/)
|
[](https://video-editor.ai-creator.top/)
|
||||||
[](https://github.com/MartinDelophy/ai-video-editor/releases)
|
[](https://github.com/MartinDelophy/ai-video-editor/releases)
|
||||||
|
|||||||
+2
-2
@@ -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
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
}]),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user