Skip to main content
Glama

Remotion Video Editing MCP Server

by smilish67
timelineUtils.ts14 kB
// 타임라인 편집 유틸리티 import { random } from 'remotion'; import { MediaItem } from '../Composition'; // 트랙 타입 export interface Track { id: string; name: string; type: 'video' | 'audio' | 'subtitle'; items: MediaItem[]; isLocked: boolean; isVisible: boolean; volume?: number; } // 편집 작업 파라미터 타입 interface EditOperationParameters { action?: string; item?: MediaItem; oldStartFrame?: number; newStartFrame?: number; oldTrackId?: string; newTrackId?: string; oldDuration?: number; newDuration?: number; trimStart?: number; trimEnd?: number; splitFrame?: number; secondPartId?: string; } // 편집 작업 타입 export interface EditOperation { id: string; type: 'cut' | 'trim' | 'move' | 'copy' | 'delete' | 'split'; trackId: string; itemId: string; timestamp: number; parameters: EditOperationParameters; } // 키프레임 타입 export interface Keyframe { frame: number; property: string; value: number | string; easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'; } // 타임라인 내보내기 데이터 타입 interface TimelineExportData { tracks: Track[]; currentFrame: number; totalDuration: number; fps: number; } // 타임라인 관리 클래스 export class TimelineManager { private tracks: Map<string, Track> = new Map(); private currentFrame: number = 0; private totalDuration: number = 0; private fps: number = 30; private undoStack: EditOperation[] = []; private redoStack: EditOperation[] = []; constructor(fps: number = 30) { this.fps = fps; } // 트랙 생성 createTrack(name: string, type: 'video' | 'audio' | 'subtitle'): Track { const track: Track = { id: this.generateId(), name, type, items: [], isLocked: false, isVisible: true, volume: type === 'audio' ? 1 : undefined }; this.tracks.set(track.id, track); return track; } // 트랙 삭제 deleteTrack(trackId: string): boolean { return this.tracks.delete(trackId); } // 트랙 목록 조회 getTracks(): Track[] { return Array.from(this.tracks.values()); } // 특정 트랙 조회 getTrack(trackId: string): Track | undefined { return this.tracks.get(trackId); } // 미디어 아이템을 트랙에 추가 addItemToTrack(trackId: string, item: MediaItem, insertFrame?: number): boolean { const track = this.tracks.get(trackId); if (!track || track.isLocked) return false; // 삽입 위치 계산 if (insertFrame !== undefined) { item.startFrame = insertFrame; } // 겹침 검사 및 해결 this.resolveOverlaps(track, item); track.items.push(item); this.updateTotalDuration(); // 실행 취소를 위한 작업 기록 this.recordOperation({ id: this.generateId(), type: 'move', trackId, itemId: item.id, timestamp: Date.now(), parameters: { action: 'add', item } }); return true; } // 미디어 아이템 삭제 removeItemFromTrack(trackId: string, itemId: string): boolean { const track = this.tracks.get(trackId); if (!track || track.isLocked) return false; const itemIndex = track.items.findIndex(item => item.id === itemId); if (itemIndex === -1) return false; const removedItem = track.items.splice(itemIndex, 1)[0]; this.updateTotalDuration(); // 실행 취소를 위한 작업 기록 this.recordOperation({ id: this.generateId(), type: 'delete', trackId, itemId, timestamp: Date.now(), parameters: { action: 'remove', item: removedItem } }); return true; } // 아이템 이동 moveItem(trackId: string, itemId: string, newStartFrame: number, newTrackId?: string): boolean { const sourceTrack = this.tracks.get(trackId); if (!sourceTrack || sourceTrack.isLocked) return false; const itemIndex = sourceTrack.items.findIndex(item => item.id === itemId); if (itemIndex === -1) return false; const item = sourceTrack.items[itemIndex]; const oldStartFrame = item.startFrame; // 같은 트랙 내에서 이동 if (!newTrackId || newTrackId === trackId) { item.startFrame = newStartFrame; this.resolveOverlaps(sourceTrack, item); } else { // 다른 트랙으로 이동 const targetTrack = this.tracks.get(newTrackId); if (!targetTrack || targetTrack.isLocked) return false; sourceTrack.items.splice(itemIndex, 1); item.startFrame = newStartFrame; this.resolveOverlaps(targetTrack, item); targetTrack.items.push(item); } this.updateTotalDuration(); // 실행 취소를 위한 작업 기록 this.recordOperation({ id: this.generateId(), type: 'move', trackId, itemId, timestamp: Date.now(), parameters: { oldStartFrame, newStartFrame, oldTrackId: trackId, newTrackId: newTrackId || trackId } }); return true; } // 아이템 트림 (시작/끝 시간 조정) trimItem(trackId: string, itemId: string, trimStart?: number, trimEnd?: number): boolean { const track = this.tracks.get(trackId); if (!track || track.isLocked) return false; const item = track.items.find(item => item.id === itemId); if (!item) return false; const oldDuration = item.durationInFrames; if (trimStart !== undefined) { const trimAmount = trimStart - item.startFrame; item.startFrame = trimStart; item.durationInFrames = Math.max(1, item.durationInFrames - trimAmount); } if (trimEnd !== undefined) { const newDuration = trimEnd - item.startFrame; item.durationInFrames = Math.max(1, newDuration); } this.updateTotalDuration(); // 실행 취소를 위한 작업 기록 this.recordOperation({ id: this.generateId(), type: 'trim', trackId, itemId, timestamp: Date.now(), parameters: { oldDuration, newDuration: item.durationInFrames, trimStart, trimEnd } }); return true; } // 아이템 분할 splitItem(trackId: string, itemId: string, splitFrame: number): boolean { const track = this.tracks.get(trackId); if (!track || track.isLocked) return false; const item = track.items.find(item => item.id === itemId); if (!item) return false; const relativeFrame = splitFrame - item.startFrame; if (relativeFrame <= 0 || relativeFrame >= item.durationInFrames) return false; // 두 번째 파트 생성 const secondPart: MediaItem = { ...item, id: this.generateId(), startFrame: splitFrame, durationInFrames: item.durationInFrames - relativeFrame }; // 첫 번째 파트 수정 item.durationInFrames = relativeFrame; // 두 번째 파트 추가 track.items.push(secondPart); // 실행 취소를 위한 작업 기록 this.recordOperation({ id: this.generateId(), type: 'split', trackId, itemId, timestamp: Date.now(), parameters: { splitFrame, secondPartId: secondPart.id } }); return true; } // 겹침 해결 private resolveOverlaps(track: Track, newItem: MediaItem): void { const newItemEnd = newItem.startFrame + newItem.durationInFrames; track.items.forEach(item => { if (item.id === newItem.id) return; const itemEnd = item.startFrame + item.durationInFrames; // 겹침 검사 if (!(newItemEnd <= item.startFrame || newItem.startFrame >= itemEnd)) { // 기존 아이템을 뒤로 밀기 if (newItem.startFrame < item.startFrame) { item.startFrame = newItemEnd; } else { // 기존 아이템 트림 item.durationInFrames = Math.max(1, newItem.startFrame - item.startFrame); } } }); } // 전체 지속 시간 업데이트 private updateTotalDuration(): void { let maxDuration = 0; this.tracks.forEach(track => { track.items.forEach(item => { const itemEnd = item.startFrame + item.durationInFrames; maxDuration = Math.max(maxDuration, itemEnd); }); }); this.totalDuration = maxDuration; } // 현재 프레임 설정 setCurrentFrame(frame: number): void { this.currentFrame = Math.max(0, Math.min(frame, this.totalDuration)); } // 현재 프레임 조회 getCurrentFrame(): number { return this.currentFrame; } // 전체 지속 시간 조회 getTotalDuration(): number { return this.totalDuration; } // 현재 프레임에서 활성 아이템들 조회 getActiveItems(): MediaItem[] { const activeItems: MediaItem[] = []; this.tracks.forEach(track => { if (!track.isVisible) return; track.items.forEach(item => { if (this.currentFrame >= item.startFrame && this.currentFrame < item.startFrame + item.durationInFrames) { activeItems.push(item); } }); }); return activeItems; } // 작업 기록 (실행 취소/다시 실행용) private recordOperation(operation: EditOperation): void { this.undoStack.push(operation); this.redoStack = []; // 새 작업 시 다시 실행 스택 초기화 // 스택 크기 제한 if (this.undoStack.length > 100) { this.undoStack.shift(); } } // 실행 취소 undo(): boolean { const operation = this.undoStack.pop(); if (!operation) return false; // 작업 타입에 따른 역방향 실행 this.executeReverseOperation(operation); this.redoStack.push(operation); return true; } // 다시 실행 redo(): boolean { const operation = this.redoStack.pop(); if (!operation) return false; // 작업 재실행 this.executeOperation(operation); this.undoStack.push(operation); return true; } // 작업 실행 private executeOperation(operation: EditOperation): void { // 실제 구현에서는 각 작업 타입에 맞는 실행 로직 구현 console.log('Executing operation:', operation); } // 역방향 작업 실행 private executeReverseOperation(operation: EditOperation): void { // 실제 구현에서는 각 작업 타입에 맞는 역방향 실행 로직 구현 console.log('Executing reverse operation:', operation); } // ID 생성 (Remotion의 deterministic random 사용) private generateId(): string { return Date.now().toString(36) + random(null).toString(36).substr(2); } // 타임라인 데이터 내보내기 export(): TimelineExportData { return { tracks: Array.from(this.tracks.values()), currentFrame: this.currentFrame, totalDuration: this.totalDuration, fps: this.fps }; } // 타임라인 데이터 가져오기 import(data: TimelineExportData): void { this.tracks.clear(); if (data.tracks) { data.tracks.forEach((track: Track) => { this.tracks.set(track.id, track); }); } this.currentFrame = data.currentFrame || 0; this.totalDuration = data.totalDuration || 0; this.fps = data.fps || 30; } } // 키프레임 애니메이션 관리 export class KeyframeManager { private keyframes: Map<string, Keyframe[]> = new Map(); // 키프레임 추가 addKeyframe(itemId: string, keyframe: Keyframe): void { if (!this.keyframes.has(itemId)) { this.keyframes.set(itemId, []); } const keyframes = this.keyframes.get(itemId)!; keyframes.push(keyframe); keyframes.sort((a, b) => a.frame - b.frame); } // 키프레임 삭제 removeKeyframe(itemId: string, frame: number, property: string): boolean { const keyframes = this.keyframes.get(itemId); if (!keyframes) return false; const index = keyframes.findIndex(kf => kf.frame === frame && kf.property === property); if (index === -1) return false; keyframes.splice(index, 1); return true; } // 특정 프레임에서의 값 계산 getValue(itemId: string, frame: number, property: string): number | string | undefined { const keyframes = this.keyframes.get(itemId); if (!keyframes) return undefined; const propertyKeyframes = keyframes.filter(kf => kf.property === property); if (propertyKeyframes.length === 0) return undefined; // 정확한 키프레임 찾기 const exactKeyframe = propertyKeyframes.find(kf => kf.frame === frame); if (exactKeyframe) return exactKeyframe.value; // 보간 계산 const beforeKeyframes = propertyKeyframes.filter(kf => kf.frame < frame); const afterKeyframes = propertyKeyframes.filter(kf => kf.frame > frame); if (beforeKeyframes.length === 0) return afterKeyframes[0]?.value; if (afterKeyframes.length === 0) return beforeKeyframes[beforeKeyframes.length - 1]?.value; const before = beforeKeyframes[beforeKeyframes.length - 1]; const after = afterKeyframes[0]; // 선형 보간 const progress = (frame - before.frame) / (after.frame - before.frame); return this.interpolate(before.value, after.value, progress, before.easing || 'linear'); } // 값 보간 private interpolate(start: number | string, end: number | string, progress: number, easing: string): number | string { // 이징 함수 적용 const easedProgress = this.applyEasing(progress, easing); // 타입별 보간 if (typeof start === 'number' && typeof end === 'number') { return start + (end - start) * easedProgress; } // 기본적으로 선형 보간 return typeof start === 'number' && typeof end === 'number' ? start + (end - start) * easedProgress : start; } // 이징 함수 private applyEasing(t: number, easing: string): number { switch (easing) { case 'ease-in': return t * t; case 'ease-out': return 1 - (1 - t) * (1 - t); case 'ease-in-out': return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; default: return t; // linear } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/smilish67/rodumani'

If you have feedback or need assistance with the MCP directory API, please join our Discord server