瀏覽代碼

feat: side-by-side video compare + speech bubble opacity + annotation isolation

Side-by-side compare:
- Compare button in header opens a picker modal listing completed videos
  in the same project
- Duration mismatch check: warns if videos differ by >5 frames, allows
  compare anyway (user can still choose to proceed)
- Selected compare video renders in a second VideoPlayer (isComparePlayer),
  synchronized to main video's currentTime and playing state via
  externalCurrentTime/externalPlaying props
- Both players run independently; compare player skips rVFC state updates
- Compare mode: comment input disabled with placeholder, keyboard shortcuts
  hidden, draw mode button visible but annotation tools on video are
  off (both players get drawMode=false, comments=[], visibleAnnotations=[])
- "Compare mode" badge shown in comment panel header; Exit Compare button
  in header to leave the mode

Speech bubble:
- Opacity reduced from 95% to 75% for a lighter visual feel

Annotations:
- In compare mode both players get drawMode=false and empty
  visibleAnnotations so annotations are isolated to single-video mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev 1 月之前
父節點
當前提交
5993957162

+ 197 - 3
src/app/review/[assetId]/page.tsx

@@ -3,7 +3,7 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useParams, useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
-import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
+import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
 import { Avatar } from '@/components/ui/avatar';
 import { VideoPlayer } from '@/components/video-player/VideoPlayer';
 import { Tool } from '@/components/video-player/AnnotationCanvas';
