release: publish Twitch VOD Manager 1.0.5
This commit is contained in:
+163
-45
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:;">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' file: blob:;">
|
||||
<title>Twitch VOD Manager</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
<link rel="stylesheet" href="./workspace.css">
|
||||
@@ -36,6 +36,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="cutterDiscardModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="cutterDiscardTitle" onclick="handleCutterDiscardOverlayClick(event)" onkeydown="trapCutterDiscardFocus(event)">
|
||||
<div class="modal cutter-discard-modal">
|
||||
<h2 id="cutterDiscardTitle">Aktuellen Schnitt verwerfen?</h2>
|
||||
<p id="cutterDiscardMessage">Beim Öffnen eines anderen Videos gehen die aktuellen Schnittänderungen verloren.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" id="cutterDiscardCancelBtn" type="button" onclick="resolveCutterDiscard(false)">Aktuelles Video behalten</button>
|
||||
<button class="btn-danger" id="cutterDiscardConfirmBtn" type="button" onclick="resolveCutterDiscard(true)">Verwerfen und öffnen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clip Dialog Modal -->
|
||||
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
|
||||
<div class="modal clip-modal">
|
||||
@@ -436,54 +447,163 @@
|
||||
<!-- Video Cutter Tab -->
|
||||
<div class="tab-content" id="cutterTab">
|
||||
<div class="cutter-container">
|
||||
<div class="settings-card">
|
||||
<h3 id="cutterSelectTitle">Video auswählen</h3>
|
||||
<div class="form-row">
|
||||
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
|
||||
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||
<div class="cutter-source-bar">
|
||||
<div class="cutter-source-copy">
|
||||
<span class="cutter-source-title" id="cutterSelectTitle">Video auswählen</span>
|
||||
<input type="text" id="cutterFilePath" readonly aria-labelledby="cutterSelectTitle" placeholder="Keine Datei ausgewählt…">
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||
</div>
|
||||
|
||||
<div class="video-preview" id="cutterPreview">
|
||||
<div class="placeholder">
|
||||
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<p id="cutterPreviewPlaceholder">Video auswählen um Vorschau zu sehen</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cutter-workspace" id="cutterWorkspace">
|
||||
<aside class="cutter-sidebar">
|
||||
<div class="cutter-sidebar-heading">
|
||||
<div>
|
||||
<span class="cutter-eyebrow">EDITOR</span>
|
||||
<h3 id="cutterEditHeading">Trimmen & schneiden</h3>
|
||||
</div>
|
||||
<button type="button" class="cutter-icon-button" id="cutterNewCutBtn" onclick="addCutterCut()" disabled aria-label="Neuer Schnitt" title="Neuer Schnitt">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<label class="cutter-preview-toggle">
|
||||
<span>
|
||||
<strong id="cutterPreviewModeLabel">Schnittvorschau</strong>
|
||||
<small id="cutterPreviewModeHint">Entfernte Bereiche überspringen</small>
|
||||
</span>
|
||||
<input type="checkbox" id="cutterPreviewMode" checked onchange="setCutterPreviewMode(this.checked)">
|
||||
<span class="cutter-toggle-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
<div class="cutter-trim-card">
|
||||
<div class="cutter-card-title" id="cutterGlobalTrimLabel">Gesamtauswahl</div>
|
||||
<div class="cutter-time-field-row">
|
||||
<label for="startTime" id="cutterStartLabel">Start</label>
|
||||
<input type="text" id="startTime" value="00:00:00" spellcheck="false" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
<div class="cutter-time-field-row">
|
||||
<label for="endTime" id="cutterEndLabel">Ende</label>
|
||||
<input type="text" id="endTime" value="00:00:00" spellcheck="false" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="cutter-cut-section">
|
||||
<div class="cutter-card-title-row">
|
||||
<span class="cutter-card-title" id="cutterCutsLabel">Entfernte Bereiche</span>
|
||||
<span class="cutter-cut-count" id="cutterCutCount">0</span>
|
||||
</div>
|
||||
<div class="cutter-cut-list" id="cutterCutList">
|
||||
<div class="cutter-cut-empty" id="cutterCutEmpty">Noch keine Schnitte</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="cutter-info" id="cutterInfo">
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span>
|
||||
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoResolutionLabel">Auflösung</span>
|
||||
<span class="cutter-info-value" id="infoResolution">----x----</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoFpsLabel">FPS</span>
|
||||
<span class="cutter-info-value" id="infoFps">--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoSelectionLabel">Auswahl</span>
|
||||
<span class="cutter-info-value" id="infoSelection">--:--:--</span>
|
||||
</div>
|
||||
<section class="cutter-preview-panel">
|
||||
<div class="video-preview" id="cutterPreview">
|
||||
<video id="cutterVideo" preload="metadata"></video>
|
||||
<div class="placeholder" id="cutterPreviewEmpty">
|
||||
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<p id="cutterPreviewPlaceholder">Video auswählen, um eine Vorschau zu sehen</p>
|
||||
</div>
|
||||
<div class="cutter-player-loading" id="cutterPlayerLoading" hidden>
|
||||
<span class="cutter-spinner"></span>
|
||||
<span id="cutterLoadingLabel">Video wird vorbereitet…</span>
|
||||
</div>
|
||||
<div class="cutter-player-controls" id="cutterPlayerControls">
|
||||
<button type="button" class="cutter-player-button" id="cutterPlayBtn" onclick="toggleCutterPlayback()" disabled aria-label="Abspielen">
|
||||
<svg class="cutter-play-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"></path></svg>
|
||||
<svg class="cutter-pause-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M7 5h4v14H7zM13 5h4v14h-4z"></path></svg>
|
||||
</button>
|
||||
<button type="button" class="cutter-player-button" id="cutterStopBtn" onclick="stopCutterPlayback()" disabled data-cutter-media-control aria-label="Stopp">
|
||||
<svg class="cutter-filled-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M7 7h10v10H7z"></path></svg>
|
||||
</button>
|
||||
<button type="button" class="cutter-player-button cutter-skip-button" id="cutterRewindBtn" onclick="skipCutterPlayback(-10)" disabled data-cutter-media-control aria-label="10 Sekunden zurück">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5.2 8.2A8 8 0 1 1 4 14.5"></path><path d="M5 4v5h5"></path></svg><span>10</span>
|
||||
</button>
|
||||
<button type="button" class="cutter-player-button cutter-skip-button" id="cutterForwardBtn" onclick="skipCutterPlayback(10)" disabled data-cutter-media-control aria-label="10 Sekunden vor">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M18.8 8.2A8 8 0 1 0 20 14.5"></path><path d="M19 4v5h-5"></path></svg><span>10</span>
|
||||
</button>
|
||||
<div class="cutter-volume-control">
|
||||
<button type="button" class="cutter-player-button" id="cutterMuteBtn" onclick="toggleCutterMute()" disabled aria-label="Ton umschalten">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9H4zm12.5 3a4 4 0 0 0-2-3.46v6.92A4 4 0 0 0 16.5 12z"></path></svg>
|
||||
</button>
|
||||
<input type="range" class="cutter-volume" id="cutterVolume" min="0" max="1" step="0.05" value="1" disabled aria-label="Lautstärke">
|
||||
</div>
|
||||
<span class="cutter-player-time"><span id="cutterCurrentTime">00:00:00</span><span>/</span><span id="cutterTotalTime">00:00:00</span></span>
|
||||
<select id="cutterPlaybackRate" hidden disabled aria-label="Wiedergabegeschwindigkeit">
|
||||
<option value="0.5">0,5×</option>
|
||||
<option value="0.75">0,75×</option>
|
||||
<option value="1" selected>1×</option>
|
||||
<option value="1.25">1,25×</option>
|
||||
<option value="1.5">1,5×</option>
|
||||
<option value="2">2×</option>
|
||||
</select>
|
||||
<div class="cutter-player-settings">
|
||||
<button type="button" class="cutter-player-button" id="cutterSettingsBtn" onclick="toggleCutterSettingsMenu()" disabled aria-label="Einstellungen" aria-expanded="false">
|
||||
<svg class="cutter-filled-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M19.4 13a7.8 7.8 0 0 0 .1-1 7.8 7.8 0 0 0-.1-1l2.1-1.6-2-3.4-2.5 1a8 8 0 0 0-1.7-1L15 3.3h-4L10.6 6a8 8 0 0 0-1.7 1L6.4 6l-2 3.4L6.5 11a7.8 7.8 0 0 0-.1 1 7.8 7.8 0 0 0 .1 1l-2.1 1.6 2 3.4 2.5-1a8 8 0 0 0 1.7 1l.4 2.7h4l.4-2.7a8 8 0 0 0 1.7-1l2.5 1 2-3.4L19.4 13zM13 15.5A3.5 3.5 0 1 1 13 8a3.5 3.5 0 0 1 0 7.5z"></path></svg>
|
||||
</button>
|
||||
<div class="cutter-settings-menu" id="cutterSettingsMenu" hidden>
|
||||
<span id="cutterSpeedLabel">Geschwindigkeit</span>
|
||||
<div class="cutter-speed-options">
|
||||
<button type="button" data-rate="0.5" onclick="setCutterPlaybackRate(0.5)">0,5×</button>
|
||||
<button type="button" data-rate="0.75" onclick="setCutterPlaybackRate(0.75)">0,75×</button>
|
||||
<button type="button" class="active" data-rate="1" onclick="setCutterPlaybackRate(1)">Normal</button>
|
||||
<button type="button" data-rate="1.25" onclick="setCutterPlaybackRate(1.25)">1,25×</button>
|
||||
<button type="button" data-rate="1.5" onclick="setCutterPlaybackRate(1.5)">1,5×</button>
|
||||
<button type="button" data-rate="2" onclick="setCutterPlaybackRate(2)">2×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="cutter-player-button" id="cutterFullscreenBtn" onclick="toggleCutterFullscreen()" disabled aria-label="Vollbild">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-info" id="cutterInfo">
|
||||
<div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span><span class="cutter-info-value" id="infoDuration">--:--:--</span></div>
|
||||
<div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoResolutionLabel">Auflösung</span><span class="cutter-info-value" id="infoResolution">----×----</span></div>
|
||||
<div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoFpsLabel">FPS</span><span class="cutter-info-value" id="infoFps">--</span></div>
|
||||
<div class="cutter-info-item"><span class="cutter-info-label" id="cutterInfoSelectionLabel">Ausgabe</span><span class="cutter-info-value" id="infoSelection">--:--:--</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container" id="timelineContainer">
|
||||
<div class="timeline" id="timeline" onclick="seekTimeline(event)">
|
||||
<div class="timeline-selection" id="timelineSelection"></div>
|
||||
<div class="timeline-current" id="timelineCurrent"></div>
|
||||
</div>
|
||||
|
||||
<div class="time-inputs">
|
||||
<div class="time-input-group">
|
||||
<label id="cutterStartLabel" for="startTime">Start:</label>
|
||||
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
<div class="cutter-timeline-toolbar">
|
||||
<div class="cutter-timeline-timecode" id="cutterTimelineTimecode">00:00:00</div>
|
||||
<div class="cutter-history-controls">
|
||||
<button type="button" class="cutter-icon-button" id="cutterUndoBtn" onclick="undoCutterEdit()" disabled aria-label="Rückgängig" title="Rückgängig (Strg+Z)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 7 4 12l5 5v-3h5a5 5 0 0 1 5 5v1h2v-1a7 7 0 0 0-7-7H9V7z"></path></svg></button>
|
||||
<button type="button" class="cutter-icon-button" id="cutterRedoBtn" onclick="redoCutterEdit()" disabled aria-label="Wiederholen" title="Wiederholen (Strg+Y)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15 7 5 5-5 5v-3h-5a5 5 0 0 0-5 5v1H3v-1a7 7 0 0 1 7-7h5V7z"></path></svg></button>
|
||||
</div>
|
||||
<div class="time-input-group">
|
||||
<label id="cutterEndLabel" for="endTime">Ende:</label>
|
||||
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
<div class="cutter-zoom-controls">
|
||||
<button type="button" class="cutter-icon-button" id="cutterZoomOutBtn" onclick="changeCutterZoom(-0.25)" disabled aria-label="Verkleinern"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14"></path></svg></button>
|
||||
<input type="range" id="cutterZoom" min="1" max="16" step="0.05" value="1" disabled aria-label="Timeline-Zoom">
|
||||
<button type="button" class="cutter-icon-button" id="cutterZoomInBtn" onclick="changeCutterZoom(0.25)" disabled aria-label="Vergrößern"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg></button>
|
||||
</div>
|
||||
<div class="cutter-actions">
|
||||
<button type="button" class="btn-secondary" id="cutterCancelExportBtn" onclick="cancelCutterExport()" hidden>Abbrechen</button>
|
||||
<button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Video exportieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cutter-timeline-scroll" id="cutterTimelineScroll">
|
||||
<div class="timeline" id="timeline">
|
||||
<div class="cutter-ruler" id="cutterRuler"></div>
|
||||
<div class="cutter-track cutter-video-track" id="cutterVideoTrack">
|
||||
<span class="cutter-track-label" id="cutterVideoTrackLabel">VIDEO</span>
|
||||
<div class="cutter-thumbnail-strip" id="cutterThumbnailStrip"></div>
|
||||
</div>
|
||||
<div class="cutter-track cutter-audio-track" id="cutterAudioTrack">
|
||||
<span class="cutter-track-label" id="cutterAudioTrackLabel">AUDIO</span>
|
||||
<img id="cutterWaveform" alt="" draggable="false">
|
||||
<div class="cutter-audio-empty" id="cutterAudioEmpty">Keine Audiospur</div>
|
||||
</div>
|
||||
<div class="cutter-outside-shade cutter-outside-left" id="cutterOutsideLeft"></div>
|
||||
<div class="cutter-outside-shade cutter-outside-right" id="cutterOutsideRight"></div>
|
||||
<div class="timeline-selection" id="timelineSelection">
|
||||
<button type="button" class="timeline-handle start" id="cutterTrimStartHandle" aria-label="Start verschieben"></button>
|
||||
<button type="button" class="timeline-handle end" id="cutterTrimEndHandle" aria-label="Ende verschieben"></button>
|
||||
</div>
|
||||
<div class="cutter-cut-overlays" id="cutterCutOverlays"></div>
|
||||
<div class="timeline-current" id="timelineCurrent"><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,9 +615,6 @@
|
||||
<div class="progress-text" id="cutProgressText">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-actions">
|
||||
<button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -783,7 +900,7 @@
|
||||
|
||||
<div class="settings-card" data-settings-pane="updates" hidden>
|
||||
<h3 id="updateTitle">Updates</h3>
|
||||
<p id="versionInfo" class="card-intro">Version: v1.0.4</p>
|
||||
<p id="versionInfo" class="card-intro">Version: v1.0.5</p>
|
||||
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||
</div>
|
||||
|
||||
@@ -964,6 +1081,7 @@
|
||||
<script src="../dist/renderer-profile.js"></script>
|
||||
<script src="../dist/renderer-vod-hover.js"></script>
|
||||
<script src="../dist/renderer-command-palette.js"></script>
|
||||
<script src="../dist/renderer-cutter.js"></script>
|
||||
<script src="../dist/renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+687
-16
@@ -1,8 +1,9 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification, type IpcMainInvokeEvent } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
|
||||
import { connect as tlsConnect, TLSSocket } from 'node:tls';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import axios from 'axios';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
||||
@@ -36,6 +37,8 @@ import {
|
||||
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
|
||||
import { buildVodPreviewFrameUrls } from './main/domain/vod-preview';
|
||||
import { getWindowsAppIdentity } from './main/domain/app-identity';
|
||||
import { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor';
|
||||
import { calculateCutterExportProgress, createCutterExportPlan } from './main/domain/cutter-export';
|
||||
import {
|
||||
setDebugLogFn, initToolDirs,
|
||||
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
||||
@@ -250,6 +253,49 @@ interface VideoInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
hasAudio: boolean;
|
||||
videoCodec: string;
|
||||
audioCodec: string | null;
|
||||
previewCompatible: boolean;
|
||||
variableFrameRate: boolean;
|
||||
}
|
||||
|
||||
interface VideoEditorMedia {
|
||||
sourceUrl: string;
|
||||
info: VideoInfo;
|
||||
jobId: number;
|
||||
thumbnails: string[];
|
||||
waveform: string | null;
|
||||
}
|
||||
|
||||
interface VideoEditorAssets {
|
||||
jobId: number;
|
||||
thumbnails: string[];
|
||||
thumbnailSprite: string | null;
|
||||
thumbnailCount: number;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
}
|
||||
|
||||
interface VideoEditorWaveform {
|
||||
jobId: number;
|
||||
waveform: string | null;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
}
|
||||
|
||||
interface VideoEditorAssetProfile {
|
||||
timelineWidth: number;
|
||||
trackHeight: number;
|
||||
pixelRatio: number;
|
||||
}
|
||||
|
||||
interface VideoEditExportRequest {
|
||||
inputFile: string;
|
||||
outputFile?: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
cuts: EditorCut[];
|
||||
}
|
||||
|
||||
interface ReleaseUpdateInfo {
|
||||
@@ -685,6 +731,31 @@ let queuePaused = false;
|
||||
// and clip downloads via activeClipProcesses. Keeping these separate
|
||||
// prevents cancel-download from killing an unrelated cutter ffmpeg.
|
||||
let currentEditorProcess: ChildProcess | null = null;
|
||||
let currentCutterProcess: ChildProcess | null = null;
|
||||
let currentCutterPartialFile: string | null = null;
|
||||
let cutterExportActive = false;
|
||||
let cutterExportCancelled = false;
|
||||
let cutterPreparedInput: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null = null;
|
||||
let cutterMediaGeneration = 0;
|
||||
let cutterMediaRequestGeneration = 0;
|
||||
let cutterAssetRunGeneration = 0;
|
||||
let cutterWaveformGeneration = 0;
|
||||
let cutterMediaJob: {
|
||||
jobId: number;
|
||||
path: string;
|
||||
identity: { path: string; size: number; mtimeMs: number; dev: number; ino: number };
|
||||
info: VideoInfo;
|
||||
waveform: VideoEditorWaveform | null;
|
||||
waveformPromise: Promise<VideoEditorWaveform | null> | null;
|
||||
previewDirectory: string | null;
|
||||
} | null = null;
|
||||
let appShutdownStarted = false;
|
||||
const currentCutterMediaProcesses = new Set<ChildProcess>();
|
||||
const currentCutterWaveformProcesses = new Set<ChildProcess>();
|
||||
const currentCutterProbeProcesses = new Set<ChildProcess>();
|
||||
const currentCutterInfoProcesses = new Set<ChildProcess>();
|
||||
const currentCutterExportProcesses = new Set<ChildProcess>();
|
||||
const currentCutterPreviewProcesses = new Set<ChildProcess>();
|
||||
// Per-item cancellation lives in `cancelledItemIds`. The previous global
|
||||
// `currentDownloadCancelled` flag was redundant once pause/cancel/remove
|
||||
// started iterating activeDownloads and adding each item to that Set; it
|
||||
@@ -2677,7 +2748,20 @@ async function getClipInfo(clipId: string): Promise<any | null> {
|
||||
// ==========================================
|
||||
// VIDEO INFO (for cutter)
|
||||
// ==========================================
|
||||
async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
|
||||
function isVideoEditorPreviewCompatible(filePath: string, videoCodec: string, audioCodec: string | null): boolean {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const audioCompatible = !audioCodec || ['aac', 'mp3', 'opus', 'vorbis'].includes(audioCodec);
|
||||
if (['.mp4', '.m4v', '.mov'].includes(extension)) return ['h264', 'av1', 'vp9'].includes(videoCodec) && audioCompatible;
|
||||
if (extension === '.webm') return ['vp8', 'vp9', 'av1'].includes(videoCodec) && audioCompatible;
|
||||
if (extension === '.mkv') return ['h264', 'av1', 'vp8', 'vp9'].includes(videoCodec) && audioCompatible;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSupportedVideoEditorInput(filePath: string): boolean {
|
||||
return ['.mp4', '.m4v', '.mov', '.webm', '.mkv', '.ts', '.avi'].includes(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
async function getVideoInfo(filePath: string, trackedProcesses?: Set<ChildProcess>, timeoutMs = 30000): Promise<VideoInfo | null> {
|
||||
const ffmpegReady = await ensureFfmpegInstalled();
|
||||
if (!ffmpegReady) {
|
||||
appendDebugLog('get-video-info-missing-ffmpeg');
|
||||
@@ -2695,7 +2779,29 @@ async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
|
||||
];
|
||||
|
||||
const proc = spawn(ffprobe, args, { windowsHide: true });
|
||||
trackedProcesses?.add(proc);
|
||||
proc.stderr?.resume();
|
||||
let output = '';
|
||||
let resolved = false;
|
||||
let forceResolveTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const resolveOnce = (value: VideoInfo | null): void => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const finish = (value: VideoInfo | null): void => {
|
||||
clearTimeout(timeoutTimer);
|
||||
if (forceResolveTimer) clearTimeout(forceResolveTimer);
|
||||
trackedProcesses?.delete(proc);
|
||||
resolveOnce(value);
|
||||
};
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
try { proc.kill(); } catch { }
|
||||
forceResolveTimer = setTimeout(() => resolveOnce(null), 2000);
|
||||
}, timeoutMs);
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
output += data.toString();
|
||||
@@ -2703,29 +2809,447 @@ async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
resolve(null);
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = JSON.parse(output);
|
||||
const videoStream = info.streams?.find((s: any) => s.codec_type === 'video');
|
||||
const audioStream = info.streams?.find((s: any) => s.codec_type === 'audio');
|
||||
const duration = parseFloat(info.format?.duration || videoStream?.duration || '0');
|
||||
const averageFps = parseFrameRate(videoStream?.avg_frame_rate);
|
||||
const realFps = parseFrameRate(videoStream?.r_frame_rate);
|
||||
const fps = averageFps > 0 ? averageFps : realFps;
|
||||
|
||||
resolve({
|
||||
duration: parseFloat(info.format?.duration || '0'),
|
||||
width: videoStream?.width || 0,
|
||||
height: videoStream?.height || 0,
|
||||
fps: parseFrameRate(videoStream?.r_frame_rate)
|
||||
if (!videoStream || !Number.isFinite(duration) || duration <= 0 || !videoStream.width || !videoStream.height || !Number.isFinite(fps) || fps <= 0) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const videoCodec = String(videoStream.codec_name || '').toLowerCase();
|
||||
const audioCodec = audioStream ? String(audioStream.codec_name || '').toLowerCase() : null;
|
||||
const variableFrameRate = averageFps > 0 && realFps > 0 && Math.abs(averageFps - realFps) / Math.max(averageFps, realFps) > 0.005;
|
||||
finish({
|
||||
duration,
|
||||
width: videoStream.width,
|
||||
height: videoStream.height,
|
||||
fps,
|
||||
hasAudio: Boolean(audioStream),
|
||||
videoCodec,
|
||||
audioCodec,
|
||||
previewCompatible: isVideoEditorPreviewCompatible(filePath, videoCodec, audioCodec),
|
||||
variableFrameRate,
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', () => resolve(null));
|
||||
proc.on('error', () => finish(null));
|
||||
});
|
||||
}
|
||||
|
||||
function cancelCutterMediaPreparation(): void {
|
||||
cutterAssetRunGeneration += 1;
|
||||
for (const process of currentCutterMediaProcesses) {
|
||||
try { process.kill(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelCutterMetadataPreparation(): void {
|
||||
for (const process of currentCutterProbeProcesses) {
|
||||
try { process.kill(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelCutterPreviewPreparation(): void {
|
||||
for (const process of currentCutterPreviewProcesses) {
|
||||
try { process.kill(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelCutterWaveformPreparation(): void {
|
||||
cutterWaveformGeneration += 1;
|
||||
for (const process of currentCutterWaveformProcesses) {
|
||||
try { process.kill(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function runEditorMediaProcess(args: string[], runGeneration: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (runGeneration !== cutterAssetRunGeneration || appShutdownStarted) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const proc = spawn(getFFmpegPath(), args, { windowsHide: true });
|
||||
currentCutterMediaProcesses.add(proc);
|
||||
proc.stderr?.resume();
|
||||
let settled = false;
|
||||
const finish = (success: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
currentCutterMediaProcesses.delete(proc);
|
||||
resolve(success && runGeneration === cutterAssetRunGeneration && !appShutdownStarted);
|
||||
};
|
||||
proc.on('close', (code) => finish(code === 0));
|
||||
proc.on('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
function runEditorWaveformProcess(args: string[], runGeneration: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (runGeneration !== cutterWaveformGeneration || appShutdownStarted) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const proc = spawn(getFFmpegPath(), args, { windowsHide: true });
|
||||
currentCutterWaveformProcesses.add(proc);
|
||||
proc.stderr?.resume();
|
||||
let settled = false;
|
||||
const finish = (success: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
currentCutterWaveformProcesses.delete(proc);
|
||||
resolve(success && runGeneration === cutterWaveformGeneration && !appShutdownStarted);
|
||||
};
|
||||
proc.on('close', (code) => finish(code === 0));
|
||||
proc.on('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
function runEditorPreviewProcess(args: string[], requestGeneration: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (requestGeneration !== cutterMediaRequestGeneration || appShutdownStarted) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const proc = spawn(getFFmpegPath(), args, { windowsHide: true });
|
||||
currentCutterPreviewProcesses.add(proc);
|
||||
proc.stderr?.resume();
|
||||
let settled = false;
|
||||
const finish = (success: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
currentCutterPreviewProcesses.delete(proc);
|
||||
resolve(success && requestGeneration === cutterMediaRequestGeneration && !appShutdownStarted);
|
||||
};
|
||||
proc.on('close', (code) => finish(code === 0));
|
||||
proc.on('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
function removeCutterPreviewDirectory(directory: string | null): void {
|
||||
if (!directory) return;
|
||||
try { fs.rmSync(directory, { recursive: true, force: true }); } catch { }
|
||||
}
|
||||
|
||||
function createVideoEditorPreview(filePath: string, info: VideoInfo, requestGeneration: number): Promise<{ sourceUrl: string; directory: string } | null> {
|
||||
const directory = fs.mkdtempSync(path.join(app.getPath('temp'), `tvm-editor-preview-${process.pid}-`));
|
||||
const previewFile = path.join(directory, 'preview.mp4');
|
||||
const copyVideo = ['h264', 'av1', 'vp9'].includes(info.videoCodec);
|
||||
const copyAudio = !info.audioCodec || ['aac', 'mp3'].includes(info.audioCodec);
|
||||
const args = ['-fflags', '+genpts', '-i', filePath, '-map', '0:v:0', '-map', '0:a:0?'];
|
||||
if (copyVideo) args.push('-c:v', 'copy');
|
||||
else args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '20', '-pix_fmt', 'yuv420p');
|
||||
if (info.hasAudio) {
|
||||
if (copyAudio) args.push('-c:a', 'copy');
|
||||
else args.push('-c:a', 'aac', '-b:a', '160k');
|
||||
} else {
|
||||
args.push('-an');
|
||||
}
|
||||
args.push('-movflags', '+faststart', '-avoid_negative_ts', 'make_zero', '-y', previewFile);
|
||||
return runEditorPreviewProcess(args, requestGeneration).then((success) => {
|
||||
if (!success || !fs.existsSync(previewFile) || fs.statSync(previewFile).size <= 256) {
|
||||
removeCutterPreviewDirectory(directory);
|
||||
return null;
|
||||
}
|
||||
return { sourceUrl: pathToFileURL(previewFile).href, directory };
|
||||
});
|
||||
}
|
||||
|
||||
function readImageDataUrl(filePath: string): string | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const mediaType = extension === '.png' ? 'image/png' : 'image/jpeg';
|
||||
return `data:${mediaType};base64,${fs.readFileSync(filePath).toString('base64')}`;
|
||||
}
|
||||
|
||||
async function prepareVideoEditorMedia(filePath: string): Promise<VideoEditorMedia | null> {
|
||||
if (appShutdownStarted || typeof filePath !== 'string' || !path.isAbsolute(filePath) || !isSupportedVideoEditorInput(filePath) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return null;
|
||||
const requestGeneration = ++cutterMediaRequestGeneration;
|
||||
cancelCutterPreviewPreparation();
|
||||
cancelCutterMetadataPreparation();
|
||||
const identityBefore = getCutterInputIdentity(filePath);
|
||||
if (!identityBefore) return null;
|
||||
const info = await getVideoInfo(filePath, currentCutterProbeProcesses);
|
||||
const identityAfter = getCutterInputIdentity(filePath);
|
||||
if (!info || info.variableFrameRate || requestGeneration !== cutterMediaRequestGeneration || !cutterInputIdentitiesMatch(identityBefore, identityAfter) || appShutdownStarted) return null;
|
||||
const preview = info.previewCompatible
|
||||
? { sourceUrl: pathToFileURL(filePath).href, directory: null }
|
||||
: await createVideoEditorPreview(filePath, info, requestGeneration);
|
||||
if (!preview || requestGeneration !== cutterMediaRequestGeneration || !cutterInputIdentitiesMatch(identityBefore, getCutterInputIdentity(filePath)) || appShutdownStarted) {
|
||||
removeCutterPreviewDirectory(preview?.directory || null);
|
||||
return null;
|
||||
}
|
||||
cancelCutterMediaPreparation();
|
||||
cancelCutterWaveformPreparation();
|
||||
removeCutterPreviewDirectory(cutterMediaJob?.previewDirectory || null);
|
||||
const jobId = ++cutterMediaGeneration;
|
||||
cutterMediaJob = { jobId, path: identityAfter!.path, identity: identityAfter!, info, waveform: null, waveformPromise: null, previewDirectory: preview.directory };
|
||||
return {
|
||||
sourceUrl: preview.sourceUrl,
|
||||
info,
|
||||
jobId,
|
||||
thumbnails: [],
|
||||
waveform: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareVideoEditorWaveform(filePath: string, jobId: number): Promise<VideoEditorWaveform | null> {
|
||||
const job = cutterMediaJob;
|
||||
if (appShutdownStarted || typeof filePath !== 'string' || !path.isAbsolute(filePath) || !Number.isInteger(jobId) || !job || jobId !== job.jobId || normalizeComparablePath(filePath) !== job.path || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), job.identity)) return null;
|
||||
if (!job.info.hasAudio) return { jobId, waveform: null, pixelWidth: 32000, pixelHeight: 240 };
|
||||
if (job.waveform) return job.waveform;
|
||||
if (job.waveformPromise) return await job.waveformPromise;
|
||||
const runGeneration = cutterWaveformGeneration;
|
||||
const promise = (async (): Promise<VideoEditorWaveform | null> => {
|
||||
const tempDir = fs.mkdtempSync(path.join(app.getPath('temp'), `tvm-editor-waveform-${process.pid}-`));
|
||||
const waveformFile = path.join(tempDir, 'waveform.png');
|
||||
try {
|
||||
const success = await runEditorWaveformProcess([
|
||||
'-i', filePath,
|
||||
'-filter_complex', 'aformat=channel_layouts=mono,showwavespic=s=32000x240:colors=white',
|
||||
'-frames:v', '1',
|
||||
'-y', waveformFile,
|
||||
], runGeneration);
|
||||
if (!success || runGeneration !== cutterWaveformGeneration || cutterMediaJob !== job || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), job.identity) || appShutdownStarted) return null;
|
||||
const waveform = readImageDataUrl(waveformFile);
|
||||
if (!waveform) return null;
|
||||
const result = { jobId, waveform, pixelWidth: 32000, pixelHeight: 240 };
|
||||
job.waveform = result;
|
||||
return result;
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})();
|
||||
job.waveformPromise = promise;
|
||||
const result = await promise;
|
||||
if (cutterMediaJob === job && job.waveformPromise === promise) job.waveformPromise = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function prepareVideoEditorAssets(filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null> {
|
||||
if (appShutdownStarted || typeof filePath !== 'string' || !path.isAbsolute(filePath) || !Number.isInteger(jobId) || !profile || !Number.isFinite(profile.timelineWidth) || profile.timelineWidth <= 0 || !Number.isFinite(profile.trackHeight) || profile.trackHeight <= 0 || !Number.isFinite(profile.pixelRatio) || profile.pixelRatio <= 0 || !cutterMediaJob || jobId !== cutterMediaJob.jobId || normalizeComparablePath(filePath) !== cutterMediaJob.path || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), cutterMediaJob.identity)) return null;
|
||||
cancelCutterMediaPreparation();
|
||||
const runGeneration = cutterAssetRunGeneration;
|
||||
const info = cutterMediaJob.info;
|
||||
const tempDir = fs.mkdtempSync(path.join(app.getPath('temp'), `tvm-editor-media-${process.pid}-`));
|
||||
const pixelRatio = Math.min(3, Math.max(1, profile.pixelRatio));
|
||||
const pixelWidth = info.duration <= 120 ? 32000 : Math.round(Math.min(32000, Math.max(1800, Math.ceil(profile.timelineWidth * pixelRatio))));
|
||||
const thumbnailCount = info.duration <= 120 ? 200 : Math.round(Math.min(100, Math.max(30, Math.ceil(pixelWidth / 320))));
|
||||
const thumbnailTileWidth = Math.max(428, Math.ceil(pixelWidth / thumbnailCount / 2) * 2);
|
||||
const thumbnailTileHeight = 240;
|
||||
try {
|
||||
const thumbnailsReady = await runEditorMediaProcess([
|
||||
'-i', filePath,
|
||||
'-vf', `fps=${thumbnailCount / info.duration},scale=${thumbnailTileWidth}:${thumbnailTileHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${thumbnailTileWidth}:${thumbnailTileHeight}`,
|
||||
'-frames:v', String(thumbnailCount),
|
||||
'-q:v', '2',
|
||||
'-start_number', '1',
|
||||
'-y', path.join(tempDir, 'thumb-%03d.jpg'),
|
||||
], runGeneration);
|
||||
if (runGeneration !== cutterAssetRunGeneration || !cutterMediaJob || jobId !== cutterMediaJob.jobId || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), cutterMediaJob.identity) || appShutdownStarted) return null;
|
||||
if (!thumbnailsReady) return null;
|
||||
const thumbnails = fs.readdirSync(tempDir)
|
||||
.filter((name) => /^thumb-\d+\.jpg$/i.test(name))
|
||||
.sort()
|
||||
.map((name) => readImageDataUrl(path.join(tempDir, name)))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
if (thumbnails.length < Math.floor(thumbnailCount * 0.95)) return null;
|
||||
return {
|
||||
jobId,
|
||||
thumbnails,
|
||||
thumbnailSprite: null,
|
||||
thumbnailCount: thumbnails.length,
|
||||
pixelWidth,
|
||||
pixelHeight: thumbnailTileHeight,
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function getCutterInputIdentity(filePath: string): { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) return null;
|
||||
return { path: normalizeComparablePath(filePath), size: stat.size, mtimeMs: stat.mtimeMs, dev: stat.dev, ino: stat.ino };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cutterInputIdentityMatches(filePath: string): boolean {
|
||||
const current = getCutterInputIdentity(filePath);
|
||||
return cutterInputIdentitiesMatch(current, cutterPreparedInput);
|
||||
}
|
||||
|
||||
function cutterInputIdentitiesMatch(
|
||||
left: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null,
|
||||
right: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null,
|
||||
): boolean {
|
||||
return Boolean(left && right
|
||||
&& left.path === right.path
|
||||
&& left.size === right.size
|
||||
&& left.mtimeMs === right.mtimeMs
|
||||
&& left.dev === right.dev
|
||||
&& left.ino === right.ino);
|
||||
}
|
||||
|
||||
function normalizeComparablePath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
let canonical = resolved;
|
||||
try { canonical = fs.realpathSync.native(resolved); } catch {
|
||||
try {
|
||||
const parent = fs.realpathSync.native(path.dirname(resolved));
|
||||
canonical = path.join(parent, path.basename(resolved));
|
||||
} catch { }
|
||||
}
|
||||
return process.platform === 'win32' ? canonical.toLocaleLowerCase('en-US') : canonical;
|
||||
}
|
||||
|
||||
function pathsReferToSameFile(left: string, right: string): boolean {
|
||||
if (normalizeComparablePath(left) === normalizeComparablePath(right)) return true;
|
||||
if (!fs.existsSync(left) || !fs.existsSync(right)) return false;
|
||||
try {
|
||||
const leftStat = fs.statSync(left);
|
||||
const rightStat = fs.statSync(right);
|
||||
return leftStat.dev === rightStat.dev && leftStat.ino !== 0 && leftStat.ino === rightStat.ino;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function publishVideoEditorOutput(partialFile: string, outputFile: string): void {
|
||||
const backupFile = `${outputFile}.${process.pid}.${Date.now()}.tvm-backup`;
|
||||
const hadExistingOutput = fs.existsSync(outputFile);
|
||||
if (hadExistingOutput) fs.renameSync(outputFile, backupFile);
|
||||
try {
|
||||
fs.renameSync(partialFile, outputFile);
|
||||
if (hadExistingOutput) {
|
||||
try { fs.rmSync(backupFile, { force: true }); } catch { }
|
||||
}
|
||||
} catch (error) {
|
||||
if (hadExistingOutput && fs.existsSync(backupFile) && !fs.existsSync(outputFile)) fs.renameSync(backupFile, outputFile);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function performVideoEditExport(request: VideoEditExportRequest, onProgress: (percent: number) => void): Promise<boolean> {
|
||||
if (appShutdownStarted) return false;
|
||||
if (!request || typeof request.inputFile !== 'string' || typeof request.outputFile !== 'string') return false;
|
||||
if (!path.isAbsolute(request.inputFile) || !path.isAbsolute(request.outputFile) || !fs.existsSync(request.inputFile)) return false;
|
||||
if (path.extname(request.outputFile).toLowerCase() !== '.mp4' || pathsReferToSameFile(request.inputFile, request.outputFile)) return false;
|
||||
if (!Number.isFinite(request.trimStart) || !Number.isFinite(request.trimEnd) || !Array.isArray(request.cuts) || request.cuts.length > 64) return false;
|
||||
if (request.cuts.some((cut) => !isPlainObject(cut) || typeof cut.id !== 'string' || !Number.isFinite(cut.start) || !Number.isFinite(cut.end))) return false;
|
||||
const inputIdentity = getCutterInputIdentity(request.inputFile);
|
||||
if (!cutterInputIdentitiesMatch(inputIdentity, cutterPreparedInput)) return false;
|
||||
const info = await getVideoInfo(request.inputFile, currentCutterExportProcesses);
|
||||
if (!info || cutterExportCancelled) return false;
|
||||
let state = setTrimRange(createVideoEditorState(info.duration, info.fps), request.trimStart, request.trimEnd);
|
||||
if (Math.abs(state.trimStart - request.trimStart) > 1 / info.fps || Math.abs(state.trimEnd - request.trimEnd) > 1 / info.fps) return false;
|
||||
try {
|
||||
for (const cut of request.cuts) {
|
||||
state = addCutAt(state, cut.start, cut.end - cut.start).state;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const segments = getPlayableSegments(state);
|
||||
if (segments.length === 0) return false;
|
||||
const outputDir = path.dirname(request.outputFile);
|
||||
if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) return false;
|
||||
const inputBytes = fs.statSync(request.inputFile).size;
|
||||
const diskCheck = ensureDiskSpace(outputDir, Math.max(128 * 1024 * 1024, Math.ceil(inputBytes * 1.25)), 'Video-Editor');
|
||||
if (!diskCheck.success) return false;
|
||||
const partialFile = path.join(outputDir, `.${path.basename(request.outputFile, '.mp4')}.${process.pid}.${Date.now()}.tvm-edit.mp4`);
|
||||
const plan = createCutterExportPlan({ inputFile: request.inputFile, outputFile: partialFile, segments, hasAudio: info.hasAudio });
|
||||
if (plan.filterComplex.length > 24000 || cutterExportCancelled) return false;
|
||||
currentCutterPartialFile = partialFile;
|
||||
const success = await new Promise<boolean>((resolve) => {
|
||||
const proc = spawn(getFFmpegPath(), plan.ffmpegArgs, { windowsHide: true });
|
||||
currentCutterProcess = proc;
|
||||
currentCutterExportProcesses.add(proc);
|
||||
proc.stderr?.resume();
|
||||
let stdout = '';
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
const lines = stdout.split(/\r?\n/);
|
||||
stdout = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^out_time_(?:us|ms)=(\d+)$/);
|
||||
if (match) onProgress(calculateCutterExportProgress(Number(match[1]) / 1_000_000, plan));
|
||||
}
|
||||
});
|
||||
proc.on('close', (code) => {
|
||||
currentCutterExportProcesses.delete(proc);
|
||||
if (currentCutterProcess === proc) currentCutterProcess = null;
|
||||
resolve(code === 0 && !cutterExportCancelled);
|
||||
});
|
||||
proc.on('error', () => {
|
||||
currentCutterExportProcesses.delete(proc);
|
||||
if (currentCutterProcess === proc) currentCutterProcess = null;
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
if (!success || !fs.existsSync(partialFile) || fs.statSync(partialFile).size <= 256) {
|
||||
fs.rmSync(partialFile, { force: true });
|
||||
currentCutterPartialFile = null;
|
||||
return false;
|
||||
}
|
||||
if (cutterExportCancelled) {
|
||||
fs.rmSync(partialFile, { force: true });
|
||||
currentCutterPartialFile = null;
|
||||
return false;
|
||||
}
|
||||
const outputInfo = await getVideoInfo(partialFile, currentCutterExportProcesses);
|
||||
if (cutterExportCancelled || !outputInfo || Math.abs(outputInfo.duration - plan.remainingDuration) > Math.max(0.12, 3 / info.fps)) {
|
||||
fs.rmSync(partialFile, { force: true });
|
||||
currentCutterPartialFile = null;
|
||||
return false;
|
||||
}
|
||||
if (cutterExportCancelled || pathsReferToSameFile(request.inputFile, request.outputFile) || !cutterInputIdentitiesMatch(getCutterInputIdentity(request.inputFile), inputIdentity)) {
|
||||
fs.rmSync(partialFile, { force: true });
|
||||
currentCutterPartialFile = null;
|
||||
return false;
|
||||
}
|
||||
publishVideoEditorOutput(partialFile, request.outputFile);
|
||||
currentCutterPartialFile = null;
|
||||
onProgress(100);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function exportVideoEdit(request: VideoEditExportRequest, onProgress: (percent: number) => void): Promise<{ success: boolean; cancelled: boolean }> {
|
||||
if (cutterExportActive || appShutdownStarted) return { success: false, cancelled: false };
|
||||
cutterExportActive = true;
|
||||
cutterExportCancelled = false;
|
||||
try {
|
||||
const success = await performVideoEditExport(request, onProgress);
|
||||
return { success, cancelled: !success && cutterExportCancelled };
|
||||
} catch (error) {
|
||||
appendDebugLog('video-editor-export-failed', String(error));
|
||||
return { success: false, cancelled: cutterExportCancelled };
|
||||
} finally {
|
||||
cutterExportActive = false;
|
||||
cutterExportCancelled = false;
|
||||
if (currentCutterPartialFile && !currentCutterProcess) {
|
||||
try { fs.rmSync(currentCutterPartialFile, { force: true }); } catch { }
|
||||
currentCutterPartialFile = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VIDEO CUTTER
|
||||
// ==========================================
|
||||
@@ -2750,6 +3274,7 @@ async function extractFrame(filePath: string, timeSeconds: number): Promise<stri
|
||||
];
|
||||
|
||||
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
||||
proc.stderr?.resume();
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 && fs.existsSync(tempFile)) {
|
||||
@@ -6190,7 +6715,13 @@ function createWindow(): void {
|
||||
mainWindow.removeMenu();
|
||||
}
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, '../src/index.html'));
|
||||
const rendererFile = path.join(__dirname, '../src/index.html');
|
||||
const rendererUrl = pathToFileURL(rendererFile).href;
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
if (url.split(/[?#]/, 1)[0] !== rendererUrl) event.preventDefault();
|
||||
});
|
||||
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||
mainWindow.loadFile(rendererFile);
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
emitQueueUpdated(true);
|
||||
@@ -6971,7 +7502,7 @@ ipcMain.handle('select-video-file', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
|
||||
{ name: 'Video Files', extensions: ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'] }
|
||||
]
|
||||
});
|
||||
return result.filePaths[0] || null;
|
||||
@@ -7433,15 +7964,85 @@ ipcMain.handle('import-config', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function isTrustedRendererEvent(event: IpcMainInvokeEvent): boolean {
|
||||
if (!mainWindow || event.sender.id !== mainWindow.webContents.id) return false;
|
||||
const rendererUrl = pathToFileURL(path.join(__dirname, '../src/index.html')).href;
|
||||
const senderUrl = event.senderFrame?.url || event.sender.getURL();
|
||||
return senderUrl.split(/[?#]/, 1)[0] === rendererUrl;
|
||||
}
|
||||
|
||||
function isPathInsideDirectory(rootDirectory: string, candidate: string): boolean {
|
||||
const root = normalizeComparablePath(rootDirectory);
|
||||
const target = normalizeComparablePath(candidate);
|
||||
const relative = path.relative(root, target);
|
||||
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
// Video Cutter IPC
|
||||
ipcMain.handle('get-video-info', async (_, filePath: string) => {
|
||||
return await getVideoInfo(filePath);
|
||||
ipcMain.handle('get-video-info', async (event, filePath: string) => {
|
||||
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
||||
return await getVideoInfo(filePath, currentCutterInfoProcesses);
|
||||
});
|
||||
|
||||
ipcMain.handle('extract-frame', async (_, filePath: string, timeSeconds: number) => {
|
||||
ipcMain.handle('extract-frame', async (event, filePath: string, timeSeconds: number) => {
|
||||
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
||||
return await extractFrame(filePath, timeSeconds);
|
||||
});
|
||||
|
||||
ipcMain.handle('prepare-video-editor-media', async (event, filePath: string) => {
|
||||
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
||||
const media = await prepareVideoEditorMedia(filePath);
|
||||
if (media && cutterMediaJob?.jobId === media.jobId) cutterPreparedInput = cutterMediaJob.identity;
|
||||
return media;
|
||||
});
|
||||
|
||||
ipcMain.handle('prepare-video-editor-waveform', async (event, filePath: string, jobId: number) => {
|
||||
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
||||
return await prepareVideoEditorWaveform(filePath, jobId);
|
||||
});
|
||||
|
||||
ipcMain.handle('prepare-video-editor-assets', async (event, filePath: string, jobId: number, profile: VideoEditorAssetProfile) => {
|
||||
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
||||
return await prepareVideoEditorAssets(filePath, jobId, profile);
|
||||
});
|
||||
|
||||
ipcMain.handle('cancel-video-editor-assets', (event, jobId: number) => {
|
||||
if (!isTrustedRendererEvent(event) || !Number.isInteger(jobId) || cutterMediaJob?.jobId !== jobId) return false;
|
||||
cancelCutterMediaPreparation();
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('export-video-edit', async (event, request: VideoEditExportRequest) => {
|
||||
if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputFile !== 'string') return { success: false, outputFile: null };
|
||||
if (!cutterInputIdentityMatches(request.inputFile)) return { success: false, outputFile: null };
|
||||
let outputFile: string | null = null;
|
||||
const testRoot = process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT;
|
||||
if (testRoot && typeof request.outputFile === 'string' && isPathInsideDirectory(testRoot, request.outputFile)) {
|
||||
outputFile = request.outputFile;
|
||||
} else {
|
||||
const defaultName = path.join(path.dirname(request.inputFile), `${path.basename(request.inputFile, path.extname(request.inputFile))}_edited.mp4`);
|
||||
const result = await dialog.showSaveDialog(mainWindow!, {
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return { success: false, outputFile: null, cancelled: true };
|
||||
outputFile = result.filePath;
|
||||
}
|
||||
const outcome = await exportVideoEdit({ ...request, outputFile }, (percent) => {
|
||||
mainWindow?.webContents.send('cut-progress', percent);
|
||||
});
|
||||
return { success: outcome.success, outputFile: outcome.success ? outputFile : null, cancelled: outcome.cancelled || undefined };
|
||||
});
|
||||
|
||||
ipcMain.handle('cancel-video-edit', (event) => {
|
||||
if (!isTrustedRendererEvent(event) || !cutterExportActive) return false;
|
||||
cutterExportCancelled = true;
|
||||
for (const process of currentCutterExportProcesses) {
|
||||
try { process.kill(); } catch { }
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => {
|
||||
const dir = path.dirname(inputFile);
|
||||
const baseName = path.basename(inputFile, path.extname(inputFile));
|
||||
@@ -7495,11 +8096,40 @@ ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
|
||||
let appDb: DbHandle | null = null;
|
||||
export function getAppDb(): DbHandle | null { return appDb; }
|
||||
|
||||
function cleanupStaleCutterMediaDirectories(): number {
|
||||
const tempRoot = path.resolve(app.getPath('temp'));
|
||||
let removed = 0;
|
||||
try {
|
||||
for (const name of fs.readdirSync(tempRoot)) {
|
||||
if (!/^tvm-editor-(?:media|waveform|preview)-[A-Za-z0-9_-]+$/.test(name)) continue;
|
||||
const candidate = path.resolve(tempRoot, name);
|
||||
if (path.dirname(candidate) !== tempRoot) continue;
|
||||
const processMatch = name.match(/^tvm-editor-(?:media|waveform|preview)-(\d+)-/);
|
||||
if (processMatch) {
|
||||
const ownerPid = Number(processMatch[1]);
|
||||
if (ownerPid === process.pid) continue;
|
||||
try {
|
||||
process.kill(ownerPid, 0);
|
||||
continue;
|
||||
} catch { }
|
||||
} else {
|
||||
const ageMs = Date.now() - fs.statSync(candidate).mtimeMs;
|
||||
if (ageMs < 24 * 60 * 60 * 1000) continue;
|
||||
}
|
||||
fs.rmSync(candidate, { recursive: true, force: true });
|
||||
removed += 1;
|
||||
}
|
||||
} catch { }
|
||||
return removed;
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const removedPartialDownloads = partialDownloadRegistry.cleanup();
|
||||
if (removedPartialDownloads.length > 0) {
|
||||
appendDebugLog('partial-downloads-cleaned-on-startup', { count: removedPartialDownloads.length });
|
||||
}
|
||||
const removedCutterMediaDirectories = cleanupStaleCutterMediaDirectories();
|
||||
if (removedCutterMediaDirectories > 0) appendDebugLog('cutter-media-cleaned-on-startup', { count: removedCutterMediaDirectories });
|
||||
refreshBundledToolPaths(true);
|
||||
startMetadataCacheCleanup();
|
||||
startDebugLogFlushTimer();
|
||||
@@ -7544,9 +8174,26 @@ let shutdownCleanupDone = false;
|
||||
let quitAfterCleanup = false;
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
|
||||
function waitForChildProcessClose(process: ChildProcess | null, timeoutMs = 5000): Promise<void> {
|
||||
if (!process || process.exitCode !== null || process.signalCode !== null) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, timeoutMs);
|
||||
process.once('close', finish);
|
||||
process.once('error', finish);
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Promise<void> {
|
||||
if (shutdownCleanupDone) return;
|
||||
shutdownCleanupDone = true;
|
||||
appShutdownStarted = true;
|
||||
|
||||
appendDebugLog('shutdown-cleanup', { reason });
|
||||
|
||||
@@ -7579,10 +8226,34 @@ async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Pro
|
||||
activeClipProcesses.clear();
|
||||
|
||||
if (currentEditorProcess) {
|
||||
try { currentEditorProcess.kill(); } catch { /* already exited */ }
|
||||
const editorProcess = currentEditorProcess;
|
||||
try { editorProcess.kill(); } catch { /* already exited */ }
|
||||
await waitForChildProcessClose(editorProcess);
|
||||
currentEditorProcess = null;
|
||||
}
|
||||
|
||||
if (cutterExportActive) cutterExportCancelled = true;
|
||||
const exportProcesses = [...currentCutterExportProcesses];
|
||||
for (const process of exportProcesses) {
|
||||
try { process.kill(); } catch { }
|
||||
}
|
||||
await Promise.all(exportProcesses.map((process) => waitForChildProcessClose(process)));
|
||||
if (currentCutterProcess && exportProcesses.includes(currentCutterProcess)) currentCutterProcess = null;
|
||||
const mediaProcesses = [...currentCutterMediaProcesses, ...currentCutterWaveformProcesses, ...currentCutterProbeProcesses, ...currentCutterInfoProcesses, ...currentCutterPreviewProcesses];
|
||||
cancelCutterMediaPreparation();
|
||||
cancelCutterWaveformPreparation();
|
||||
cancelCutterMetadataPreparation();
|
||||
cancelCutterPreviewPreparation();
|
||||
for (const process of currentCutterInfoProcesses) {
|
||||
try { process.kill(); } catch { }
|
||||
}
|
||||
await Promise.all(mediaProcesses.map((process) => waitForChildProcessClose(process)));
|
||||
removeCutterPreviewDirectory(cutterMediaJob?.previewDirectory || null);
|
||||
if (currentCutterPartialFile) {
|
||||
try { fs.rmSync(currentCutterPartialFile, { force: true }); } catch { }
|
||||
currentCutterPartialFile = null;
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
flushQueueSave();
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ describe.runIf(process.platform === 'win32')('prepareWindowsDevExecutable', () =
|
||||
sourcePath,
|
||||
destinationPath,
|
||||
iconPath,
|
||||
version: '1.0.4'
|
||||
version: '1.0.5'
|
||||
});
|
||||
|
||||
const executable = ResEdit.NtExecutable.from(fs.readFileSync(destinationPath));
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { calculateCutterExportProgress, createCutterExportPlan } from './cutter-export';
|
||||
|
||||
describe('cutter export segments', () => {
|
||||
test('sorts playable segments and preserves the caller input', () => {
|
||||
const segments = [
|
||||
{ start: 30, end: 45 },
|
||||
{ start: 5, end: 20 },
|
||||
];
|
||||
|
||||
const plan = createCutterExportPlan({
|
||||
inputFile: 'D:\\media\\source.mp4',
|
||||
outputFile: 'D:\\media\\result.mp4',
|
||||
segments,
|
||||
hasAudio: true,
|
||||
});
|
||||
|
||||
expect(plan.segments).toEqual([
|
||||
{ start: 5, end: 20 },
|
||||
{ start: 30, end: 45 },
|
||||
]);
|
||||
expect(plan.remainingDuration).toBe(30);
|
||||
expect(segments).toEqual([
|
||||
{ start: 30, end: 45 },
|
||||
{ start: 5, end: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds an audio and video concat filter with a separated argument list', () => {
|
||||
const plan = createCutterExportPlan({
|
||||
inputFile: 'D:\\media folder\\source.mp4',
|
||||
outputFile: 'D:\\exports\\result.mp4',
|
||||
segments: [
|
||||
{ start: 5, end: 20 },
|
||||
{ start: 30.5, end: 45.25 },
|
||||
],
|
||||
hasAudio: true,
|
||||
});
|
||||
|
||||
expect(plan.filterComplex).toBe('[0:v]trim=start=5:end=20,setpts=PTS-STARTPTS[v0];[0:a]atrim=start=5:end=20,asetpts=PTS-STARTPTS[a0];[0:v]trim=start=30.5:end=45.25,setpts=PTS-STARTPTS[v1];[0:a]atrim=start=30.5:end=45.25,asetpts=PTS-STARTPTS[a1];[v0][a0][v1][a1]concat=n=2:v=1:a=1[outv][outa]');
|
||||
expect(plan.ffmpegArgs).toEqual([
|
||||
'-i', 'D:\\media folder\\source.mp4',
|
||||
'-filter_complex', plan.filterComplex,
|
||||
'-map', '[outv]',
|
||||
'-map', '[outa]',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'veryfast',
|
||||
'-crf', '20',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '160k',
|
||||
'-movflags', '+faststart',
|
||||
'-progress', 'pipe:1',
|
||||
'-y', 'D:\\exports\\result.mp4',
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds a video-only concat filter without audio mappings or codecs', () => {
|
||||
const plan = createCutterExportPlan({
|
||||
inputFile: 'input.mkv',
|
||||
outputFile: 'output.mp4',
|
||||
segments: [
|
||||
{ start: 0, end: 10 },
|
||||
{ start: 12, end: 18 },
|
||||
],
|
||||
hasAudio: false,
|
||||
});
|
||||
|
||||
expect(plan.filterComplex).toBe('[0:v]trim=start=0:end=10,setpts=PTS-STARTPTS[v0];[0:v]trim=start=12:end=18,setpts=PTS-STARTPTS[v1];[v0][v1]concat=n=2:v=1:a=0[outv]');
|
||||
expect(plan.ffmpegArgs).toEqual([
|
||||
'-i', 'input.mkv',
|
||||
'-filter_complex', plan.filterComplex,
|
||||
'-map', '[outv]',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'veryfast',
|
||||
'-crf', '20',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an',
|
||||
'-movflags', '+faststart',
|
||||
'-progress', 'pipe:1',
|
||||
'-y', 'output.mp4',
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: 'empty segment list', segments: [] },
|
||||
{ name: 'negative start', segments: [{ start: -1, end: 2 }] },
|
||||
{ name: 'zero duration', segments: [{ start: 2, end: 2 }] },
|
||||
{ name: 'reversed range', segments: [{ start: 3, end: 2 }] },
|
||||
{ name: 'non-finite start', segments: [{ start: Number.NaN, end: 2 }] },
|
||||
{ name: 'non-finite end', segments: [{ start: 1, end: Number.POSITIVE_INFINITY }] },
|
||||
{ name: 'range below export precision', segments: [{ start: 1, end: 1.0000000001 }] },
|
||||
{ name: 'overlap after sorting', segments: [{ start: 10, end: 20 }, { start: 5, end: 12 }] },
|
||||
])('rejects $name', ({ segments }) => {
|
||||
expect(() => createCutterExportPlan({
|
||||
inputFile: 'input.mp4',
|
||||
outputFile: 'output.mp4',
|
||||
segments,
|
||||
hasAudio: true,
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
test('calculates progress against the remaining segment duration and clamps it', () => {
|
||||
const plan = createCutterExportPlan({
|
||||
inputFile: 'input.mp4',
|
||||
outputFile: 'output.mp4',
|
||||
segments: [{ start: 10, end: 20 }, { start: 50, end: 70 }],
|
||||
hasAudio: true,
|
||||
});
|
||||
|
||||
expect(calculateCutterExportProgress(0, plan)).toBe(0);
|
||||
expect(calculateCutterExportProgress(15, plan)).toBe(50);
|
||||
expect(calculateCutterExportProgress(45, plan)).toBe(100);
|
||||
expect(calculateCutterExportProgress(-5, plan)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { EditorSegment } from './video-editor';
|
||||
|
||||
export interface CutterExportPlanOptions {
|
||||
inputFile: string;
|
||||
outputFile: string;
|
||||
segments: readonly EditorSegment[];
|
||||
hasAudio: boolean;
|
||||
}
|
||||
|
||||
export interface CutterExportPlan {
|
||||
segments: EditorSegment[];
|
||||
remainingDuration: number;
|
||||
filterComplex: string;
|
||||
ffmpegArgs: string[];
|
||||
}
|
||||
|
||||
const precision = 9;
|
||||
|
||||
function round(value: number): number {
|
||||
return Number(value.toFixed(precision));
|
||||
}
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
return value.toFixed(precision).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function validatePath(value: string, name: string): string {
|
||||
if (!value.trim()) throw new Error(`${name} must not be empty`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSegments(segments: readonly EditorSegment[]): EditorSegment[] {
|
||||
if (segments.length === 0) throw new Error('At least one playable segment is required');
|
||||
const normalized = segments.map((segment) => {
|
||||
if (!Number.isFinite(segment.start) || !Number.isFinite(segment.end)) {
|
||||
throw new Error('Segment boundaries must be finite');
|
||||
}
|
||||
if (segment.start < 0 || segment.end <= segment.start) {
|
||||
throw new Error('Segment boundaries must be ordered and non-negative');
|
||||
}
|
||||
const start = round(segment.start);
|
||||
const end = round(segment.end);
|
||||
if (end <= start) throw new Error('Segment duration is below export precision');
|
||||
return { start, end };
|
||||
}).sort((left, right) => left.start - right.start || left.end - right.end);
|
||||
|
||||
for (let index = 1; index < normalized.length; index += 1) {
|
||||
if (normalized[index].start < normalized[index - 1].end) {
|
||||
throw new Error('Playable segments must not overlap');
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createFilterComplex(segments: readonly EditorSegment[], hasAudio: boolean): string {
|
||||
const filters: string[] = [];
|
||||
const concatInputs: string[] = [];
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const start = formatSeconds(segment.start);
|
||||
const end = formatSeconds(segment.end);
|
||||
filters.push(`[0:v]trim=start=${start}:end=${end},setpts=PTS-STARTPTS[v${index}]`);
|
||||
concatInputs.push(`[v${index}]`);
|
||||
if (hasAudio) {
|
||||
filters.push(`[0:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS[a${index}]`);
|
||||
concatInputs.push(`[a${index}]`);
|
||||
}
|
||||
});
|
||||
|
||||
filters.push(`${concatInputs.join('')}concat=n=${segments.length}:v=1:a=${hasAudio ? 1 : 0}[outv]${hasAudio ? '[outa]' : ''}`);
|
||||
return filters.join(';');
|
||||
}
|
||||
|
||||
function createFfmpegArgs(inputFile: string, outputFile: string, filterComplex: string, hasAudio: boolean): string[] {
|
||||
const args = [
|
||||
'-i', inputFile,
|
||||
'-filter_complex', filterComplex,
|
||||
'-map', '[outv]',
|
||||
];
|
||||
if (hasAudio) args.push('-map', '[outa]');
|
||||
args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p');
|
||||
if (hasAudio) args.push('-c:a', 'aac', '-b:a', '160k');
|
||||
else args.push('-an');
|
||||
args.push('-movflags', '+faststart', '-progress', 'pipe:1', '-y', outputFile);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function createCutterExportPlan(options: CutterExportPlanOptions): CutterExportPlan {
|
||||
const inputFile = validatePath(options.inputFile, 'inputFile');
|
||||
const outputFile = validatePath(options.outputFile, 'outputFile');
|
||||
const segments = normalizeSegments(options.segments);
|
||||
const remainingDuration = round(segments.reduce((total, segment) => total + segment.end - segment.start, 0));
|
||||
const filterComplex = createFilterComplex(segments, options.hasAudio);
|
||||
return {
|
||||
segments,
|
||||
remainingDuration,
|
||||
filterComplex,
|
||||
ffmpegArgs: createFfmpegArgs(inputFile, outputFile, filterComplex, options.hasAudio),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateCutterExportProgress(processedSeconds: number, plan: Pick<CutterExportPlan, 'remainingDuration'>): number {
|
||||
if (!Number.isFinite(processedSeconds) || processedSeconds <= 0) return 0;
|
||||
if (!Number.isFinite(plan.remainingDuration) || plan.remainingDuration <= 0) {
|
||||
throw new Error('remainingDuration must be greater than zero');
|
||||
}
|
||||
return Math.min(100, round(processedSeconds / plan.remainingDuration * 100));
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
addCutAt,
|
||||
commitEditorState,
|
||||
createEditorHistory,
|
||||
createVideoEditorState,
|
||||
formatEditorTimecode,
|
||||
getPlayableSegments,
|
||||
movePreviewTimeOutOfCuts,
|
||||
parseEditorTimecode,
|
||||
redoEditorState,
|
||||
setCutRange,
|
||||
setTrimRange,
|
||||
timeToTimelinePercent,
|
||||
timelinePercentToTime,
|
||||
undoEditorState,
|
||||
} from './video-editor';
|
||||
|
||||
describe('video editor timecodes', () => {
|
||||
test('formats minute and hour timecodes with a frame field', () => {
|
||||
expect(formatEditorTimecode(66.52, 25)).toBe('01:06:13');
|
||||
expect(formatEditorTimecode(3666.52, 25)).toBe('01:01:06:13');
|
||||
});
|
||||
|
||||
test('parses minute and hour timecodes and snaps to a real frame', () => {
|
||||
expect(parseEditorTimecode('01:06:13', 25)).toBeCloseTo(66.52, 8);
|
||||
expect(parseEditorTimecode('01:01:06:13', 25)).toBeCloseTo(3666.52, 8);
|
||||
expect(parseEditorTimecode('00:02:29', 30)).toBeCloseTo(2.9666666667, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('video editor ranges', () => {
|
||||
test('keeps global trim boundaries frame-aligned and ordered', () => {
|
||||
const state = setTrimRange(createVideoEditorState(120, 25), 10.019, 90.021);
|
||||
expect(state.trimStart).toBe(10);
|
||||
expect(state.trimEnd).toBe(90.04);
|
||||
});
|
||||
|
||||
test('adds multiple cuts in timeline order without allowing overlaps', () => {
|
||||
const first = addCutAt(createVideoEditorState(120, 25), 40, 8);
|
||||
const second = addCutAt(first.state, 12, 5);
|
||||
expect(second.state.cuts.map((cut) => [cut.start, cut.end])).toEqual([[12, 17], [40, 48]]);
|
||||
|
||||
const rejected = setCutRange(second.state, first.cut.id, 15, 44);
|
||||
expect(rejected).toEqual(second.state);
|
||||
});
|
||||
|
||||
test('removes cut ranges from the export while preserving every playable segment', () => {
|
||||
let state = setTrimRange(createVideoEditorState(100, 25), 5, 95);
|
||||
state = addCutAt(state, 20, 10).state;
|
||||
state = addCutAt(state, 60, 5).state;
|
||||
expect(getPlayableSegments(state)).toEqual([
|
||||
{ start: 5, end: 20 },
|
||||
{ start: 30, end: 60 },
|
||||
{ start: 65, end: 95 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('preview playback jumps to the end of any removed range', () => {
|
||||
let state = addCutAt(createVideoEditorState(100, 25), 20, 10).state;
|
||||
state = addCutAt(state, 60, 5).state;
|
||||
expect(movePreviewTimeOutOfCuts(state, 24)).toBe(30);
|
||||
expect(movePreviewTimeOutOfCuts(state, 64.99)).toBe(65);
|
||||
expect(movePreviewTimeOutOfCuts(state, 40)).toBe(40);
|
||||
});
|
||||
|
||||
test('clips cuts to a smaller trim and removes empty remainders', () => {
|
||||
let state = addCutAt(createVideoEditorState(100, 25), 10, 15).state;
|
||||
state = addCutAt(state, 70, 20).state;
|
||||
state = setTrimRange(state, 20, 75);
|
||||
expect(state.cuts.map((cut) => [cut.start, cut.end])).toEqual([[20, 25], [70, 75]]);
|
||||
state = setTrimRange(state, 30, 60);
|
||||
expect(state.cuts).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects empty cuts while allowing adjacent cut boundaries', () => {
|
||||
const initial = createVideoEditorState(100, 25);
|
||||
expect(() => addCutAt(initial, 10, 0)).toThrow();
|
||||
let state = addCutAt(initial, 10, 5).state;
|
||||
state = addCutAt(state, 15, 5).state;
|
||||
expect(movePreviewTimeOutOfCuts(state, 10)).toBe(20);
|
||||
expect(getPlayableSegments(state)).toEqual([
|
||||
{ start: 0, end: 10 },
|
||||
{ start: 20, end: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps at least one playable frame', () => {
|
||||
const state = createVideoEditorState(10, 25);
|
||||
expect(() => addCutAt(state, 0, 10)).toThrow('playable frame');
|
||||
const cut = addCutAt(state, 1, 2);
|
||||
expect(setCutRange(cut.state, cut.cut.id, 0, 10)).toEqual(cut.state);
|
||||
expect(setTrimRange(cut.state, 1, 2)).toEqual(cut.state);
|
||||
});
|
||||
|
||||
test('accepts exactly one playable frame at repeating frame rates', () => {
|
||||
const state = createVideoEditorState(1, 30);
|
||||
const edited = addCutAt(state, 0, 29 / 30).state;
|
||||
expect(getPlayableSegments(edited)).toEqual([{ start: 0.966666667, end: 1 }]);
|
||||
});
|
||||
|
||||
test('limits an edit to 64 removed ranges', () => {
|
||||
let state = createVideoEditorState(200, 25);
|
||||
for (let index = 0; index < 64; index += 1) state = addCutAt(state, index * 2, 1).state;
|
||||
expect(state.cuts).toHaveLength(64);
|
||||
expect(() => addCutAt(state, 150, 1)).toThrow('64');
|
||||
});
|
||||
|
||||
test('converts timeline percentages and times at frame precision', () => {
|
||||
const state = createVideoEditorState(120, 25);
|
||||
expect(timeToTimelinePercent(state, 60)).toBe(50);
|
||||
expect(timelinePercentToTime(state, 50.01)).toBe(60);
|
||||
expect(timelinePercentToTime(state, 100)).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('video editor history', () => {
|
||||
test('undo and redo restore complete trim and cut states', () => {
|
||||
const initial = createVideoEditorState(100, 25);
|
||||
const trimmed = setTrimRange(initial, 5, 90);
|
||||
const cut = addCutAt(trimmed, 20, 10).state;
|
||||
let history = createEditorHistory(initial);
|
||||
history = commitEditorState(history, trimmed);
|
||||
history = commitEditorState(history, cut);
|
||||
|
||||
history = undoEditorState(history);
|
||||
expect(history.present).toEqual(trimmed);
|
||||
history = undoEditorState(history);
|
||||
expect(history.present).toEqual(initial);
|
||||
history = redoEditorState(history);
|
||||
expect(history.present).toEqual(trimmed);
|
||||
history = redoEditorState(history);
|
||||
expect(history.present).toEqual(cut);
|
||||
});
|
||||
|
||||
test('does not record no-op changes and clears redo after a new edit', () => {
|
||||
const initial = createVideoEditorState(100, 25);
|
||||
const trimmed = setTrimRange(initial, 5, 90);
|
||||
let history = commitEditorState(createEditorHistory(initial), trimmed);
|
||||
history = commitEditorState(history, trimmed);
|
||||
expect(history.past).toHaveLength(1);
|
||||
history = undoEditorState(history);
|
||||
expect(history.future).toHaveLength(1);
|
||||
history = commitEditorState(history, setTrimRange(history.present, 10, 80));
|
||||
expect(history.future).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
export interface EditorCut {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface EditorSegment {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface VideoEditorState {
|
||||
duration: number;
|
||||
fps: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
cuts: EditorCut[];
|
||||
}
|
||||
|
||||
export interface EditorHistory {
|
||||
past: VideoEditorState[];
|
||||
present: VideoEditorState;
|
||||
future: VideoEditorState[];
|
||||
}
|
||||
|
||||
const precision = 9;
|
||||
const frameTolerance = 1e-8;
|
||||
export const maxVideoEditorCuts = 64;
|
||||
|
||||
function finitePositive(value: number, name: string): number {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${name} must be greater than zero`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function rounded(value: number): number {
|
||||
return Number(value.toFixed(precision));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
function snapToFrame(value: number, fps: number): number {
|
||||
return rounded(Math.round(value * fps) / fps);
|
||||
}
|
||||
|
||||
function cloneState(state: VideoEditorState): VideoEditorState {
|
||||
return {
|
||||
duration: state.duration,
|
||||
fps: state.fps,
|
||||
trimStart: state.trimStart,
|
||||
trimEnd: state.trimEnd,
|
||||
cuts: state.cuts.map((cut) => ({ ...cut })),
|
||||
};
|
||||
}
|
||||
|
||||
function statesEqual(left: VideoEditorState, right: VideoEditorState): boolean {
|
||||
return left.duration === right.duration
|
||||
&& left.fps === right.fps
|
||||
&& left.trimStart === right.trimStart
|
||||
&& left.trimEnd === right.trimEnd
|
||||
&& left.cuts.length === right.cuts.length
|
||||
&& left.cuts.every((cut, index) => {
|
||||
const other = right.cuts[index];
|
||||
return cut.id === other.id && cut.start === other.start && cut.end === other.end;
|
||||
});
|
||||
}
|
||||
|
||||
function nextCutId(cuts: EditorCut[]): string {
|
||||
const used = new Set(cuts.map((cut) => cut.id));
|
||||
let index = cuts.length + 1;
|
||||
while (used.has(`cut-${index}`)) index += 1;
|
||||
return `cut-${index}`;
|
||||
}
|
||||
|
||||
function overlapsAnotherCut(cuts: EditorCut[], id: string, start: number, end: number): boolean {
|
||||
return cuts.some((cut) => cut.id !== id && start < cut.end && end > cut.start);
|
||||
}
|
||||
|
||||
function sortCuts(cuts: EditorCut[]): EditorCut[] {
|
||||
return [...cuts].sort((left, right) => left.start - right.start || left.end - right.end || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function hasPlayableFrame(state: VideoEditorState, cuts: EditorCut[]): boolean {
|
||||
const removedDuration = cuts.reduce((total, cut) => total + cut.end - cut.start, 0);
|
||||
return state.trimEnd - state.trimStart - removedDuration >= 1 / state.fps - frameTolerance;
|
||||
}
|
||||
|
||||
export function createVideoEditorState(duration: number, fps: number): VideoEditorState {
|
||||
const safeDuration = finitePositive(duration, 'duration');
|
||||
const safeFps = finitePositive(fps, 'fps');
|
||||
return {
|
||||
duration: rounded(safeDuration),
|
||||
fps: safeFps,
|
||||
trimStart: 0,
|
||||
trimEnd: rounded(safeDuration),
|
||||
cuts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatEditorTimecode(time: number, fps: number): string {
|
||||
const safeFps = finitePositive(fps, 'fps');
|
||||
let wholeSeconds = Math.floor(Math.max(0, time));
|
||||
let frames = Math.round((Math.max(0, time) - wholeSeconds) * safeFps);
|
||||
const frameBase = Math.max(1, Math.round(safeFps));
|
||||
if (frames >= frameBase) {
|
||||
wholeSeconds += 1;
|
||||
frames = 0;
|
||||
}
|
||||
const hours = Math.floor(wholeSeconds / 3600);
|
||||
const minutes = Math.floor((wholeSeconds % 3600) / 60);
|
||||
const seconds = wholeSeconds % 60;
|
||||
const fields = hours > 0
|
||||
? [hours, minutes, seconds, frames]
|
||||
: [minutes, seconds, frames];
|
||||
return fields.map((field) => String(field).padStart(2, '0')).join(':');
|
||||
}
|
||||
|
||||
export function parseEditorTimecode(value: string, fps: number): number {
|
||||
const safeFps = finitePositive(fps, 'fps');
|
||||
const fields = value.trim().split(':');
|
||||
if (fields.length !== 3 && fields.length !== 4) {
|
||||
throw new Error('Timecode must use MM:SS:FF or HH:MM:SS:FF');
|
||||
}
|
||||
const numbers = fields.map((field) => Number(field));
|
||||
if (numbers.some((field) => !Number.isInteger(field) || field < 0)) {
|
||||
throw new Error('Timecode fields must be non-negative integers');
|
||||
}
|
||||
const [hours, minutes, seconds, frames] = numbers.length === 4
|
||||
? numbers
|
||||
: [0, numbers[0], numbers[1], numbers[2]];
|
||||
if (minutes >= 60 || seconds >= 60 || frames >= Math.max(1, Math.round(safeFps))) {
|
||||
throw new Error('Timecode field is out of range');
|
||||
}
|
||||
return rounded(hours * 3600 + minutes * 60 + seconds + frames / safeFps);
|
||||
}
|
||||
|
||||
export function setTrimRange(state: VideoEditorState, start: number, end: number): VideoEditorState {
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return state;
|
||||
const frameDuration = 1 / state.fps;
|
||||
const nextStart = clamp(snapToFrame(start, state.fps), 0, state.duration);
|
||||
const nextEnd = clamp(snapToFrame(end, state.fps), 0, state.duration);
|
||||
if (nextEnd - nextStart < frameDuration - frameTolerance) return state;
|
||||
const cuts = state.cuts
|
||||
.map((cut) => ({
|
||||
...cut,
|
||||
start: Math.max(cut.start, nextStart),
|
||||
end: Math.min(cut.end, nextEnd),
|
||||
}))
|
||||
.filter((cut) => cut.end - cut.start >= frameDuration - frameTolerance);
|
||||
const nextState = {
|
||||
...state,
|
||||
trimStart: rounded(nextStart),
|
||||
trimEnd: rounded(nextEnd),
|
||||
cuts: sortCuts(cuts),
|
||||
};
|
||||
return hasPlayableFrame(nextState, nextState.cuts) ? nextState : state;
|
||||
}
|
||||
|
||||
export function addCutAt(state: VideoEditorState, start: number, duration: number): { state: VideoEditorState; cut: EditorCut } {
|
||||
if (state.cuts.length >= maxVideoEditorCuts) throw new Error(`Edit supports up to ${maxVideoEditorCuts} removed ranges`);
|
||||
const nextStart = clamp(snapToFrame(start, state.fps), state.trimStart, state.trimEnd);
|
||||
const nextEnd = clamp(snapToFrame(start + duration, state.fps), state.trimStart, state.trimEnd);
|
||||
const frameDuration = 1 / state.fps;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0 || nextEnd - nextStart < frameDuration - frameTolerance) {
|
||||
throw new Error('Cut must contain at least one frame');
|
||||
}
|
||||
const cut: EditorCut = { id: nextCutId(state.cuts), start: rounded(nextStart), end: rounded(nextEnd) };
|
||||
if (overlapsAnotherCut(state.cuts, cut.id, cut.start, cut.end)) {
|
||||
throw new Error('Cut overlaps another cut');
|
||||
}
|
||||
const cuts = sortCuts([...state.cuts, cut]);
|
||||
if (!hasPlayableFrame(state, cuts)) throw new Error('Edit must keep at least one playable frame');
|
||||
return {
|
||||
cut,
|
||||
state: { ...state, cuts },
|
||||
};
|
||||
}
|
||||
|
||||
export function setCutRange(state: VideoEditorState, id: string, start: number, end: number): VideoEditorState {
|
||||
const existing = state.cuts.find((cut) => cut.id === id);
|
||||
if (!existing || !Number.isFinite(start) || !Number.isFinite(end)) return state;
|
||||
const nextStart = clamp(snapToFrame(start, state.fps), state.trimStart, state.trimEnd);
|
||||
const nextEnd = clamp(snapToFrame(end, state.fps), state.trimStart, state.trimEnd);
|
||||
if (nextEnd - nextStart < 1 / state.fps - frameTolerance) return state;
|
||||
if (overlapsAnotherCut(state.cuts, id, nextStart, nextEnd)) return state;
|
||||
const cuts = sortCuts(state.cuts.map((cut) => cut.id === id
|
||||
? { ...cut, start: rounded(nextStart), end: rounded(nextEnd) }
|
||||
: cut));
|
||||
if (!hasPlayableFrame(state, cuts)) return state;
|
||||
return {
|
||||
...state,
|
||||
cuts,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeCut(state: VideoEditorState, id: string): VideoEditorState {
|
||||
if (!state.cuts.some((cut) => cut.id === id)) return state;
|
||||
return { ...state, cuts: state.cuts.filter((cut) => cut.id !== id) };
|
||||
}
|
||||
|
||||
export function getPlayableSegments(state: VideoEditorState): EditorSegment[] {
|
||||
const segments: EditorSegment[] = [];
|
||||
let cursor = state.trimStart;
|
||||
for (const cut of sortCuts(state.cuts)) {
|
||||
const start = clamp(cut.start, state.trimStart, state.trimEnd);
|
||||
const end = clamp(cut.end, state.trimStart, state.trimEnd);
|
||||
if (start > cursor) segments.push({ start: rounded(cursor), end: rounded(start) });
|
||||
cursor = Math.max(cursor, end);
|
||||
}
|
||||
if (cursor < state.trimEnd) segments.push({ start: rounded(cursor), end: rounded(state.trimEnd) });
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function getPlayableDuration(state: VideoEditorState): number {
|
||||
return rounded(getPlayableSegments(state).reduce((total, segment) => total + segment.end - segment.start, 0));
|
||||
}
|
||||
|
||||
export function movePreviewTimeOutOfCuts(state: VideoEditorState, time: number): number {
|
||||
let nextTime = clamp(time, state.trimStart, state.trimEnd);
|
||||
for (const cut of sortCuts(state.cuts)) {
|
||||
if (nextTime >= cut.start && nextTime < cut.end) nextTime = cut.end;
|
||||
}
|
||||
return rounded(clamp(nextTime, state.trimStart, state.trimEnd));
|
||||
}
|
||||
|
||||
export function timeToTimelinePercent(state: VideoEditorState, time: number): number {
|
||||
return clamp((time / state.duration) * 100, 0, 100);
|
||||
}
|
||||
|
||||
export function timelinePercentToTime(state: VideoEditorState, percent: number): number {
|
||||
return snapToFrame(clamp(percent, 0, 100) / 100 * state.duration, state.fps);
|
||||
}
|
||||
|
||||
export function createEditorHistory(initial: VideoEditorState): EditorHistory {
|
||||
return { past: [], present: cloneState(initial), future: [] };
|
||||
}
|
||||
|
||||
export function commitEditorState(history: EditorHistory, next: VideoEditorState): EditorHistory {
|
||||
if (statesEqual(history.present, next)) return history;
|
||||
return {
|
||||
past: [...history.past, cloneState(history.present)],
|
||||
present: cloneState(next),
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function undoEditorState(history: EditorHistory): EditorHistory {
|
||||
const previous = history.past.at(-1);
|
||||
if (!previous) return history;
|
||||
return {
|
||||
past: history.past.slice(0, -1),
|
||||
present: cloneState(previous),
|
||||
future: [cloneState(history.present), ...history.future],
|
||||
};
|
||||
}
|
||||
|
||||
export function redoEditorState(history: EditorHistory): EditorHistory {
|
||||
const next = history.future[0];
|
||||
if (!next) return history;
|
||||
return {
|
||||
past: [...history.past, cloneState(history.present)],
|
||||
present: cloneState(next),
|
||||
future: history.future.slice(1),
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,49 @@ interface VideoInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
hasAudio: boolean;
|
||||
videoCodec: string;
|
||||
audioCodec: string | null;
|
||||
previewCompatible: boolean;
|
||||
variableFrameRate: boolean;
|
||||
}
|
||||
|
||||
interface VideoEditorMedia {
|
||||
sourceUrl: string;
|
||||
info: VideoInfo;
|
||||
jobId: number;
|
||||
thumbnails: string[];
|
||||
waveform: string | null;
|
||||
}
|
||||
|
||||
interface VideoEditorAssets {
|
||||
jobId: number;
|
||||
thumbnails: string[];
|
||||
thumbnailSprite: string | null;
|
||||
thumbnailCount: number;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
}
|
||||
|
||||
interface VideoEditorWaveform {
|
||||
jobId: number;
|
||||
waveform: string | null;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
}
|
||||
|
||||
interface VideoEditorAssetProfile {
|
||||
timelineWidth: number;
|
||||
trackHeight: number;
|
||||
pixelRatio: number;
|
||||
}
|
||||
|
||||
interface VideoEditExportRequest {
|
||||
inputFile: string;
|
||||
outputFile?: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
cuts: Array<{ id: string; start: number; end: number }>;
|
||||
}
|
||||
|
||||
// Expose protected methods to renderer
|
||||
@@ -112,6 +155,12 @@ contextBridge.exposeInMainWorld('api', {
|
||||
// Video Cutter
|
||||
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
extractFrame: (filePath: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', filePath, timeSeconds),
|
||||
prepareVideoEditorMedia: (filePath: string): Promise<VideoEditorMedia | null> => ipcRenderer.invoke('prepare-video-editor-media', filePath),
|
||||
prepareVideoEditorWaveform: (filePath: string, jobId: number): Promise<VideoEditorWaveform | null> => ipcRenderer.invoke('prepare-video-editor-waveform', filePath, jobId),
|
||||
prepareVideoEditorAssets: (filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null> => ipcRenderer.invoke('prepare-video-editor-assets', filePath, jobId, profile),
|
||||
cancelVideoEditorAssets: (jobId: number): Promise<boolean> => ipcRenderer.invoke('cancel-video-editor-assets', jobId),
|
||||
exportVideoEdit: (request: VideoEditExportRequest): Promise<{ success: boolean; outputFile: string | null; cancelled?: boolean }> => ipcRenderer.invoke('export-video-edit', request),
|
||||
cancelVideoEdit: (): Promise<boolean> => ipcRenderer.invoke('cancel-video-edit'),
|
||||
cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> =>
|
||||
ipcRenderer.invoke('cut-video', inputFile, startTime, endTime),
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+49
@@ -165,6 +165,49 @@ interface VideoInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
hasAudio: boolean;
|
||||
videoCodec: string;
|
||||
audioCodec: string | null;
|
||||
previewCompatible: boolean;
|
||||
variableFrameRate: boolean;
|
||||
}
|
||||
|
||||
interface VideoEditorMedia {
|
||||
sourceUrl: string;
|
||||
info: VideoInfo;
|
||||
jobId: number;
|
||||
thumbnails: string[];
|
||||
waveform: string | null;
|
||||
}
|
||||
|
||||
interface VideoEditorAssets {
|
||||
jobId: number;
|
||||
thumbnails: string[];
|
||||
thumbnailSprite: string | null;
|
||||
thumbnailCount: number;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
}
|
||||
|
||||
interface VideoEditorWaveform {
|
||||
jobId: number;
|
||||
waveform: string | null;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
}
|
||||
|
||||
interface VideoEditorAssetProfile {
|
||||
timelineWidth: number;
|
||||
trackHeight: number;
|
||||
pixelRatio: number;
|
||||
}
|
||||
|
||||
interface VideoEditExportRequest {
|
||||
inputFile: string;
|
||||
outputFile?: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
cuts: Array<{ id: string; start: number; end: number }>;
|
||||
}
|
||||
|
||||
interface ClipDialogData {
|
||||
@@ -375,6 +418,12 @@ interface ApiBridge {
|
||||
onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void;
|
||||
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
prepareVideoEditorMedia(filePath: string): Promise<VideoEditorMedia | null>;
|
||||
prepareVideoEditorWaveform(filePath: string, jobId: number): Promise<VideoEditorWaveform | null>;
|
||||
prepareVideoEditorAssets(filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null>;
|
||||
cancelVideoEditorAssets(jobId: number): Promise<boolean>;
|
||||
exportVideoEdit(request: VideoEditExportRequest): Promise<{ success: boolean; outputFile: string | null; cancelled?: boolean }>;
|
||||
cancelVideoEdit(): Promise<boolean>;
|
||||
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
mergeVideos(inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
getVersion(): Promise<string>;
|
||||
|
||||
@@ -57,7 +57,7 @@ const UI_TEXT_DE = {
|
||||
commandPaletteHint: 'Auf/Ab zum Navigieren, Enter zum Ausführen, Esc zum Schließen',
|
||||
mergeTitle: 'Videos zusammenfügen',
|
||||
mergeDesc: 'Wähle mehrere Videos aus, um sie zu einem Video zusammenzufügen. Die Reihenfolge kann geändert werden.',
|
||||
mergeAdd: '+ Videos hinzufügen',
|
||||
mergeAdd: 'Videos hinzufügen',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
themeLight: 'Hell',
|
||||
@@ -499,17 +499,58 @@ const UI_TEXT_DE = {
|
||||
previewLoading: 'Lade Vorschau...',
|
||||
previewUnavailable: 'Vorschau nicht verfügbar',
|
||||
previewAlt: 'Vorschau',
|
||||
cutting: 'Schneidet...',
|
||||
cut: 'Schneiden',
|
||||
cutSuccess: 'Video erfolgreich geschnitten!',
|
||||
cutFailed: 'Fehler beim Schneiden des Videos.',
|
||||
cutting: 'Exportiert…',
|
||||
cut: 'Video exportieren',
|
||||
cutSuccess: 'Video erfolgreich exportiert!',
|
||||
cutFailed: 'Fehler beim Exportieren des Videos.',
|
||||
infoDuration: 'Dauer',
|
||||
infoResolution: 'Auflösung',
|
||||
infoFps: 'FPS',
|
||||
infoSelection: 'Auswahl',
|
||||
startLabel: 'Start:',
|
||||
endLabel: 'Ende:',
|
||||
filePathPlaceholder: 'Keine Datei ausgewählt…'
|
||||
infoSelection: 'Ausgabe',
|
||||
startLabel: 'Start',
|
||||
endLabel: 'Ende',
|
||||
filePathPlaceholder: 'Keine Datei ausgewählt…',
|
||||
editorTitle: 'Trimmen & schneiden',
|
||||
previewMode: 'Schnittvorschau',
|
||||
previewModeHint: 'Entfernte Bereiche überspringen',
|
||||
globalTrim: 'Gesamtauswahl',
|
||||
cutsLabel: 'Entfernte Bereiche',
|
||||
noCuts: 'Noch keine Schnitte',
|
||||
newCut: 'Neuer Schnitt',
|
||||
cutLabel: 'Schnitt',
|
||||
removeCut: 'Schnitt entfernen',
|
||||
videoTrack: 'VIDEO',
|
||||
audioTrack: 'AUDIO',
|
||||
noAudio: 'Keine Audiospur',
|
||||
loadingMedia: 'Video wird vorbereitet…',
|
||||
speedLabel: 'Geschwindigkeit',
|
||||
play: 'Abspielen',
|
||||
pause: 'Pausieren',
|
||||
stop: 'Stopp',
|
||||
rewind10: '10 Sekunden zurück',
|
||||
forward10: '10 Sekunden vor',
|
||||
mute: 'Stummschalten',
|
||||
unmute: 'Ton einschalten',
|
||||
volume: 'Lautstärke',
|
||||
playerSettings: 'Player-Einstellungen',
|
||||
fullscreen: 'Vollbild',
|
||||
undo: 'Rückgängig',
|
||||
redo: 'Wiederholen',
|
||||
zoomOut: 'Timeline verkleinern',
|
||||
zoomIn: 'Timeline vergrößern',
|
||||
zoom: 'Timeline-Zoom',
|
||||
trimStart: 'Startgrenze verschieben',
|
||||
trimEnd: 'Endgrenze verschieben',
|
||||
export: 'Video exportieren',
|
||||
cancel: 'Abbrechen',
|
||||
exportSuccess: 'Video wurde erfolgreich exportiert.',
|
||||
exportFailed: 'Der Videoexport ist fehlgeschlagen.',
|
||||
invalidRange: 'Der Zeitbereich ist ungültig oder überschneidet einen anderen Schnitt.',
|
||||
unsupportedFile: 'Bitte eine unterstützte Videodatei auswählen.',
|
||||
discardTitle: 'Aktuellen Schnitt verwerfen?',
|
||||
discardMessage: 'Beim Öffnen eines anderen Videos gehen die aktuellen Schnittänderungen verloren.',
|
||||
discardCancel: 'Aktuelles Video behalten',
|
||||
discardConfirm: 'Verwerfen und öffnen'
|
||||
},
|
||||
merge: {
|
||||
empty: 'Keine Videos ausgewahlt',
|
||||
|
||||
@@ -57,7 +57,7 @@ const UI_TEXT_EN = {
|
||||
commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close',
|
||||
mergeTitle: 'Merge videos',
|
||||
mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.',
|
||||
mergeAdd: '+ Add videos',
|
||||
mergeAdd: 'Add videos',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
themeLight: 'Light',
|
||||
@@ -499,17 +499,58 @@ const UI_TEXT_EN = {
|
||||
previewLoading: 'Loading preview...',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
previewAlt: 'Preview',
|
||||
cutting: 'Cutting...',
|
||||
cut: 'Cut',
|
||||
cutSuccess: 'Video cut successfully!',
|
||||
cutFailed: 'Failed to cut video.',
|
||||
cutting: 'Exporting…',
|
||||
cut: 'Export video',
|
||||
cutSuccess: 'Video exported successfully!',
|
||||
cutFailed: 'Failed to export video.',
|
||||
infoDuration: 'Duration',
|
||||
infoResolution: 'Resolution',
|
||||
infoFps: 'FPS',
|
||||
infoSelection: 'Selection',
|
||||
startLabel: 'Start:',
|
||||
endLabel: 'End:',
|
||||
filePathPlaceholder: 'No file selected…'
|
||||
infoSelection: 'Output',
|
||||
startLabel: 'Start',
|
||||
endLabel: 'End',
|
||||
filePathPlaceholder: 'No file selected…',
|
||||
editorTitle: 'Trim & cut',
|
||||
previewMode: 'Cut preview',
|
||||
previewModeHint: 'Skip removed ranges',
|
||||
globalTrim: 'Global selection',
|
||||
cutsLabel: 'Removed ranges',
|
||||
noCuts: 'No cuts yet',
|
||||
newCut: 'New cut',
|
||||
cutLabel: 'Cut',
|
||||
removeCut: 'Remove cut',
|
||||
videoTrack: 'VIDEO',
|
||||
audioTrack: 'AUDIO',
|
||||
noAudio: 'No audio track',
|
||||
loadingMedia: 'Preparing video…',
|
||||
speedLabel: 'Playback speed',
|
||||
play: 'Play',
|
||||
pause: 'Pause',
|
||||
stop: 'Stop',
|
||||
rewind10: 'Back 10 seconds',
|
||||
forward10: 'Forward 10 seconds',
|
||||
mute: 'Mute',
|
||||
unmute: 'Unmute',
|
||||
volume: 'Volume',
|
||||
playerSettings: 'Player settings',
|
||||
fullscreen: 'Fullscreen',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
zoomOut: 'Zoom timeline out',
|
||||
zoomIn: 'Zoom timeline in',
|
||||
zoom: 'Timeline zoom',
|
||||
trimStart: 'Move start boundary',
|
||||
trimEnd: 'Move end boundary',
|
||||
export: 'Export video',
|
||||
cancel: 'Cancel',
|
||||
exportSuccess: 'Video exported successfully.',
|
||||
exportFailed: 'Video export failed.',
|
||||
invalidRange: 'The time range is invalid or overlaps another cut.',
|
||||
unsupportedFile: 'Select a supported video file.',
|
||||
discardTitle: 'Discard the current edit?',
|
||||
discardMessage: 'Opening another video will discard the current editing changes.',
|
||||
discardCancel: 'Keep current video',
|
||||
discardConfirm: 'Discard and open'
|
||||
},
|
||||
merge: {
|
||||
empty: 'No videos selected',
|
||||
|
||||
@@ -70,8 +70,6 @@ let queueDragDropInitialized = false;
|
||||
|
||||
let cutterFile: string | null = null;
|
||||
let cutterVideoInfo: VideoInfo | null = null;
|
||||
let cutterStartTime = 0;
|
||||
let cutterEndTime = 0;
|
||||
let isCutting = false;
|
||||
|
||||
let mergeFiles: string[] = [];
|
||||
|
||||
@@ -485,13 +485,16 @@ function initCutterDragDrop(): void {
|
||||
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length === 0) return;
|
||||
// First video-ish file wins
|
||||
const allowed = /\.(mp4|mkv|ts|mov|avi)$/i;
|
||||
const file = files.find((f) => allowed.test(f.name)) || files[0];
|
||||
const allowed = /\.(mp4|m4v|mov|webm|mkv|ts|avi)$/i;
|
||||
const file = files.find((entry) => allowed.test(entry.name));
|
||||
if (!file) {
|
||||
showAppToast(UI_TEXT.cutter.unsupportedFile, 'warn');
|
||||
return;
|
||||
}
|
||||
const filePath = window.api.getPathForFile(file);
|
||||
if (!filePath) return;
|
||||
|
||||
const loader = (window as unknown as { loadCutterFromPath?: (p: string) => Promise<void> }).loadCutterFromPath;
|
||||
const loader = (window as unknown as { requestCutterVideoReplacement?: (p: string) => Promise<void> }).requestCutterVideoReplacement;
|
||||
if (typeof loader === 'function') {
|
||||
await loader(filePath);
|
||||
}
|
||||
|
||||
@@ -176,9 +176,42 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution);
|
||||
setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps);
|
||||
setText('cutterInfoSelectionLabel', UI_TEXT.cutter.infoSelection);
|
||||
setText('cutterDiscardTitle', UI_TEXT.cutter.discardTitle);
|
||||
setText('cutterDiscardMessage', UI_TEXT.cutter.discardMessage);
|
||||
setText('cutterDiscardCancelBtn', UI_TEXT.cutter.discardCancel);
|
||||
setText('cutterDiscardConfirmBtn', UI_TEXT.cutter.discardConfirm);
|
||||
setText('cutterStartLabel', UI_TEXT.cutter.startLabel);
|
||||
setText('cutterEndLabel', UI_TEXT.cutter.endLabel);
|
||||
setText('btnCut', UI_TEXT.cutter.cut);
|
||||
setText('cutterEditHeading', UI_TEXT.cutter.editorTitle);
|
||||
setText('cutterPreviewModeLabel', UI_TEXT.cutter.previewMode);
|
||||
setText('cutterPreviewModeHint', UI_TEXT.cutter.previewModeHint);
|
||||
setText('cutterGlobalTrimLabel', UI_TEXT.cutter.globalTrim);
|
||||
setText('cutterCutsLabel', UI_TEXT.cutter.cutsLabel);
|
||||
setText('cutterCutEmpty', UI_TEXT.cutter.noCuts);
|
||||
setText('cutterVideoTrackLabel', UI_TEXT.cutter.videoTrack);
|
||||
setText('cutterAudioTrackLabel', UI_TEXT.cutter.audioTrack);
|
||||
setText('cutterAudioEmpty', UI_TEXT.cutter.noAudio);
|
||||
setText('cutterLoadingLabel', UI_TEXT.cutter.loadingMedia);
|
||||
setText('cutterSpeedLabel', UI_TEXT.cutter.speedLabel);
|
||||
setAriaLabel('cutterPlayBtn', UI_TEXT.cutter.play);
|
||||
setAriaLabel('cutterStopBtn', UI_TEXT.cutter.stop);
|
||||
setAriaLabel('cutterRewindBtn', UI_TEXT.cutter.rewind10);
|
||||
setAriaLabel('cutterForwardBtn', UI_TEXT.cutter.forward10);
|
||||
setAriaLabel('cutterMuteBtn', UI_TEXT.cutter.mute);
|
||||
setAriaLabel('cutterVolume', UI_TEXT.cutter.volume);
|
||||
setAriaLabel('cutterSettingsBtn', UI_TEXT.cutter.playerSettings);
|
||||
setAriaLabel('cutterFullscreenBtn', UI_TEXT.cutter.fullscreen);
|
||||
setAriaLabel('cutterUndoBtn', UI_TEXT.cutter.undo);
|
||||
setAriaLabel('cutterRedoBtn', UI_TEXT.cutter.redo);
|
||||
setAriaLabel('cutterZoomOutBtn', UI_TEXT.cutter.zoomOut);
|
||||
setAriaLabel('cutterZoomInBtn', UI_TEXT.cutter.zoomIn);
|
||||
setAriaLabel('cutterZoom', UI_TEXT.cutter.zoom);
|
||||
setAriaLabel('cutterTrimStartHandle', UI_TEXT.cutter.trimStart);
|
||||
setAriaLabel('cutterTrimEndHandle', UI_TEXT.cutter.trimEnd);
|
||||
setText('cutterCancelExportBtn', UI_TEXT.cutter.cancel);
|
||||
setAriaLabel('cutterNewCutBtn', UI_TEXT.cutter.newCut);
|
||||
setTitle('cutterNewCutBtn', UI_TEXT.cutter.newCut);
|
||||
setText('mergeTitle', UI_TEXT.static.mergeTitle);
|
||||
setText('mergeDesc', UI_TEXT.static.mergeDesc);
|
||||
setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
|
||||
@@ -413,6 +446,9 @@ function applyLanguageToStaticUI(): void {
|
||||
if (typeof workspaceSync === 'function') {
|
||||
workspaceSync(activeTabId.replace(/Tab$/, ''));
|
||||
}
|
||||
if (typeof updateCutterPlayUi === 'function') updateCutterPlayUi();
|
||||
if (typeof updateCutterMuteUi === 'function') updateCutterMuteUi();
|
||||
if (typeof renderCutterEditor === 'function') renderCutterEditor();
|
||||
}
|
||||
|
||||
function localizeCurrentStatusText(current: string): string {
|
||||
|
||||
+9
-140
@@ -75,6 +75,7 @@ async function init(): Promise<void> {
|
||||
loadVodScrollPositions();
|
||||
initVodScrollTracking();
|
||||
initCutterDragDrop();
|
||||
initCutterEditor();
|
||||
|
||||
// Restore last active tab from previous session (default 'vods')
|
||||
initTopNavActiveIndicator();
|
||||
@@ -531,6 +532,12 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
|
||||
|
||||
function closeTopmostOpenModal(): boolean {
|
||||
// Try each known modal in priority order
|
||||
const cutterDiscardModal = document.getElementById('cutterDiscardModal');
|
||||
if (cutterDiscardModal?.classList.contains('show')) {
|
||||
resolveCutterDiscard(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
const commandPaletteModal = document.getElementById('commandPaletteModal');
|
||||
if (commandPaletteModal?.classList.contains('show')) {
|
||||
const closeCp = (window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette;
|
||||
@@ -974,6 +981,7 @@ function focusWorkspaceTarget(id: string, source?: HTMLElement): void {
|
||||
}
|
||||
|
||||
function showTab(tab: string): void {
|
||||
if (tab !== 'cutter' && byId('cutterTab').classList.contains('active') && typeof deactivateCutterEditor === 'function') deactivateCutterEditor();
|
||||
queryAll('.nav-item').forEach((i) => {
|
||||
i.classList.remove('active');
|
||||
i.removeAttribute('aria-current');
|
||||
@@ -990,6 +998,7 @@ function showTab(tab: string): void {
|
||||
navItem.setAttribute('aria-current', 'page');
|
||||
syncTopNavActiveIndicator();
|
||||
byId(tab + 'Tab').classList.add('active');
|
||||
if (tab === 'cutter' && typeof activateCutterEditor === 'function') activateCutterEditor();
|
||||
syncWorkspaceChrome(tab);
|
||||
scheduleSegmentedIndicatorsSync();
|
||||
|
||||
@@ -1671,146 +1680,6 @@ function initSegmentedIndicators(): void {
|
||||
window.addEventListener('resize', scheduleSegmentedIndicatorsSync);
|
||||
}
|
||||
|
||||
async function loadCutterFromPath(filePath: string): Promise<void> {
|
||||
if (!filePath) return;
|
||||
|
||||
cutterFile = filePath;
|
||||
byId<HTMLInputElement>('cutterFilePath').value = filePath;
|
||||
|
||||
const info = await window.api.getVideoInfo(filePath);
|
||||
if (!info) {
|
||||
alert(UI_TEXT.cutter.videoInfoFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
cutterVideoInfo = info;
|
||||
cutterStartTime = 0;
|
||||
cutterEndTime = info.duration;
|
||||
|
||||
byId('cutterInfo').classList.add('shown');
|
||||
byId('timelineContainer').classList.add('shown');
|
||||
byId('btnCut').disabled = false;
|
||||
|
||||
byId('infoDuration').textContent = formatTime(info.duration);
|
||||
byId('infoResolution').textContent = `${info.width}x${info.height}`;
|
||||
byId('infoFps').textContent = Math.round(info.fps);
|
||||
byId('infoSelection').textContent = formatTime(info.duration);
|
||||
|
||||
byId<HTMLInputElement>('startTime').value = '00:00:00';
|
||||
byId<HTMLInputElement>('endTime').value = formatTime(info.duration);
|
||||
|
||||
updateTimeline();
|
||||
await updatePreview(0);
|
||||
}
|
||||
|
||||
async function selectCutterVideo(): Promise<void> {
|
||||
const filePath = await window.api.selectVideoFile();
|
||||
if (!filePath) return;
|
||||
await loadCutterFromPath(filePath);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function parseTime(timeStr: string): number {
|
||||
const parts = timeStr.split(':').map((p: string) => parseInt(p, 10) || 0);
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function updateTimeline(): void {
|
||||
if (!cutterVideoInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = byId('timelineSelection');
|
||||
const startPercent = (cutterStartTime / cutterVideoInfo.duration) * 100;
|
||||
const endPercent = (cutterEndTime / cutterVideoInfo.duration) * 100;
|
||||
|
||||
selection.style.left = startPercent + '%';
|
||||
selection.style.width = (endPercent - startPercent) + '%';
|
||||
|
||||
const duration = cutterEndTime - cutterStartTime;
|
||||
byId('infoSelection').textContent = formatTime(duration);
|
||||
}
|
||||
|
||||
function updateTimeFromInput(): void {
|
||||
const startStr = byId<HTMLInputElement>('startTime').value;
|
||||
const endStr = byId<HTMLInputElement>('endTime').value;
|
||||
|
||||
cutterStartTime = Math.max(0, parseTime(startStr));
|
||||
cutterEndTime = Math.min(cutterVideoInfo?.duration || 0, parseTime(endStr));
|
||||
|
||||
if (cutterEndTime <= cutterStartTime) {
|
||||
cutterEndTime = cutterStartTime + 1;
|
||||
}
|
||||
|
||||
updateTimeline();
|
||||
}
|
||||
|
||||
async function seekTimeline(event: MouseEvent): Promise<void> {
|
||||
if (!cutterVideoInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeline = byId<HTMLElement>('timeline');
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const percent = (event.clientX - rect.left) / rect.width;
|
||||
const time = percent * cutterVideoInfo.duration;
|
||||
|
||||
byId('timelineCurrent').style.left = (percent * 100) + '%';
|
||||
await updatePreview(time);
|
||||
}
|
||||
|
||||
async function updatePreview(time: number): Promise<void> {
|
||||
if (!cutterFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = byId('cutterPreview');
|
||||
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`);
|
||||
|
||||
const frame = await window.api.extractFrame(cutterFile, time);
|
||||
if (frame) {
|
||||
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
|
||||
return;
|
||||
}
|
||||
|
||||
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewUnavailable)}</p></div>`);
|
||||
}
|
||||
|
||||
async function startCutting(): Promise<void> {
|
||||
if (!cutterFile || isCutting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCutting = true;
|
||||
byId('btnCut').disabled = true;
|
||||
byId('btnCut').textContent = UI_TEXT.cutter.cutting;
|
||||
byId('cutProgress').classList.add('show');
|
||||
|
||||
const result = await window.api.cutVideo(cutterFile, cutterStartTime, cutterEndTime);
|
||||
|
||||
isCutting = false;
|
||||
byId('btnCut').disabled = false;
|
||||
byId('btnCut').textContent = UI_TEXT.cutter.cut;
|
||||
byId('cutProgress').classList.remove('show');
|
||||
|
||||
if (result.success) {
|
||||
alert(`${UI_TEXT.cutter.cutSuccess}\n\n${result.outputFile}`);
|
||||
return;
|
||||
}
|
||||
|
||||
alert(UI_TEXT.cutter.cutFailed);
|
||||
}
|
||||
|
||||
async function addMergeFiles(): Promise<void> {
|
||||
const files = await window.api.selectMultipleVideos();
|
||||
if (!files || files.length === 0) {
|
||||
|
||||
+1050
-96
File diff suppressed because it is too large
Load Diff
+68
-3
@@ -190,6 +190,16 @@ a:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input.cutter-volume:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cutter-volume-control:has(.cutter-volume:focus-visible) .cutter-player-button {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
box-shadow: inset 0 0 0 2px var(--workspace-primary);
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
@@ -1817,11 +1827,14 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
|
||||
}
|
||||
|
||||
.clip-input,
|
||||
.cutter-container,
|
||||
.merge-container {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.cutter-container {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.clip-input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -2168,6 +2181,44 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.cutter-discard-modal {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.cutter-discard-modal h2 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.cutter-discard-modal p {
|
||||
margin: 0;
|
||||
color: var(--workspace-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#cutterDiscardCancelBtn {
|
||||
color: #000;
|
||||
background: var(--workspace-primary);
|
||||
border-color: var(--workspace-primary);
|
||||
}
|
||||
|
||||
#cutterDiscardCancelBtn:hover:not(:disabled) {
|
||||
color: #000;
|
||||
background: var(--workspace-primary-hover);
|
||||
border-color: var(--workspace-primary-hover);
|
||||
}
|
||||
|
||||
#cutterDiscardConfirmBtn {
|
||||
color: #000;
|
||||
background: var(--workspace-danger);
|
||||
border-color: var(--workspace-danger);
|
||||
}
|
||||
|
||||
#cutterDiscardConfirmBtn:hover:not(:disabled) {
|
||||
color: #000;
|
||||
background: #f38d8d;
|
||||
border-color: #f38d8d;
|
||||
}
|
||||
|
||||
.update-modal {
|
||||
background: var(--workspace-panel-raised);
|
||||
border-color: var(--workspace-border-strong);
|
||||
@@ -2586,6 +2637,15 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
|
||||
.cutter-volume {
|
||||
transition: width 220ms cubic-bezier(0.2, 0.75, 0.25, 1), margin 220ms cubic-bezier(0.2, 0.75, 0.25, 1), opacity 150ms ease, visibility 0s linear 220ms !important;
|
||||
}
|
||||
|
||||
.cutter-volume-control:hover .cutter-volume,
|
||||
.cutter-volume-control:focus-within .cutter-volume {
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
|
||||
.top-nav::before {
|
||||
transition: transform 420ms cubic-bezier(0.22, 0.76, 0.22, 1), width 420ms cubic-bezier(0.22, 0.76, 0.22, 1), height 420ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
|
||||
}
|
||||
@@ -2951,8 +3011,9 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
|
||||
}
|
||||
|
||||
#settingsTab .language-picker .lang-option > span:not(.flag-icon) {
|
||||
color: #fff;
|
||||
mix-blend-mode: difference;
|
||||
color: var(--workspace-text-muted);
|
||||
mix-blend-mode: normal;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
#settingsTab .language-picker .lang-option:hover {
|
||||
@@ -2966,6 +3027,10 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#settingsTab .language-picker .lang-option.active > span:not(.flag-icon) {
|
||||
color: var(--workspace-primary-text);
|
||||
}
|
||||
|
||||
#settingsTab .language-picker .lang-option.active:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user