diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts
index 1b5835f..ca33ada 100644
--- a/app/api/versions/[versionId]/comments/route.ts
+++ b/app/api/versions/[versionId]/comments/route.ts
@@ -275,9 +275,44 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Timestamp is required');
}
- const parsedTimestamp = parseFloat(timestamp);
- if (isNaN(parsedTimestamp)) {
- return apiErrors.badRequest('Timestamp must be a valid number');
+ const maxTimestamp =
+ typeof version.duration === 'number' && Number.isFinite(version.duration)
+ ? version.duration
+ : null;
+
+ const parseCommentTimestamp = (value: unknown, fieldName: string) => {
+ const parsed = typeof value === 'number' ? value : Number(value);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return {
+ error: apiErrors.badRequest(`${fieldName} must be a finite non-negative number`),
+ };
+ }
+
+ if (maxTimestamp !== null && parsed > maxTimestamp) {
+ return {
+ error: apiErrors.badRequest(`${fieldName} must be less than or equal to video duration`),
+ };
+ }
+
+ return { value: parsed };
+ };
+
+ const parsedTimestampResult = parseCommentTimestamp(timestamp, 'Timestamp');
+ if ('error' in parsedTimestampResult) {
+ return parsedTimestampResult.error;
+ }
+ const parsedTimestamp = parsedTimestampResult.value;
+
+ let parsedTimestampEnd: number | null = null;
+ if (timestampEnd !== undefined && timestampEnd !== null) {
+ const parsedTimestampEndResult = parseCommentTimestamp(timestampEnd, 'Timestamp end');
+ if ('error' in parsedTimestampEndResult) {
+ return parsedTimestampEndResult.error;
+ }
+ parsedTimestampEnd = parsedTimestampEndResult.value;
+ if (parsedTimestampEnd < parsedTimestamp) {
+ return apiErrors.badRequest('Timestamp end must be greater than or equal to timestamp');
+ }
}
if (!content && !voiceUrl && !imageUrl && !annotationData) {
@@ -401,7 +436,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
data: {
content: content?.trim() || null,
timestamp: parsedTimestamp,
- timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null,
+ timestampEnd: parsedTimestampEnd,
parentId: parentId || null,
voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null,
diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx
index 694713b..8ebd8c2 100644
--- a/components/video-page-content.tsx
+++ b/components/video-page-content.tsx
@@ -301,6 +301,8 @@ export function VideoPageContent({
videoDuration,
isPlaying,
isMuted,
+ isFrameMode,
+ frameStepLabel,
isDragging,
playbackSpeed,
qualityOptions,
@@ -318,6 +320,7 @@ export function VideoPageContent({
handlePlayPause,
handleSeekToTimestamp,
handleMuteToggle,
+ handleFrameModeToggle,
handleSkip,
handleSpeedChange,
handleQualityChange,
@@ -422,6 +425,10 @@ export function VideoPageContent({
isUploadingAudio,
imageBlob,
setImageBlob,
+ commentRangeStart,
+ commentRangeEnd,
+ toggleCommentRangeSelection,
+ clearCommentRangeSelection,
isUploadingImage,
imageInputRef,
handleAddComment,
@@ -442,6 +449,10 @@ export function VideoPageContent({
replyAudioBlob,
replyImageBlob,
setReplyImageBlob,
+ replyRangeStart,
+ replyRangeEnd,
+ toggleReplyRangeSelection,
+ clearReplyRangeSelection,
isUploadingReplyAudio,
isUploadingReplyImage,
replyImageInputRef,
@@ -471,7 +482,6 @@ export function VideoPageContent({
setVideo,
activeVersionId,
activeVersion,
- comments,
currentTime,
isGuest,
normalizedGuestName,
@@ -495,6 +505,7 @@ export function VideoPageContent({
return filteredComments.map((comment) => ({
id: comment.id,
timestamp: comment.timestamp,
+ timestampEnd: comment.timestampEnd,
color: comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE'),
annotationData: comment.annotationData,
preview: `${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`,
@@ -783,7 +794,10 @@ export function VideoPageContent({
setIsEditingAnnotation={setIsEditingAnnotation}
currentTime={currentTime}
duration={duration}
+ isFrameMode={isFrameMode}
+ frameStepLabel={frameStepLabel}
handleSkip={handleSkip}
+ handleFrameModeToggle={handleFrameModeToggle}
handleMuteToggle={handleMuteToggle}
isMuted={isMuted}
selectedQualityLabel={selectedQualityLabel}
@@ -849,6 +863,10 @@ export function VideoPageContent({
setReplyingTo={setReplyingTo}
replyText={replyText}
setReplyText={setReplyText}
+ replyRangeStart={replyRangeStart}
+ replyRangeEnd={replyRangeEnd}
+ toggleReplyRangeSelection={toggleReplyRangeSelection}
+ clearReplyRangeSelection={clearReplyRangeSelection}
handleReplyComment={commentsActions.onReplyComment}
startReplyRecording={startReplyRecording}
isReplyRecording={isReplyRecording}
@@ -903,6 +921,10 @@ export function VideoPageContent({
setImageBlob={setImageBlob}
commentText={commentText}
setCommentText={setCommentText}
+ commentRangeStart={commentRangeStart}
+ commentRangeEnd={commentRangeEnd}
+ toggleCommentRangeSelection={toggleCommentRangeSelection}
+ clearCommentRangeSelection={clearCommentRangeSelection}
playVoice={playVoice}
playingVoiceId={playingVoiceId}
voiceProgress={voiceProgress}
diff --git a/components/video-page/comment-composer.tsx b/components/video-page/comment-composer.tsx
index 9a31502..ae20336 100644
--- a/components/video-page/comment-composer.tsx
+++ b/components/video-page/comment-composer.tsx
@@ -37,6 +37,10 @@ interface CommentComposerProps {
setImageBlob: (blob: File | null) => void;
commentText: string;
setCommentText: (value: string) => void;
+ commentRangeStart: number | null;
+ commentRangeEnd: number | null;
+ toggleCommentRangeSelection: () => void;
+ clearCommentRangeSelection: () => void;
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
playingVoiceId: string | null;
voiceProgress: number;
@@ -76,6 +80,10 @@ export const CommentComposer = memo(function CommentComposer({
setImageBlob,
commentText,
setCommentText,
+ commentRangeStart,
+ commentRangeEnd,
+ toggleCommentRangeSelection,
+ clearCommentRangeSelection,
playVoice,
playingVoiceId,
voiceProgress,
@@ -103,6 +111,16 @@ export const CommentComposer = memo(function CommentComposer({
pauseVideoForAnnotation,
assets,
}: CommentComposerProps) {
+ const rangeButtonLabel =
+ commentRangeStart === null || commentRangeEnd !== null ? 'Set In' : 'Set Out';
+ const hasCommentRange = commentRangeStart !== null;
+ const commentRangeLabel =
+ commentRangeStart !== null
+ ? commentRangeEnd !== null
+ ? `${formatTime(commentRangeStart)} - ${formatTime(commentRangeEnd)}`
+ : `In ${formatTime(commentRangeStart)}`
+ : null;
+
return (
{isRecording ? (
@@ -192,6 +210,31 @@ export const CommentComposer = memo(function CommentComposer({
rows={1}
className="resize-none text-sm"
/>
+
+
+ {commentRangeLabel && (
+
+ {commentRangeLabel}
+
+ )}
+ {hasCommentRange && (
+
+ )}
+
)}
+
+
+ {commentRangeLabel && (
+
+ {commentRangeLabel}
+
+ )}
+ {hasCommentRange && (
+
+ )}
+
void;
currentUserId: string | null;
projectOwnerId: string;
@@ -87,6 +87,10 @@ interface CommentsPaneProps {
setReplyingTo: (id: string | null) => void;
replyText: string;
setReplyText: (value: string) => void;
+ replyRangeStart: number | null;
+ replyRangeEnd: number | null;
+ toggleReplyRangeSelection: () => void;
+ clearReplyRangeSelection: () => void;
handleReplyComment: (
parentId: string,
voiceData?: { url: string; duration: number },
@@ -161,6 +165,10 @@ export const CommentsPane = memo(function CommentsPane({
setReplyingTo,
replyText,
setReplyText,
+ replyRangeStart,
+ replyRangeEnd,
+ toggleReplyRangeSelection,
+ clearReplyRangeSelection,
handleReplyComment,
startReplyRecording,
isReplyRecording,
@@ -186,6 +194,18 @@ export const CommentsPane = memo(function CommentsPane({
assetsPane,
}: CommentsPaneProps) {
const [isPaneDraggingOver, setIsPaneDraggingOver] = useState(false);
+ const formatCommentRange = (timestamp: number, timestampEnd: number | null) => {
+ if (timestampEnd === null) return formatTime(timestamp);
+ return `${formatTime(timestamp)} - ${formatTime(timestampEnd)}`;
+ };
+ const replyRangeButtonLabel =
+ replyRangeStart === null || replyRangeEnd !== null ? 'Set In' : 'Set Out';
+ const replyRangeLabel =
+ replyRangeStart !== null
+ ? replyRangeEnd !== null
+ ? `${formatTime(replyRangeStart)} - ${formatTime(replyRangeEnd)}`
+ : `In ${formatTime(replyRangeStart)}`
+ : null;
return (
<>
@@ -383,13 +403,14 @@ export const CommentsPane = memo(function CommentsPane({
onClick={() =>
handleSeekToTimestamp(comment.timestamp, comment.annotationData, {
pauseAfterSeek: true,
+ timestampEnd: comment.timestampEnd,
})
}
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10 hover:bg-primary/20 transition-colors"
title="Jump to this timestamp"
>
- {formatTime(comment.timestamp)}
+ {formatCommentRange(comment.timestamp, comment.timestampEnd)}
{canResolveComments && (
@@ -416,6 +437,7 @@ export const CommentsPane = memo(function CommentsPane({
{
+ clearReplyRangeSelection();
setReplyingTo(comment.id);
setReplyText('');
}}
@@ -670,6 +692,19 @@ export const CommentsPane = memo(function CommentsPane({
{replyAuthor}
+
{new Date(reply.createdAt).toLocaleDateString()}
@@ -938,6 +973,31 @@ export const CommentsPane = memo(function CommentsPane({
rows={1}
className="resize-none text-sm"
/>
+
+
+ {replyRangeLabel && (
+
+ {replyRangeLabel}
+
+ )}
+ {replyRangeStart !== null && (
+
+ )}
+
+
+
+ {replyRangeLabel && (
+
+ {replyRangeLabel}
+
+ )}
+ {replyRangeStart !== null && (
+
+ )}
+
@@ -375,6 +385,16 @@ export const PlayerCore = memo(function PlayerCore({
+
+ Frame {frameStepLabel}
+
+
{activeProviderId === 'bunny' && (
@@ -489,21 +509,70 @@ export const PlayerCore = memo(function PlayerCore({
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
/>
- {commentMarkers.map((comment) => (
- {
- e.stopPropagation();
- handleSeekToTimestamp(comment.timestamp, comment.annotationData);
- }}
- className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
- style={{
- left: `calc(${duration > 0 ? (comment.timestamp / duration) * 100 : 0}% - 6px)`,
- backgroundColor: comment.color,
- }}
- title={`${formatTime(comment.timestamp)}${comment.preview}`}
- />
- ))}
+ {commentMarkers.map((comment) => {
+ const startPercent = duration > 0 ? (comment.timestamp / duration) * 100 : 0;
+ const hasRange =
+ comment.timestampEnd !== null && Number.isFinite(comment.timestampEnd)
+ ? comment.timestampEnd > comment.timestamp
+ : false;
+ const endPercent =
+ hasRange && comment.timestampEnd !== null
+ ? (comment.timestampEnd / duration) * 100
+ : 0;
+
+ if (hasRange && comment.timestampEnd !== null) {
+ return (
+ {
+ e.stopPropagation();
+ handleSeekToTimestamp(comment.timestamp, comment.annotationData, {
+ pauseAfterSeek: true,
+ timestampEnd: comment.timestampEnd,
+ });
+ }}
+ className="absolute top-1/2 z-10 h-4 -translate-y-1/2 transition-opacity hover:opacity-100"
+ style={{
+ left: `calc(${startPercent}% - 6px)`,
+ width: `calc(${Math.max(endPercent - startPercent, 0)}% + 12px)`,
+ }}
+ title={`${formatTime(comment.timestamp)} - ${formatTime(comment.timestampEnd)}${comment.preview}`}
+ >
+
+
+
+
+ );
+ }
+
+ return (
+ {
+ e.stopPropagation();
+ handleSeekToTimestamp(comment.timestamp, comment.annotationData, {
+ pauseAfterSeek: comment.timestampEnd !== null,
+ timestampEnd: comment.timestampEnd,
+ });
+ }}
+ className="absolute top-1/2 z-10 h-3 w-3 -translate-y-1/2 rounded-full transition-transform hover:scale-150"
+ style={{
+ left: `calc(${startPercent}% - 6px)`,
+ backgroundColor: comment.color,
+ }}
+ title={`${formatTime(comment.timestamp)}${comment.preview}`}
+ />
+ );
+ })}
>
diff --git a/components/video-page/types.ts b/components/video-page/types.ts
index 4d1bbdd..3f8b08e 100644
--- a/components/video-page/types.ts
+++ b/components/video-page/types.ts
@@ -78,6 +78,8 @@ export interface ApprovalRequest {
export interface CommentReply {
id: string;
content: string | null;
+ timestamp: number;
+ timestampEnd: number | null;
voiceUrl: string | null;
voiceDuration: number | null;
imageUrl: string | null;
@@ -94,6 +96,7 @@ export interface Comment {
id: string;
content: string | null;
timestamp: number;
+ timestampEnd: number | null;
voiceUrl: string | null;
voiceDuration: number | null;
imageUrl: string | null;
@@ -145,6 +148,7 @@ export type DownloadTarget = BunnyDownloadPreference | 'direct';
export interface CommentMarker {
id: string;
timestamp: number;
+ timestampEnd: number | null;
color: string;
annotationData: string | null;
preview: string;