@@ -58,6 +58,36 @@ export default function ReviewPage() {
   // Portrait / landscape detection
   const [isPortrait, setIsPortrait] = useState(false);
 
+  // ── Side-by-side compare mode ────────────────────────────────────────────
+  const [compareMode, setCompareMode] = useState(false);
+  const [compareAsset, setCompareAsset] = useState<Asset | null>(null);
+  const [showComparePicker, setShowComparePicker] = useState(false);
+  const [projectAssets, setProjectAssets] = useState<Asset[]>([]);
+  const [compareMismatch, setCompareMismatch] = useState<string | null>(null);
+  const [playing, setPlaying] = useState(false);
+
+  const handleCompareSelect = useCallback((compareAssetArg: Asset) => {
+    setShowComparePicker(false);
+    setCompareMismatch(null);
+    const dur1 = asset?.duration ?? 0;
+    const dur2 = compareAssetArg.duration ?? 0;
+    const fps = asset?.fps ?? compareAssetArg.fps ?? 30;
+    const diffFrames = Math.abs(dur1 - dur2) * fps;
+    if (diffFrames > 5) {
+      setCompareMismatch(
+        `Videos differ by ${Math.round(diffFrames)} frames. Playback may be out of sync.`
+      );
+    }
+    setCompareAsset(compareAssetArg);
+    setCompareMode(true);
+  }, [asset]);
+
+  const handleExitCompare = useCallback(() => {
+    setCompareMode(false);
+    setCompareAsset(null);
+    setCompareMismatch(null);
+  }, []);
+
   useEffect(() => {
     const mq = window.matchMedia('(orientation: portrait)');
     setIsPortrait(mq.matches);
@@ -397,6 +427,34 @@ export default function ReviewPage() {
 
         <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
 
+        {/* Compare mode toggle */}
+        <button
+          onClick={() => {
+            if (compareMode) {
+              handleExitCompare();
+            } else {
+              setShowComparePicker(true);
+              if (token && asset) {
+                assetsApi.list(token, asset.projectId).then(({ assets }) => {
+                  setProjectAssets(assets.filter(a => a.id !== assetId && a.transcodeStatus === 'COMPLETED'));
+                }).catch(() => {});
+              }
+            }
+          }}
+          className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0 ${
+            compareMode
+              ? 'bg-indigo-600 text-white'
+              : ''
+          }`}
+          style={!compareMode ? { color: '#818CF8', background: 'rgba(129,140,248,0.10)' } : {}}
+          title="Side-by-side comparison"
+        >
+          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
+          </svg>
+          <span className="hidden sm:inline">{compareMode ? 'Exit Compare' : 'Compare'}</span>
+        </button>
+
         {/* Status selector */}
         <div className="relative shrink-0">
           <button
@@ -439,6 +497,60 @@ export default function ReviewPage() {
         </div>
       </header>
 
+      {/* ── Compare picker modal ─────────────────────────────────────────────── */}
+      {showComparePicker && (
+        <>
+          <div className="fixed inset-0 z-50" onClick={() => setShowComparePicker(false)} />
+          <div
+            className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-2xl overflow-hidden w-full max-w-md"
+            style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-modal)' }}
+          >
+            <div className="px-5 py-4 flex items-center justify-between" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
+              <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Select video to compare</h2>
+              <button onClick={() => setShowComparePicker(false)} className="w-7 h-7 flex items-center justify-center rounded-lg transition-colors hover:bg-white/10"
+                style={{ color: 'var(--text-muted)' }}>
+                <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+                </svg>
+              </button>
+            </div>
+            <div className="p-2 max-h-80 overflow-y-auto">
+              {projectAssets.length === 0 ? (
+                <p className="text-sm text-center py-8" style={{ color: 'var(--text-muted)' }}>
+                  No other completed videos in this project.
+                </p>
+              ) : (
+                projectAssets.map(a => (
+                  <button
+                    key={a.id}
+                    onClick={() => handleCompareSelect(a)}
+                    className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors hover:bg-white/5"
+                  >
+                    {a.thumbnail ? (
+                      <img src={`${API_BASE}/uploads/${a.thumbnail}`} className="w-16 h-10 rounded-lg object-cover shrink-0" alt={a.title} />
+                    ) : (
+                      <div className="w-16 h-10 rounded-lg shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
+                        <svg className="w-5 h-5" style={{ color: 'rgba(255,255,255,0.2)' }} fill="currentColor" viewBox="0 0 24 24">
+                          <path d="M8 5v14l11-7z" />
+                        </svg>
+                      </div>
+                    )}
+                    <div className="flex-1 min-w-0">
+                      <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{a.title}</p>
+                      <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
+                        {a.duration ? `${Math.floor(a.duration / 60)}:${Math.floor(a.duration % 60).toString().padStart(2, '0')}` : '—'}
+                        {' · '}
+                        {a.filename}
+                      </p>
+                    </div>
+                  </button>
+                ))
+              )}
+            </div>
+          </div>
+        </>
+      )}
+
       {/* ── Body ───────────────────────────────────────────── */}
       {/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
       <div
@@ -456,6 +568,66 @@ export default function ReviewPage() {
             : { flex: 1, overflowY: 'auto' }}
         >
 
+          {/* ── Side-by-side compare layout ───────────────────────── */}
+          {compareMode ? (
+            <div className="flex gap-2 w-full">
+              {/* Main video */}
+              <div className="flex-1 min-w-0">
+                <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
+                  {asset.title}
+                </div>
+                <VideoPlayer
+                  src={videoUrl}
+                  mimeType={asset.mimeType}
+                  fps={fps}
+                  comments={[]}
+                  visibleAnnotations={[]}
+                  drawMode={false}
+                  drawTool={drawTool}
+                  drawColor={drawColor}
+                  onDrawModeChange={() => {}}
+                  onDrawToolChange={() => {}}
+                  onDrawColorChange={() => {}}
+                  pendingStrokes={[]}
+                  onStrokeComplete={() => {}}
+                  onTimeUpdate={handleTimeUpdate}
+                  onCommentClick={() => {}}
+                  externalCurrentTime={currentTime}
+                  externalPlaying={playing}
+                />
+              </div>
+
+              {/* Compare video */}
+              {compareAsset && (
+                <div className="flex-1 min-w-0">
+                  <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
+                    {compareAsset.title}
+                  </div>
+                  <VideoPlayer
+                    src={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
+                    mimeType={compareAsset.mimeType}
+                    fps={compareAsset.fps ?? 30}
+                    comments={[]}
+                    visibleAnnotations={[]}
+                    drawMode={false}
+                    drawTool={drawTool}
+                    drawColor={drawColor}
+                    onDrawModeChange={() => {}}
+                    onDrawToolChange={() => {}}
+                    onDrawColorChange={() => {}}
+                    pendingStrokes={[]}
+                    onStrokeComplete={() => {}}
+                    onTimeUpdate={() => {}}
+                    onCommentClick={() => {}}
+                    isComparePlayer={true}
+                    externalCurrentTime={currentTime}
+                    externalPlaying={playing}
+                  />
+                </div>
+              )}
+            </div>
+          ) : (
+          /* ── Normal single-video layout ─────────────────────────── */
           <VideoPlayer
             src={videoUrl}
             mimeType={asset.mimeType}
@@ -472,7 +644,20 @@ export default function ReviewPage() {
             onStrokeComplete={handleStrokeComplete}
             onTimeUpdate={handleTimeUpdate}
             onCommentClick={handleCommentSeek}
+            onPlayingChange={setPlaying}
           />
+          )}
+
+          {/* ── Compare mismatch warning ─────────────────────────── */}
+          {compareMode && compareMismatch && (
+            <div className="rounded-xl px-4 py-3 text-xs flex items-center gap-2"
+                 style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.25)', color: '#FCD34D' }}>
+              <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
+              </svg>
+              {compareMismatch}
+            </div>
+          )}
 
           {/* Transcode status overlay — shown when video is not ready */}
           {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
@@ -541,6 +726,7 @@ export default function ReviewPage() {
           )}
 
           {/* Keyboard shortcuts */}
+          {!compareMode && (
           <div className="flex flex-wrap gap-3 text-xs shrink-0 hidden sm:flex" style={{ color: 'var(--text-subtle)' }}>
             <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
             <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek ±5s</span>
@@ -549,6 +735,7 @@ export default function ReviewPage() {
             <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
             <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps, asset?.duration ?? 0)}</span>
           </div>
+          )}
         </div>
 
         {/* Resize handle — only shown in landscape */}
@@ -595,6 +782,11 @@ export default function ReviewPage() {
               >
                 {showResolved ? 'Hide resolved' : 'Show resolved'}
               </button>
+              {compareMode && (
+                <span className="text-[11px] px-2 py-0.5 rounded-md" style={{ background: 'rgba(99,102,241,0.15)', color: '#818CF8' }}>
+                  Compare mode
+                </span>
+              )}
             </div>
           </div>
 
@@ -714,9 +906,11 @@ export default function ReviewPage() {
               <div className="flex-1 flex gap-2">
                 <textarea
                   className="input flex-1"
-                  value={newComment}
+                  value={compareMode ? '' : newComment}
                   onChange={e => setNewComment(e.target.value)}
-                  placeholder={replyTo ? 'Write a reply…' : 'Add a comment…'}
+                  placeholder={compareMode ? 'Comments disabled in compare mode' : replyTo ? 'Write a reply…' : 'Add a comment…'}
+                  disabled={compareMode}
+                  readOnly={compareMode}
                   rows={1}
                   style={{ resize: 'none', overflow: 'hidden' }}
                   onKeyDown={e => {

+ 1 - 1
src/components/video-player/SpeechBubble.tsx

@@ -16,7 +16,7 @@ export function SpeechBubble({ comment, fps = 30, left, onDismiss }: Props) {
     <div
       className="flex items-start gap-2 rounded-xl px-4 py-3 w-full"
       style={{
-        background: 'rgba(20, 22, 40, 0.95)',
+        background: 'rgba(20, 22, 40, 0.75)',
         border: '1px solid rgba(255,255,255,0.12)',
         backdropFilter: 'blur(12px)',
         maxWidth: '300px',

+ 52 - 3
src/components/video-player/VideoPlayer.tsx

@@ -29,6 +29,14 @@ interface Props {
   onStrokeComplete: (stroke: AnnotationData) => void;
   onTimeUpdate: (time: number) => void;
   onCommentClick: (comment: Comment) => void;
+  onPlayingChange?: (playing: boolean) => void;
+  // ── Compare mode ───────────────────────────────────────────────────────
+  /** When provided, this player is slave to the main player in compare mode */
+  isComparePlayer?: boolean;
+  /** External time to sync to (provided by parent in compare mode) */
+  externalCurrentTime?: number;
+  /** External playing state to sync to */
+  externalPlaying?: boolean;
 }
 
 export function VideoPlayer({
@@ -47,6 +55,10 @@ export function VideoPlayer({
   onStrokeComplete,
   onTimeUpdate,
   onCommentClick,
+  onPlayingChange,
+  isComparePlayer = false,
+  externalCurrentTime,
+  externalPlaying,
 }: Props) {
   const videoRef = useRef<HTMLVideoElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
@@ -118,6 +130,11 @@ export function VideoPlayer({
         videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
         return;
       }
+      // In compare mode the parent controls state — rVFC just keeps redraws flowing
+      if (isComparePlayer) {
+        videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
+        return;
+      }
       const t = metadata.mediaTime;
       setCurrentTime(t);
       onTimeUpdate(t);
@@ -128,6 +145,8 @@ export function VideoPlayer({
     function onSeeked() {
       // Only update UI for non-frame-step seeks (e.g. click-to-seek on timeline)
       if (stepInFlightRef.current) return;
+      // Compare player: skip — parent owns state
+      if (isComparePlayer) return;
       const t = videoRef.current?.currentTime ?? 0;
       setCurrentTime(t);
       onTimeUpdate(t);
@@ -147,6 +166,30 @@ export function VideoPlayer({
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
+  // ── Compare mode sync: slave to external time + play state ──────────────
+  const prevExternalTime = useRef<number | undefined>(undefined);
+  useEffect(() => {
+    if (!isComparePlayer || externalCurrentTime === undefined) return;
+    const video = videoRef.current;
+    if (!video) return;
+    // Only seek if time actually changed (comparing number avoids stale closure)
+    if (prevExternalTime.current !== externalCurrentTime) {
+      prevExternalTime.current = externalCurrentTime;
+      video.currentTime = externalCurrentTime;
+    }
+  }, [isComparePlayer, externalCurrentTime]);
+
+  useEffect(() => {
+    if (!isComparePlayer || externalPlaying === undefined) return;
+    const video = videoRef.current;
+    if (!video) return;
+    if (externalPlaying && video.paused) {
+      video.play().catch(() => {});
+    } else if (!externalPlaying && !video.paused) {
+      video.pause();
+    }
+  }, [isComparePlayer, externalPlaying]);
+
   // HLS
   useEffect(() => {
     const video = videoRef.current;
@@ -261,7 +304,13 @@ export function VideoPlayer({
   function togglePlay() {
     const video = videoRef.current;
     if (!video) return;
-    video.paused ? video.play() : video.pause();
+    if (video.paused) {
+      video.play().then(() => { setPlaying(true); onPlayingChange?.(true); }).catch(() => {});
+    } else {
+      video.pause();
+      setPlaying(false);
+      onPlayingChange?.(false);
+    }
   }
 
   function toggleMute() {
@@ -317,8 +366,8 @@ export function VideoPlayer({
           ref={videoRef}
           className="w-full block"
           onClick={() => { if (!drawMode) togglePlay(); }}
-          onPlay={() => setPlaying(true)}
-          onPause={() => setPlaying(false)}
+          onPlay={() => { setPlaying(true); onPlayingChange?.(true); }}
+          onPause={() => { setPlaying(false); onPlayingChange?.(false); }}
           onLoadedMetadata={() => setDuration(videoRef.current?.duration ?? 0)}
           playsInline
         />