Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
265 lines
6.8 KiB
TypeScript
265 lines
6.8 KiB
TypeScript
import { getAudioEngine } from "@openreel/core";
|
|
import { useProjectStore } from "../stores/project-store";
|
|
|
|
export interface SilenceSettings {
|
|
threshold: number;
|
|
minSilenceDuration: number;
|
|
paddingBefore: number;
|
|
paddingAfter: number;
|
|
}
|
|
|
|
export interface SilentRegion {
|
|
start: number;
|
|
end: number;
|
|
duration: number;
|
|
}
|
|
|
|
export interface SilenceAnalysisResult {
|
|
silentRegions: SilentRegion[];
|
|
totalSilenceDuration: number;
|
|
clipDuration: number;
|
|
}
|
|
|
|
export const DEFAULT_SILENCE_SETTINGS: SilenceSettings = {
|
|
threshold: -40,
|
|
minSilenceDuration: 0.5,
|
|
paddingBefore: 0.1,
|
|
paddingAfter: 0.1,
|
|
};
|
|
|
|
export type SilenceProgressCallback = (
|
|
progress: number,
|
|
message: string,
|
|
) => void;
|
|
|
|
export class SilenceCutBridge {
|
|
private audioContext: AudioContext | null = null;
|
|
|
|
private getAudioContext(): AudioContext {
|
|
if (!this.audioContext) {
|
|
this.audioContext = new AudioContext();
|
|
}
|
|
return this.audioContext;
|
|
}
|
|
|
|
async analyzeClip(
|
|
clipId: string,
|
|
settings: SilenceSettings,
|
|
onProgress?: SilenceProgressCallback,
|
|
): Promise<SilenceAnalysisResult> {
|
|
const store = useProjectStore.getState();
|
|
const clip = store.getClip(clipId);
|
|
|
|
if (!clip) {
|
|
throw new Error("Clip not found");
|
|
}
|
|
|
|
const mediaItem = store.getMediaItem(clip.mediaId);
|
|
if (!mediaItem?.blob) {
|
|
throw new Error("Media blob not found");
|
|
}
|
|
|
|
onProgress?.(10, "Loading audio...");
|
|
|
|
const audioContext = this.getAudioContext();
|
|
const arrayBuffer = await mediaItem.blob.arrayBuffer();
|
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
|
|
onProgress?.(30, "Detecting silence...");
|
|
|
|
const audioEngine = getAudioEngine();
|
|
const rawSilentRanges = audioEngine.detectSilence(
|
|
audioBuffer,
|
|
settings.threshold,
|
|
);
|
|
|
|
onProgress?.(60, "Processing results...");
|
|
|
|
const inPoint = clip.inPoint ?? 0;
|
|
const outPoint = clip.outPoint ?? audioBuffer.duration;
|
|
const clipDuration = outPoint - inPoint;
|
|
|
|
const filteredRegions = rawSilentRanges
|
|
.map((range) => {
|
|
const relativeStart = range.start - inPoint;
|
|
const relativeEnd = range.end - inPoint;
|
|
|
|
const adjustedStart = Math.max(
|
|
0,
|
|
relativeStart + settings.paddingBefore,
|
|
);
|
|
const adjustedEnd = Math.min(
|
|
clipDuration,
|
|
relativeEnd - settings.paddingAfter,
|
|
);
|
|
|
|
return {
|
|
start: adjustedStart,
|
|
end: adjustedEnd,
|
|
duration: adjustedEnd - adjustedStart,
|
|
};
|
|
})
|
|
.filter(
|
|
(region) =>
|
|
region.duration >= settings.minSilenceDuration &&
|
|
region.start >= 0 &&
|
|
region.end <= clipDuration &&
|
|
region.start < region.end,
|
|
);
|
|
|
|
onProgress?.(90, "Finalizing...");
|
|
|
|
const totalSilenceDuration = filteredRegions.reduce(
|
|
(sum, region) => sum + region.duration,
|
|
0,
|
|
);
|
|
|
|
onProgress?.(100, "Complete");
|
|
|
|
return {
|
|
silentRegions: filteredRegions,
|
|
totalSilenceDuration,
|
|
clipDuration,
|
|
};
|
|
}
|
|
|
|
async cutSilence(
|
|
clipId: string,
|
|
silentRegions: SilentRegion[],
|
|
onProgress?: SilenceProgressCallback,
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
if (silentRegions.length === 0) {
|
|
return { success: true };
|
|
}
|
|
|
|
const store = useProjectStore.getState();
|
|
const initialClip = store.getClip(clipId);
|
|
|
|
if (!initialClip) {
|
|
return { success: false, error: "Clip not found" };
|
|
}
|
|
|
|
const clipStartTime = initialClip.startTime;
|
|
const sortedRegions = [...silentRegions].sort((a, b) => b.start - a.start);
|
|
|
|
onProgress?.(0, "Preparing cuts...");
|
|
|
|
const actionHistory = store.actionHistory;
|
|
actionHistory.beginGroup("Cut silence");
|
|
|
|
try {
|
|
for (let i = 0; i < sortedRegions.length; i++) {
|
|
const region = sortedRegions[i];
|
|
const progress = Math.round(((i + 1) / sortedRegions.length) * 100);
|
|
onProgress?.(progress, `Cutting section ${i + 1}/${sortedRegions.length}`);
|
|
|
|
const absoluteStart = clipStartTime + region.start;
|
|
const absoluteEnd = clipStartTime + region.end;
|
|
|
|
const currentClip = this.findClipContainingTime(absoluteStart);
|
|
if (!currentClip) {
|
|
continue;
|
|
}
|
|
|
|
const clipRelativeEnd = absoluteEnd - currentClip.startTime;
|
|
|
|
if (clipRelativeEnd < currentClip.duration) {
|
|
const splitResult = await store.splitClip(
|
|
currentClip.id,
|
|
clipRelativeEnd,
|
|
);
|
|
if (!splitResult.success) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const clipAfterFirstSplit = this.findClipContainingTime(absoluteStart);
|
|
if (!clipAfterFirstSplit) {
|
|
continue;
|
|
}
|
|
|
|
const newRelativeStart = absoluteStart - clipAfterFirstSplit.startTime;
|
|
|
|
if (newRelativeStart > 0) {
|
|
const splitResult = await store.splitClip(
|
|
clipAfterFirstSplit.id,
|
|
newRelativeStart,
|
|
);
|
|
if (!splitResult.success) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const silentClip = this.findClipInTimeRange(
|
|
absoluteStart,
|
|
absoluteEnd,
|
|
);
|
|
if (silentClip) {
|
|
await store.rippleDeleteClip(silentClip.id);
|
|
}
|
|
}
|
|
|
|
onProgress?.(100, "Complete");
|
|
return { success: true };
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Unknown error occurred";
|
|
return { success: false, error: message };
|
|
} finally {
|
|
actionHistory.endGroup();
|
|
}
|
|
}
|
|
|
|
private findClipContainingTime(time: number) {
|
|
const store = useProjectStore.getState();
|
|
const { project } = store;
|
|
|
|
for (const track of project.timeline.tracks) {
|
|
for (const clip of track.clips) {
|
|
const clipEnd = clip.startTime + clip.duration;
|
|
if (time >= clip.startTime && time < clipEnd) {
|
|
return clip;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private findClipInTimeRange(start: number, end: number) {
|
|
const store = useProjectStore.getState();
|
|
const { project } = store;
|
|
|
|
for (const track of project.timeline.tracks) {
|
|
for (const clip of track.clips) {
|
|
const clipMidpoint = clip.startTime + clip.duration / 2;
|
|
if (clipMidpoint >= start && clipMidpoint < end) {
|
|
return clip;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this.audioContext) {
|
|
this.audioContext.close();
|
|
this.audioContext = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
let bridgeInstance: SilenceCutBridge | null = null;
|
|
|
|
export function getSilenceCutBridge(): SilenceCutBridge {
|
|
if (!bridgeInstance) {
|
|
bridgeInstance = new SilenceCutBridge();
|
|
}
|
|
return bridgeInstance;
|
|
}
|
|
|
|
export function disposeSilenceCutBridge(): void {
|
|
if (bridgeInstance) {
|
|
bridgeInstance.dispose();
|
|
bridgeInstance = null;
|
|
}
|
|
}
|