From 47d5f6315d2c1c2b5dd429822ac23df04cb6c7c9 Mon Sep 17 00:00:00 2001 From: "haixin.yang" Date: Mon, 20 Jul 2026 10:29:42 +0800 Subject: [PATCH] Publish editor updates --- README.de.md | 43 ++++++++++++++++++++++++ README.es.md | 43 ++++++++++++++++++++++++ README.fr.md | 43 ++++++++++++++++++++++++ README.ja.md | 43 ++++++++++++++++++++++++ README.ko.md | 43 ++++++++++++++++++++++++ README.md | 2 +- README.pt-BR.md | 43 ++++++++++++++++++++++++ README.ru.md | 43 ++++++++++++++++++++++++ README.th.md | 43 ++++++++++++++++++++++++ README.vi.md | 43 ++++++++++++++++++++++++ README.zh-CN.md | 2 +- index.html | 4 +-- src/App.jsx | 5 +-- src/components/PreviewStage.jsx | 27 +++++++-------- src/hooks/useMediaSync.js | 2 +- src/i18n.js | 2 ++ src/lib/anchoredResize.js | 38 ++++++++++++++++++++++ src/lib/anchoredResize.test.js | 22 +++++++++++++ src/lib/media.js | 5 ++- src/lib/offlineVideoExport.js | 2 +- src/lib/playbackControls.js | 4 +-- src/lib/timelineCutActions.js | 47 +++++++++++++++++++++++++++ src/lib/timelineCutActions.test.js | 47 +++++++++++++++++++++++++++ src/lib/visualOverlayTimeline.js | 18 +++++----- src/lib/visualOverlayTimeline.test.js | 3 +- 25 files changed, 584 insertions(+), 33 deletions(-) create mode 100644 README.de.md create mode 100644 README.es.md create mode 100644 README.fr.md create mode 100644 README.ja.md create mode 100644 README.ko.md create mode 100644 README.pt-BR.md create mode 100644 README.ru.md create mode 100644 README.th.md create mode 100644 README.vi.md create mode 100644 src/lib/anchoredResize.js create mode 100644 src/lib/anchoredResize.test.js create mode 100644 src/lib/timelineCutActions.test.js diff --git a/README.de.md b/README.de.md new file mode 100644 index 0000000..0487a9f --- /dev/null +++ b/README.de.md @@ -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) + diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..6c592bc --- /dev/null +++ b/README.es.md @@ -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) + diff --git a/README.fr.md b/README.fr.md new file mode 100644 index 0000000..90e64ca --- /dev/null +++ b/README.fr.md @@ -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) + diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000..a82c23c --- /dev/null +++ b/README.ja.md @@ -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) + diff --git a/README.ko.md b/README.ko.md new file mode 100644 index 0000000..71dd41d --- /dev/null +++ b/README.ko.md @@ -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) + diff --git a/README.md b/README.md index 2274332..6c520a7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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/) [![GitHub release](https://img.shields.io/github/v/release/MartinDelophy/ai-video-editor?style=flat-square)](https://github.com/MartinDelophy/ai-video-editor/releases) diff --git a/README.pt-BR.md b/README.pt-BR.md new file mode 100644 index 0000000..2b0fdbb --- /dev/null +++ b/README.pt-BR.md @@ -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) + diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..157e335 --- /dev/null +++ b/README.ru.md @@ -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) + diff --git a/README.th.md b/README.th.md new file mode 100644 index 0000000..86f54ca --- /dev/null +++ b/README.th.md @@ -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) + diff --git a/README.vi.md b/README.vi.md new file mode 100644 index 0000000..050695f --- /dev/null +++ b/README.vi.md @@ -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) + diff --git a/README.zh-CN.md b/README.zh-CN.md index 31f5195..ecd1bf6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # 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/) [![GitHub Release](https://img.shields.io/github/v/release/MartinDelophy/ai-video-editor?style=flat-square)](https://github.com/MartinDelophy/ai-video-editor/releases) diff --git a/index.html b/index.html index 60b31fa..57337d3 100644 --- a/index.html +++ b/index.html @@ -47,8 +47,8 @@ "@context":"https://schema.org", "@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":"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":["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":"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","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"} ] } diff --git a/src/App.jsx b/src/App.jsx index 9795b4b..ecd156b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -507,10 +507,11 @@ export function App() { }); const { handleCutTrack } = createTimelineCutActions({ - captionSegments, commitCaptionSegments, commitStickerSegments, commitVisualSegments, + audioSegments, captionSegments, commitCaptionSegments, commitStickerSegments, commitVisualSegments, currentStickerSegmentIndex, currentTime, focusedSegmentIndex, getCurrentVisualAssetSnapshot, imageDuration, imageSrc, notify, - selectedSegmentId, selectedSegmentIndex, selectedStickerSegmentId, + selectedAudioSegmentId, selectedSegmentId, selectedSegmentIndex, selectedStickerSegmentId, + setAudioSegments, setCaptionSegments, setSelectedAudioSegmentId, selectedTrack, stickerSegments, trackLocks, visualSegments, }); diff --git a/src/components/PreviewStage.jsx b/src/components/PreviewStage.jsx index 80f23b5..e7620a6 100644 --- a/src/components/PreviewStage.jsx +++ b/src/components/PreviewStage.jsx @@ -21,6 +21,7 @@ import { getStickerBaseSize } from "../lib/stickerGeometry.js"; import { CaptionOverlay } from "./CaptionOverlay.jsx"; import { IconButton } from "./ui.jsx"; import { getVisualOverlayPixelBox, snapVisualOverlayTransform } from "../lib/visualOverlayTimeline.js"; +import { getAnchoredResize } from "../lib/anchoredResize.js"; export function PreviewStage({ t, @@ -216,7 +217,6 @@ export function PreviewStage({ const initial = { ...visualTransform }; const centerX = rect.left + rect.width * (0.5 + initial.x / 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 round = (value) => Math.round(value * 100) / 100; const move = (moveEvent) => { @@ -225,14 +225,14 @@ export function PreviewStage({ 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), }); - if (mode === "scale") { - const candidate = { - ...initial, - scale: round(Math.max(0.1, Math.min(4, initial.scale * Math.hypot(moveEvent.clientX - centerX, moveEvent.clientY - centerY) / startDistance))), - }; + if (mode.startsWith("scale-")) { + const handle = mode.slice(6); + let candidate = getAnchoredResize({ handle, pointer: { x: moveEvent.clientX, y: moveEvent.clientY }, frame: rect, box: { width: rect.width, height: rect.height }, transform: initial }); + candidate = { ...candidate, scale: Math.max(0.1, Math.min(4, candidate.scale)) }; 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); - 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") { 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 centerX = rect.left + rect.width * (0.5 + initial.x / 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 round = (value) => Math.round(value * 100) / 100; const move = (moveEvent) => { @@ -265,10 +264,12 @@ export function PreviewStage({ onUpdateVisualOverlay(snapped.transform); } if (mode.startsWith("scale")) { - const horizontal = Math.abs(moveEvent.clientX - centerX) / Math.max(1, Math.abs(startX - centerX)); - const vertical = Math.abs(moveEvent.clientY - centerY) / Math.max(1, Math.abs(startY - centerY)); - 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; - onUpdateVisualOverlay({ ...initial, scale: round(Math.max(0.08, Math.min(4, initial.scale * ratio))) }); + const handle = mode.slice(6); + const box = getVisualOverlayPixelBox(overlay, activePreviewFrameSize); + let candidate = getAnchoredResize({ handle, pointer: { x: moveEvent.clientX, y: moveEvent.clientY }, frame: rect, box, transform: initial }); + 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") { const angle = Math.atan2(moveEvent.clientY - centerY, moveEvent.clientX - centerX) * 180 / Math.PI; @@ -452,7 +453,7 @@ export function PreviewStage({
startVisualTransform(event, "move")}> {t("visualBasic", "画面")}
) : null} {previewTransition?.next?.src && trackVisibility.image ? ( diff --git a/src/hooks/useMediaSync.js b/src/hooks/useMediaSync.js index c16a001..b45f2c9 100644 --- a/src/hooks/useMediaSync.js +++ b/src/hooks/useMediaSync.js @@ -9,7 +9,7 @@ export function useMediaSync(d) { d.audioSegments.forEach((s) => { const a = d.audioSegmentRefs.current.get(s.id); if (!a) return; 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(); }); }, [d.audioSegments, d.currentTime, d.isPlaying, d.trackVisibility.audio]); diff --git a/src/i18n.js b/src/i18n.js index 1e2c796..4d01c5e 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -11,6 +11,7 @@ export const APP_LANGUAGES = [ { id: "pt", name: "Portuguese", nativeName: "Português", hint: "Portuguese" }, { id: "th", name: "Thai", nativeName: "ไทย", hint: "Thai" }, { id: "vi", name: "Vietnamese", nativeName: "Tiếng Việt", hint: "Vietnamese" }, + { id: "ru", name: "Russian", nativeName: "Русский", hint: "Russian" }, ]; const EXPORT_RENDER_COPY = { @@ -1714,6 +1715,7 @@ export const LANGUAGE_FALLBACKS = { pt: "en", th: "en", vi: "en", + ru: "en", }; export function getStoredLanguage() { diff --git a/src/lib/anchoredResize.js b/src/lib/anchoredResize.js new file mode 100644 index 0000000..349b0ad --- /dev/null +++ b/src/lib/anchoredResize.js @@ -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, + }; +} diff --git a/src/lib/anchoredResize.test.js b/src/lib/anchoredResize.test.js new file mode 100644 index 0000000..ce00567 --- /dev/null +++ b/src/lib/anchoredResize.test.js @@ -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 }); + }); +}); diff --git a/src/lib/media.js b/src/lib/media.js index 5cc2192..8a87da3 100644 --- a/src/lib/media.js +++ b/src/lib/media.js @@ -880,6 +880,9 @@ export async function exportBrowserVideo({ volume: segment.volume ?? 1, role: "voice", 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), 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) => { const source = audioContext.createBufferSource(); diff --git a/src/lib/offlineVideoExport.js b/src/lib/offlineVideoExport.js index 6281554..a4cf4c2 100644 --- a/src/lib/offlineVideoExport.js +++ b/src/lib/offlineVideoExport.js @@ -92,7 +92,7 @@ export async function mixOfflineAudio({ const inputs = [ ...voiceAudioSegments.filter((item) => item.blob).map((item) => ({ 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), })), ...(sourceAudioBlob && sourceAudioSegments.length ? sourceAudioSegments.map((item) => ({ diff --git a/src/lib/playbackControls.js b/src/lib/playbackControls.js index 74129bc..7cd35f9 100644 --- a/src/lib/playbackControls.js +++ b/src/lib/playbackControls.js @@ -21,7 +21,7 @@ export function createPlaybackControls(deps) { deps.currentTimeRef.current = clamped; deps.setCurrentTime(clamped); deps.audioSegments.forEach((segment) => { 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.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(); voices.forEach(({ segment, audio }) => { 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); }); if (source && deps.sourceAudioUrl) { diff --git a/src/lib/timelineCutActions.js b/src/lib/timelineCutActions.js index 8196e98..9ed2326 100644 --- a/src/lib/timelineCutActions.js +++ b/src/lib/timelineCutActions.js @@ -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 } : {}) }); 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 = () => { if (d.selectedTrack === "image") return void handleCutVisualSegment(); if (d.selectedTrack === "caption") return void handleCutCaption(); + if (d.selectedTrack === "audio") return void handleCutAudioSegment(); if (d.selectedTrack === "sticker") { if (d.trackLocks.sticker) return void d.notify("贴纸轨已锁定,无法剪切"); const selected = d.selectedStickerSegmentId && d.stickerSegments.findIndex((segment) => segment.id === d.selectedStickerSegmentId); diff --git a/src/lib/timelineCutActions.test.js b/src/lib/timelineCutActions.test.js new file mode 100644 index 0000000..b280e6d --- /dev/null +++ b/src/lib/timelineCutActions.test.js @@ -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); + }); +}); diff --git a/src/lib/visualOverlayTimeline.js b/src/lib/visualOverlayTimeline.js index 7782b97..64796c9 100644 --- a/src/lib/visualOverlayTimeline.js +++ b/src/lib/visualOverlayTimeline.js @@ -37,6 +37,14 @@ export function getVisualOverlayPixelBox(overlay, frameSize) { export function createVisualOverlaySegment(asset, start = 0, options = {}) { 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 { id: options.id || `overlay-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, assetId: asset?.id || "", @@ -54,14 +62,8 @@ export function createVisualOverlaySegment(asset, start = 0, options = {}) { duration, muted: options.muted === true, layer: Math.max(1, Number(options.layer) || 1), - keyframes: normalizeVisualKeyframes(options.keyframes?.length ? options.keyframes : [{ - time: 0, - x: 27, - y: -24, - scale: 0.34, - rotation: 0, - opacity: 1, - }]), + baseTransform, + keyframes: normalizeVisualKeyframes(options.keyframes || []), }; } diff --git a/src/lib/visualOverlayTimeline.test.js b/src/lib/visualOverlayTimeline.test.js index 9d36299..eefb195 100644 --- a/src/lib/visualOverlayTimeline.test.js +++ b/src/lib/visualOverlayTimeline.test.js @@ -13,7 +13,8 @@ describe("visual overlay timeline", () => { 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" }); 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", () => {