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 {
|
|
getMotionTrackingEngine,
|
|
type Rectangle,
|
|
type TrackingOptions,
|
|
type TrackingJob,
|
|
type TrackingData,
|
|
type Point,
|
|
} from "@openreel/core";
|
|
|
|
export interface MotionTrackingState {
|
|
isTracking: boolean;
|
|
progress: number;
|
|
currentJob: TrackingJob | null;
|
|
trackingData: TrackingData | null;
|
|
lostFrames: number[];
|
|
error: string | null;
|
|
}
|
|
|
|
export type MotionTrackingStateListener = (state: MotionTrackingState) => void;
|
|
|
|
class MotionTrackingBridge {
|
|
private engine = getMotionTrackingEngine();
|
|
private currentState: MotionTrackingState = {
|
|
isTracking: false,
|
|
progress: 0,
|
|
currentJob: null,
|
|
trackingData: null,
|
|
lostFrames: [],
|
|
error: null,
|
|
};
|
|
private listeners: Set<MotionTrackingStateListener> = new Set();
|
|
private clipTrackingMap: Map<string, string> = new Map();
|
|
private unsubscribeProgress: (() => void) | null = null;
|
|
private unsubscribeLost: (() => void) | null = null;
|
|
|
|
constructor() {
|
|
this.unsubscribeProgress = this.engine.onTrackingProgress(
|
|
this.handleProgress,
|
|
);
|
|
this.unsubscribeLost = this.engine.onTrackingLost(this.handleTrackingLost);
|
|
}
|
|
|
|
private handleProgress = (progress: number): void => {
|
|
this.updateState({ progress });
|
|
};
|
|
|
|
private handleTrackingLost = (frameIndex: number): void => {
|
|
const lostFrames = [...this.currentState.lostFrames, frameIndex];
|
|
this.updateState({ lostFrames });
|
|
};
|
|
|
|
private updateState(partial: Partial<MotionTrackingState>): void {
|
|
this.currentState = { ...this.currentState, ...partial };
|
|
this.notifyListeners();
|
|
}
|
|
|
|
private notifyListeners(): void {
|
|
for (const listener of this.listeners) {
|
|
listener(this.currentState);
|
|
}
|
|
}
|
|
|
|
subscribe(listener: MotionTrackingStateListener): () => void {
|
|
this.listeners.add(listener);
|
|
listener(this.currentState);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
getState(): MotionTrackingState {
|
|
return this.currentState;
|
|
}
|
|
|
|
async startTracking(
|
|
clipId: string,
|
|
region: Rectangle,
|
|
options: TrackingOptions = {},
|
|
): Promise<TrackingJob> {
|
|
this.updateState({
|
|
isTracking: true,
|
|
progress: 0,
|
|
lostFrames: [],
|
|
error: null,
|
|
trackingData: null,
|
|
});
|
|
|
|
try {
|
|
const job = await this.engine.startTracking(clipId, region, options);
|
|
this.updateState({ currentJob: job });
|
|
|
|
const pollInterval = setInterval(() => {
|
|
const currentJob = this.engine.getTrackingJob(job.id);
|
|
if (!currentJob) {
|
|
clearInterval(pollInterval);
|
|
return;
|
|
}
|
|
|
|
this.updateState({ currentJob, progress: currentJob.progress });
|
|
|
|
if (
|
|
currentJob.status === "completed" ||
|
|
currentJob.status === "failed" ||
|
|
currentJob.status === "cancelled"
|
|
) {
|
|
clearInterval(pollInterval);
|
|
|
|
if (currentJob.status === "completed") {
|
|
const allTrackingData = this.engine.getTrackingDataForClip(clipId);
|
|
const latestData =
|
|
allTrackingData.length > 0
|
|
? allTrackingData[allTrackingData.length - 1]
|
|
: null;
|
|
|
|
if (latestData) {
|
|
this.clipTrackingMap.set(clipId, latestData.trackId);
|
|
}
|
|
|
|
this.updateState({
|
|
isTracking: false,
|
|
trackingData: latestData,
|
|
currentJob,
|
|
});
|
|
} else if (currentJob.status === "failed") {
|
|
this.updateState({
|
|
isTracking: false,
|
|
error: currentJob.error || "Tracking failed",
|
|
currentJob,
|
|
});
|
|
} else {
|
|
this.updateState({
|
|
isTracking: false,
|
|
currentJob,
|
|
});
|
|
}
|
|
}
|
|
}, 100);
|
|
|
|
return job;
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Unknown error";
|
|
this.updateState({
|
|
isTracking: false,
|
|
error: errorMessage,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
cancelTracking(jobId: string): void {
|
|
this.engine.cancelTracking(jobId);
|
|
this.updateState({
|
|
isTracking: false,
|
|
progress: 0,
|
|
});
|
|
}
|
|
|
|
applyTrackingToElement(
|
|
trackId: string,
|
|
elementId: string,
|
|
offset: Point = { x: 0, y: 0 },
|
|
): void {
|
|
this.engine.applyTrackingToElement(trackId, elementId, offset);
|
|
}
|
|
|
|
applyTrackingToClip(clipId: string, offset: Point = { x: 0, y: 0 }): boolean {
|
|
const trackId = this.clipTrackingMap.get(clipId);
|
|
if (!trackId) {
|
|
console.warn(`No tracking data found for clip ${clipId}`);
|
|
return false;
|
|
}
|
|
try {
|
|
this.applyTrackingToElement(trackId, clipId, offset);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
setTrackingOffset(elementId: string, offset: Point): void {
|
|
this.engine.setTrackingOffset(elementId, offset);
|
|
}
|
|
|
|
getTrackingOffset(elementId: string): Point | null {
|
|
return this.engine.getTrackingOffset(elementId);
|
|
}
|
|
|
|
setApplyScale(elementId: string, applyScale: boolean): void {
|
|
this.engine.setApplyScale(elementId, applyScale);
|
|
}
|
|
|
|
setApplyRotation(elementId: string, applyRotation: boolean): void {
|
|
this.engine.setApplyRotation(elementId, applyRotation);
|
|
}
|
|
|
|
getElementPositionAtTime(
|
|
elementId: string,
|
|
timeInSeconds: number,
|
|
): Point | null {
|
|
return this.engine.getElementPositionAtTime(elementId, timeInSeconds);
|
|
}
|
|
|
|
correctTrackingPoint(
|
|
trackId: string,
|
|
frameIndex: number,
|
|
position: Point,
|
|
): void {
|
|
this.engine.correctTrackingPoint(trackId, frameIndex, position);
|
|
}
|
|
|
|
getTrackingDataForClip(clipId: string): TrackingData[] {
|
|
return this.engine.getTrackingDataForClip(clipId);
|
|
}
|
|
|
|
getTrackingData(clipId: string, trackId: string): TrackingData | undefined {
|
|
return this.engine.getTrackingData(clipId, trackId);
|
|
}
|
|
|
|
removeAttachment(elementId: string): void {
|
|
this.engine.removeTrackingFromElement(elementId);
|
|
}
|
|
|
|
hasTrackingData(clipId: string): boolean {
|
|
return this.clipTrackingMap.has(clipId);
|
|
}
|
|
|
|
getClipTrackId(clipId: string): string | null {
|
|
return this.clipTrackingMap.get(clipId) || null;
|
|
}
|
|
|
|
reset(): void {
|
|
this.updateState({
|
|
isTracking: false,
|
|
progress: 0,
|
|
currentJob: null,
|
|
trackingData: null,
|
|
lostFrames: [],
|
|
error: null,
|
|
});
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this.unsubscribeProgress) {
|
|
this.unsubscribeProgress();
|
|
}
|
|
if (this.unsubscribeLost) {
|
|
this.unsubscribeLost();
|
|
}
|
|
this.listeners.clear();
|
|
}
|
|
}
|
|
|
|
let motionTrackingBridgeInstance: MotionTrackingBridge | null = null;
|
|
|
|
export function getMotionTrackingBridge(): MotionTrackingBridge {
|
|
if (!motionTrackingBridgeInstance) {
|
|
motionTrackingBridgeInstance = new MotionTrackingBridge();
|
|
}
|
|
return motionTrackingBridgeInstance;
|
|
}
|
|
|
|
export function resetMotionTrackingBridge(): void {
|
|
if (motionTrackingBridgeInstance) {
|
|
motionTrackingBridgeInstance.reset();
|
|
}
|
|
}
|