VideoInfoPanel.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. 'use client';
  2. import { Asset } from '@/lib/api';
  3. interface Props {
  4. asset: Asset;
  5. fps: number;
  6. compact?: boolean; // true = show inline row; false = collapsible section
  7. }
  8. function formatBytes(bytes?: number | null): string {
  9. if (!bytes) return '—';
  10. if (bytes < 1024) return `${bytes} B`;
  11. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  12. if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  13. return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
  14. }
  15. function formatDate(iso: string): string {
  16. if (!iso) return '—';
  17. try {
  18. return new Intl.DateTimeFormat('en-US', {
  19. month: 'short',
  20. day: 'numeric',
  21. year: 'numeric',
  22. }).format(new Date(iso));
  23. } catch {
  24. return '—';
  25. }
  26. }
  27. function fmtDur(s?: number | null): string {
  28. if (!s || isNaN(s)) return '—';
  29. const h = Math.floor(s / 3600);
  30. const m = Math.floor((s % 3600) / 60);
  31. const sec = Math.floor(s % 60);
  32. if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
  33. return `${m}:${String(sec).padStart(2,'0')}`;
  34. }
  35. function fmtRes(width?: number | null, height?: number | null): string {
  36. if (!width || !height) return '—';
  37. return `${width} × ${height}`;
  38. }
  39. // Bitrate in bits/s → human-readable string
  40. function fmtBitrate(bps?: number | null): string {
  41. if (!bps || bps <= 0) return '—';
  42. if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
  43. if (bps >= 1_000) return `${(bps / 1_000).toFixed(0)} Kbps`;
  44. return `${bps} bps`;
  45. }
  46. // Codec display name
  47. function fmtCodec(codec?: string | null): string {
  48. if (!codec) return '—';
  49. const c = codec.toUpperCase();
  50. if (c === 'H264' || c === 'AVC') return 'H.264 / AVC';
  51. if (c === 'H265' || c === 'HEVC') return 'H.265 / HEVC';
  52. if (c === 'VP8') return 'VP8';
  53. if (c === 'VP9') return 'VP9';
  54. if (c === 'AV1') return 'AV1';
  55. if (c.startsWith('AUDIO/')) return codec.replace('AUDIO/', '').toUpperCase();
  56. return codec;
  57. }
  58. // Mime type → human label
  59. function fmtMime(mime?: string): string {
  60. if (!mime) return '—';
  61. if (mime === 'video/mp4' || mime === 'video/mp4; codecs=avc1') return 'MP4';
  62. if (mime === 'video/webm') return 'WebM';
  63. if (mime === 'application/x-mpegURL') return 'HLS / M3U8';
  64. if (mime === 'video/quicktime') return 'MOV / QuickTime';
  65. if (mime === 'video/x-msvideo') return 'AVI';
  66. if (mime === 'video/x-matroska') return 'MKV';
  67. return mime;
  68. }
  69. // VideoInfoPanel — collapsible info strip for review page
  70. export function VideoInfoPanel({ asset, fps }: Props) {
  71. const hasVideoRes =
  72. asset.videoWidth != null || asset.videoHeight != null;
  73. const hasFileSize =
  74. asset.fileSize != null && asset.fileSize > 0;
  75. const rows: { label: string; value: string }[] = [
  76. { label: 'Duration', value: fmtDur(asset.duration) },
  77. { label: 'Bitrate', value: fmtBitrate(asset.bitrate) },
  78. { label: 'Resolution', value: fmtRes(asset.videoWidth, asset.videoHeight) },
  79. { label: 'Frame Rate', value: fps ? `${fps} fps` : asset.fps ? `${asset.fps} fps` : '—' },
  80. { label: 'Codec', value: fmtCodec(asset.codec) },
  81. { label: 'Format', value: fmtMime(asset.mimeType) },
  82. { label: 'File Size', value: hasFileSize ? formatBytes(asset.fileSize) : '—' },
  83. { label: 'Uploaded', value: asset.createdAt ? formatDate(asset.createdAt) : '—' },
  84. ];
  85. // Only show rows with real data
  86. const visible = rows.filter(r => r.value !== '—');
  87. if (visible.length === 0) return null;
  88. return (
  89. <div className="px-3 py-2" style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
  90. {/* Info rows — 2-column grid */}
  91. <div className="grid grid-cols-2 gap-x-4 gap-y-1">
  92. {visible.map(({ label, value }) => (
  93. <div key={label} className="flex items-center justify-between gap-2">
  94. <span className="text-[11px] truncate shrink-0" style={{ color: 'var(--text-subtle)' }}>
  95. {label}
  96. </span>
  97. <span className="text-[11px] font-medium text-right shrink-0" style={{ color: 'var(--text)' }}>
  98. {value}
  99. </span>
  100. </div>
  101. ))}
  102. </div>
  103. </div>
  104. );
  105. }