요약(TL;DR)
프레임 단위 정확도의 댓글을 구현하려면 세 가지 시스템을 동기화해야 합니다. 바로 영상 플레이어의 탐색(seeking), 타임스탬프 정밀도, 그리고 정확한 프레임과 댓글의 연결입니다. 우리는 HLS 영상 프로토콜, 밀리초 타임스탬프와 분리된 프레임 번호 추적, 실시간 플레이어 상태 동기화를 활용한 시스템을 구축했습니다. 핵심 과제는 가변 프레임 레이트, 플레이어 버퍼링, 탐색 정확도였습니다. 해결책은 플레이어 이벤트 훅을 결합한 이중 타임스탬프 시스템(UI용 밀리초, 정확도용 프레임 번호)이었습니다.
문제 정의
영상 피드백 도구들은 댓글에 위치가 없는 것처럼 다룹니다. “영상의 1:23 지점”처럼 타임스탬프를 붙이긴 하지만 딱 거기까지입니다. 여러 리뷰어가 겹치는 순간에 댓글을 달면 추적이 혼란스러워집니다. 피드백이 1:22에 대한 것인가, 1:24에 대한 것인가? 누군가 전환 장면에 댓글을 단 것인가, 그 다음 샷에 단 것인가?
프레임 단위 정확도의 댓글은 이 문제를 해결합니다. 리뷰어가 영상 프레임을 클릭하면 댓글이 바로 그 정확한 프레임에 고정됩니다. 크리에이터는 그 순간을 맥락 속에서 정확히 다시 재생할 수 있습니다. 모호함이 없습니다.
이를 구축하려면 세 가지 까다로운 문제를 해결해야 합니다.
- 재생 정확도: 사용자가 정확히 어느 프레임을 보고 있는지 어떻게 알 수 있는가?
- 타임스탬프 정밀도: 서로 다른 영상 포맷과 프레임 레이트에서 타임스탬프를 어떻게 안정적으로 저장하는가?
- 댓글-프레임 결합: 영상이 재인코딩되거나 프레임 레이트가 바뀌어도 댓글을 해당 프레임에 어떻게 계속 연결해 두는가?
아키텍처 개요
우리의 프레임 단위 정확도 시스템은 다섯 개의 구성 요소로 이루어져 있습니다.
Frontend (React)
↓
YouViCo Player API (custom wrapper around HLS.js)
↓
Frame-Position Tracking Service
↓
Comment Service (Backend)
↓
Storage Layer (PostgreSQL + Redis)
핵심은 이것입니다. 영상 플레이어와 댓글 시스템이 완벽하게 동기화된 상태를 유지해야 한다는 점입니다. 사용자가 1234번 프레임에서 일시정지하면, 댓글의 타임스탬프는 “대략 1:23 즈음”이 아니라 1234번 프레임을 정확히 나타내야 합니다.
커스텀 영상 플레이어 구축
우리는 영상 전송에 HLS(HTTP Live Streaming)를 사용합니다. HLS는 영상을 2초 단위 세그먼트로 나누어 점진적으로 스트리밍합니다. 이는 안정성 측면에서 훌륭하지만 프레임 정확도를 복잡하게 만듭니다.
플레이어 상태 관리
우리는 HLS.js를 커스텀 API로 감싸 두 가지 타임스탬프를 동시에 추적합니다.
interface PlayerState {
// Milliseconds from video start (UI display)
currentTimeMs: number;
// Frame number (storage and precision)
currentFrameNumber: number;
// Metadata about the video
frameRate: number;
totalFrames: number;
duration: number;
}
class YouViCoPlayer {
private state: PlayerState;
private hls: HLS;
constructor(videoElement: HTMLVideoElement, hlsUrl: string) {
this.hls = new HLS();
this.hls.attachMedia(videoElement);
this.hls.loadSource(hlsUrl);
// Hook into HLS events
this.setupPlayerHooks(videoElement);
}
private setupPlayerHooks(videoElement: HTMLVideoElement) {
// Update frame number when player time changes
videoElement.addEventListener('timeupdate', () => {
this.state.currentTimeMs = videoElement.currentTime * 1000;
this.state.currentFrameNumber = this.calculateFrameNumber();
// Broadcast to comment system
this.emitPlayerStateChange(this.state);
});
// Handle seeking
videoElement.addEventListener('seeking', () => {
this.state.currentTimeMs = videoElement.currentTime * 1000;
this.state.currentFrameNumber = this.calculateFrameNumber();
});
videoElement.addEventListener('seeked', () => {
// Ensure frame accuracy after seek completes
this.validateFrameAccuracy();
});
}
private calculateFrameNumber(): number {
// Convert time to frame number
// Frame = (time in seconds) * frameRate
return Math.round((this.state.currentTimeMs / 1000) * this.state.frameRate);
}
}
과제 1: 가변 프레임 레이트
영상은 다양한 프레임 레이트로 들어옵니다. 24fps(영화), 29.97fps(NTSC), 30fps, 60fps 등입니다. 100번 프레임의 댓글은 프레임 레이트에 따라 전혀 다른 지점을 의미합니다.
해결책: 모든 댓글에 프레임 번호와 프레임 레이트를 함께 저장합니다.
interface Comment {
id: string;
frameNumber: number; // Absolute frame in original video
frameRate: number; // Original video frame rate (29.97, 60, etc.)
timestampMs: number; // Fallback in case frame rate info is lost
text: string;
authorId: string;
createdAt: Date;
}
// When retrieving a comment, convert to current playback frame rate
function getCommentFrameForCurrentVideo(
comment: Comment,
currentFrameRate: number
): number {
// If frame rates match, return as-is
if (comment.frameRate === currentFrameRate) {
return comment.frameNumber;
}
// If they differ, convert via timestamp
const timeInSeconds = comment.frameNumber / comment.frameRate;
return Math.round(timeInSeconds * currentFrameRate);
}
과제 2: 플레이어 버퍼링과 탐색 정확도
사용자가 특정 프레임으로 탐색할 때, HLS 플레이어가 바로 그 정확한 프레임에 도달하지 못할 수 있습니다. HLS는 키프레임(2초마다)을 사용하며, 플레이어는 정확한 프레임이 아니라 가장 가까운 키프레임으로 탐색합니다.
해결책: 프레임 단위 탐색을 구현합니다.
async seekToFrame(targetFrame: number) {
const targetTimeMs = (targetFrame / this.state.frameRate) * 1000;
const videoElement = this.hls.media;
// Seek to approximate time first
videoElement.currentTime = targetTimeMs / 1000;
// Wait for seek to complete
return new Promise<void>((resolve) => {
const checkFrame = () => {
const currentFrame = this.calculateFrameNumber();
if (currentFrame === targetFrame) {
resolve();
} else if (currentFrame > targetFrame) {
// Overshot, shouldn't happen but stop trying
resolve();
} else {
// Keep waiting for the frame
requestAnimationFrame(checkFrame);
}
};
videoElement.addEventListener('seeked', checkFrame, { once: true });
});
}
댓글 저장 전략
댓글이 게시되면, 나중에 안정적으로 다시 불러올 수 있을 만큼 충분한 정보를 저장해야 합니다.
이중 타임스탬프 접근법
세 가지 값을 저장합니다.
interface CommentTimestamp {
// Primary: Frame-based reference (survives re-encoding)
frameNumber: number;
frameRate: number;
// Secondary: Millisecond timestamp (for UI display, fallback)
timestampMs: number;
// Tertiary: Duration at time of comment (handles variable-length videos)
videoDurationMs: number;
}
// When comment is retrieved, use this priority:
// 1. Try to locate by frame number
// 2. If frame number not available, use millisecond timestamp
// 3. If that fails, calculate position as percentage of video
function resolveCommentPosition(
comment: Comment,
currentVideo: VideoMetadata
): FramePosition {
// Method 1: Frame number conversion
if (comment.timestamp.frameNumber !== undefined) {
return {
frameNumber: getCommentFrameForCurrentVideo(comment, currentVideo.frameRate),
type: 'frame-accurate'
};
}
// Method 2: Time-based fallback
if (comment.timestamp.timestampMs !== undefined) {
const newFrame = (comment.timestamp.timestampMs / 1000) * currentVideo.frameRate;
return {
frameNumber: Math.round(newFrame),
type: 'time-based-fallback'
};
}
// Method 3: Percentage of video
const percentagePosition = comment.timestamp.timestampMs / comment.timestamp.videoDurationMs;
return {
frameNumber: Math.round(percentagePosition * currentVideo.totalFrames),
type: 'percentage-based-fallback'
};
}
실시간 동기화
댓글은 플레이어가 해당 프레임에 도달하는 바로 그 순간 나타나야 합니다.
class CommentSyncEngine {
private comments: Comment[] = [];
private currentFrame: number = 0;
private handlePlayerStateChange(newState: PlayerState) {
this.currentFrame = newState.currentFrameNumber;
// Find comments for this frame
const commentsAtFrame = this.comments.filter(c =>
c.position.frameNumber === this.currentFrame
);
if (commentsAtFrame.length > 0) {
// Trigger comment display
this.emitCommentsForFrame(commentsAtFrame);
}
}
// When user plays/pauses, make sure to show comments in visible range
private handlePlaybackChange(isPlaying: boolean) {
if (isPlaying) {
// Player is moving forward, comments will auto-trigger
return;
}
// Player is paused, check if any comments exist for current frame
const currentFrameComments = this.comments.filter(c =>
c.position.frameNumber === this.currentFrame
);
if (currentFrameComments.length > 0) {
this.emitCommentsForFrame(currentFrameComments);
}
}
}
데이터베이스 스키마
프레임 단위 정확도 저장에는 세심한 스키마 설계가 필요합니다.
CREATE TABLE comments (
id UUID PRIMARY KEY,
video_id UUID NOT NULL,
author_id UUID NOT NULL,
-- Primary timestamp (frame-based)
frame_number INTEGER,
frame_rate DECIMAL(5, 2), -- e.g., 29.97, 60.00
-- Fallback timestamps
timestamp_ms INTEGER,
video_duration_ms INTEGER,
-- Comment content
text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
-- Indexes for fast lookup
FOREIGN KEY (video_id) REFERENCES videos(id),
FOREIGN KEY (author_id) REFERENCES users(id)
);
-- Index for finding comments by frame number
CREATE INDEX idx_comments_frame ON comments(video_id, frame_number);
-- Index for time-based fallback queries
CREATE INDEX idx_comments_timestamp ON comments(video_id, timestamp_ms);
성능 지표
우리의 프레임 단위 정확도 시스템은 다음의 SLA를 유지합니다.
- 댓글 표시 지연: 프레임 도달부터 UI 업데이트까지 100ms 미만
- 탐색 정확도: 99.9%가 목표 프레임에서 1프레임 이내에 도달
- 프레임 번호 정밀도: 지원하는 모든 프레임 레이트에서 100% 정확
- 재인코딩 내성: 영상 재인코딩 이후에도 댓글이 정확하게 유지됨
프로덕션 환경에서 우리는 다음을 확인합니다.
- 평균 댓글 표시 지연: 45ms
- 탐색 정확도: 99.97%(업계 표준은 99%)
- 프레임 레이트 변경 시 데이터 손실 0건
핵심 교훈
- 프레임 레이트는 교묘하다: 모든 계산에서 이를 반드시 고려하세요.
- 이중 타임스탬프가 당신을 구한다: UX에는 밀리초를, 정밀도에는 프레임을 사용하세요.
- 실제 영상으로 테스트하라: 합성 테스트 영상은 실제 환경의 프레임 문제를 드러내지 못합니다.
- 탐색 동작을 모니터링하라: 사용자는 탐색과 댓글 표시에서 100ms 미만의 응답을 기대합니다.
프레임 단위 정확도의 댓글은 팀이 영상을 리뷰하는 방식을 완전히 바꿔놓았습니다. 피드백은 모호한 표현(“1:25쯤”)에서 정밀한 표현(“프레임 4,247, 2:56:12”)으로 진화했습니다.