mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat: add audio asset support with recording and upload functionality
- Implemented audio asset handling in the API, including download and deletion routes. - Added audio content type mappings and extraction functions for audio file names and keys. - Enhanced the asset management UI to support audio uploads, including recording capabilities. - Updated the database schema to accommodate audio assets with new enum values. - Integrated audio playback features in the asset list and comment sections. - Improved user experience with drag-and-drop support for audio files.
This commit is contained in:
@@ -47,3 +47,6 @@ next-env.d.ts
|
||||
PROGRESS.md
|
||||
OPTIMIZATIONS.md
|
||||
.kilocode
|
||||
|
||||
# Claude doesn't respect AGENTS.md and I don't want double AGENTS.md files on the repo.
|
||||
CLAUDE.md
|
||||
@@ -15,7 +15,74 @@ import {
|
||||
} from '@/lib/guest-upload-token';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav'];
|
||||
|
||||
// Canonical MIME types accepted
|
||||
const ALLOWED_TYPES = new Set(['audio/webm', 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/mpeg', 'audio/wav']);
|
||||
|
||||
// Normalize known MIME aliases to canonical values
|
||||
const MIME_ALIASES: Record<string, string> = {
|
||||
'audio/wave': 'audio/wav',
|
||||
'audio/vnd.wave': 'audio/wav',
|
||||
'audio/x-wav': 'audio/wav',
|
||||
'audio/x-pn-wav': 'audio/wav',
|
||||
'audio/mp3': 'audio/mpeg',
|
||||
'audio/x-mpeg': 'audio/mpeg',
|
||||
};
|
||||
|
||||
// Map canonical MIME to fallback file extension
|
||||
const MIME_TO_EXT: Record<string, string> = {
|
||||
'audio/webm': 'webm',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/opus': 'opus',
|
||||
'audio/mp4': 'm4a',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/wav': 'wav',
|
||||
};
|
||||
|
||||
// Safe extensions to preserve from original filename (prevents path traversal, allows known types)
|
||||
// Intentionally excludes flac/aac: they have no corresponding MIME in ALLOWED_TYPES and are
|
||||
// never produced by MediaRecorder, so accepting them would create extension/MIME mismatches.
|
||||
const SAFE_AUDIO_EXTENSIONS = new Set(['webm', 'ogg', 'opus', 'mp3', 'm4a', 'mp4', 'wav']);
|
||||
|
||||
// Reject content that looks like HTML/XML/script regardless of the declared MIME type.
|
||||
function isHtmlContent(bytes: Buffer): boolean {
|
||||
const snippet = bytes.toString('latin1', 0, Math.min(bytes.length, 512)).trimStart().slice(0, 50).toLowerCase();
|
||||
return (
|
||||
snippet.startsWith('<!doctype') ||
|
||||
snippet.startsWith('<html') ||
|
||||
snippet.startsWith('<?xml') ||
|
||||
snippet.startsWith('<script') ||
|
||||
snippet.startsWith('<svg')
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the first bytes of the file match known audio container signatures.
|
||||
function hasValidAudioMagicBytes(header: Buffer, mimeType: string): boolean {
|
||||
if (header.length < 8) return false;
|
||||
switch (mimeType) {
|
||||
// WebM / Matroska: EBML header 1a 45 df a3
|
||||
case 'audio/webm':
|
||||
return header[0] === 0x1a && header[1] === 0x45 && header[2] === 0xdf && header[3] === 0xa3;
|
||||
// OGG container (covers ogg vorbis and opus)
|
||||
case 'audio/ogg':
|
||||
case 'audio/opus':
|
||||
return header[0] === 0x4f && header[1] === 0x67 && header[2] === 0x67 && header[3] === 0x53; // "OggS"
|
||||
// MPEG audio: ID3 tag header or raw MPEG sync frame
|
||||
case 'audio/mpeg': {
|
||||
const hasId3 = header[0] === 0x49 && header[1] === 0x44 && header[2] === 0x33; // "ID3"
|
||||
const hasMpegSync = header[0] === 0xff && (header[1] & 0xe0) === 0xe0;
|
||||
return hasId3 || hasMpegSync;
|
||||
}
|
||||
// MP4 / M4A: ISO base media file; "ftyp" box starts at offset 4
|
||||
case 'audio/mp4':
|
||||
return header[4] === 0x66 && header[5] === 0x74 && header[6] === 0x79 && header[7] === 0x70; // "ftyp"
|
||||
// WAV: RIFF header
|
||||
case 'audio/wav':
|
||||
return header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46; // "RIFF"
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -101,14 +168,18 @@ export async function POST(request: NextRequest) {
|
||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||
}
|
||||
|
||||
// Check content type
|
||||
const contentType = file.type || 'audio/webm';
|
||||
if (!ALLOWED_TYPES.includes(contentType)) {
|
||||
return apiErrors.badRequest(`Unsupported audio format: ${contentType}`);
|
||||
// Normalize content type: strip codec params, then resolve aliases
|
||||
const rawContentType = file.type || 'audio/webm';
|
||||
const strippedType = rawContentType.split(';')[0].trim().toLowerCase();
|
||||
const contentType = MIME_ALIASES[strippedType] ?? strippedType;
|
||||
if (!ALLOWED_TYPES.has(contentType)) {
|
||||
return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = contentType.split('/')[1] || 'webm';
|
||||
// Prefer the original file extension when it's a known safe type (e.g. preserve .opus, .mp3)
|
||||
// Fall back to MIME-derived extension for blobs without a real name (e.g. MediaRecorder output)
|
||||
const origExt = (file.name.split('.').pop() ?? '').toLowerCase();
|
||||
const ext = SAFE_AUDIO_EXTENSIONS.has(origExt) ? origExt : (MIME_TO_EXT[contentType] ?? 'webm');
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const key = `voice/${filename}`;
|
||||
|
||||
@@ -116,6 +187,14 @@ export async function POST(request: NextRequest) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Validate file content against magic bytes — rejects HTML/scripts masquerading as audio
|
||||
if (isHtmlContent(buffer)) {
|
||||
return apiErrors.badRequest('File content does not match an audio format');
|
||||
}
|
||||
if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) {
|
||||
return apiErrors.badRequest('File content does not match the declared audio format');
|
||||
}
|
||||
|
||||
// Upload to R2
|
||||
await r2Client.send(
|
||||
new PutObjectCommand({
|
||||
|
||||
@@ -7,19 +7,31 @@ import { fetchWithTimeout, resolveBunnyDownloadSource } from '@/lib/bunny-downlo
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
extractImageFileNameFromProxyUrl,
|
||||
extractAudioFileNameFromProxyUrl,
|
||||
getVideoAssetAccessContext,
|
||||
} from '@/lib/video-assets';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
|
||||
type BunnySourcePreference = 'auto' | 'original' | 'compressed';
|
||||
|
||||
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
const IMAGE_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
};
|
||||
|
||||
const AUDIO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
webm: 'audio/webm',
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/ogg',
|
||||
mp4: 'audio/mp4',
|
||||
m4a: 'audio/mp4',
|
||||
mpeg: 'audio/mpeg',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
};
|
||||
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
@@ -47,7 +59,7 @@ function buildContentDisposition(fileNameWithExt: string): string {
|
||||
|
||||
function imageContentTypeFromFileName(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
|
||||
return IMAGE_CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// GET /api/videos/[videoId]/assets/[assetId]/download
|
||||
@@ -101,6 +113,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
||||
const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl);
|
||||
if (!fileName) return apiErrors.badRequest('Invalid audio asset URL');
|
||||
const key = `voice/${fileName}`;
|
||||
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm';
|
||||
const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`;
|
||||
const contentDisposition = buildContentDisposition(downloadName);
|
||||
const extKey = ext.replace('.', '');
|
||||
const contentType = AUDIO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'audio/webm';
|
||||
|
||||
return proxyR2MediaObject({
|
||||
request,
|
||||
key,
|
||||
fallbackContentType: contentType,
|
||||
cacheControl: 'private, no-store',
|
||||
extraHeaders: {
|
||||
'Content-Disposition': contentDisposition,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
internalErrorMessage: 'Failed to retrieve audio',
|
||||
});
|
||||
}
|
||||
|
||||
const sourceParam = request.nextUrl.searchParams.get('source');
|
||||
const rawQuality = request.nextUrl.searchParams.get('quality');
|
||||
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
let shouldDeleteImageObject = false;
|
||||
let shouldDeleteAudioObject = false;
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.videoAsset.delete({ where: { id: asset.id } });
|
||||
|
||||
@@ -52,12 +53,23 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
]);
|
||||
shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||
}
|
||||
|
||||
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
||||
const [assetReferenceCount, commentReferenceCount] = await Promise.all([
|
||||
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
|
||||
tx.comment.count({ where: { voiceUrl: asset.sourceUrl } }),
|
||||
]);
|
||||
shouldDeleteAudioObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||
}
|
||||
});
|
||||
|
||||
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
||||
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||
}
|
||||
if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) {
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||
}
|
||||
|
||||
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined;
|
||||
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
||||
|
||||
@@ -15,9 +15,12 @@ import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import {
|
||||
SAFE_BUNNY_VIDEO_ID,
|
||||
SAFE_IMAGE_PROXY_PATH,
|
||||
SAFE_AUDIO_PROXY_PATH,
|
||||
canDeleteAssetForViewer,
|
||||
extractImageFileNameFromProxyUrl,
|
||||
extractImageKeyFromProxyUrl,
|
||||
extractAudioKeyFromProxyUrl,
|
||||
extractAudioFileNameFromProxyUrl,
|
||||
getVideoAssetAccessContext,
|
||||
sanitizeAssetDisplayName,
|
||||
} from '@/lib/video-assets';
|
||||
@@ -32,7 +35,7 @@ const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
type AssetWithViewerFields = {
|
||||
id: string;
|
||||
videoId: string;
|
||||
kind: 'IMAGE' | 'VIDEO';
|
||||
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
|
||||
provider: VideoAssetProvider;
|
||||
displayName: string;
|
||||
sourceUrl: string;
|
||||
@@ -159,6 +162,22 @@ async function isFreshImageAttachment(url: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function isFreshAudioAttachment(url: string): Promise<boolean> {
|
||||
const key = extractAudioKeyFromProxyUrl(url);
|
||||
if (!key) return false;
|
||||
|
||||
try {
|
||||
const head = await r2Client.send(new HeadObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
}));
|
||||
if (!head.LastModified) return false;
|
||||
return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/videos/[videoId]/assets
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
@@ -225,7 +244,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const response = successResponse({
|
||||
assets: pagedAssets.map((asset) => shapeAssetForViewer(
|
||||
asset,
|
||||
context.canDownloadAssets,
|
||||
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
|
||||
context.canDownloadAssets || (asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess),
|
||||
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
|
||||
)),
|
||||
pagination: {
|
||||
@@ -259,7 +279,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const provider = typeof body?.provider === 'string' ? body.provider.trim().toUpperCase() : '';
|
||||
|
||||
if (provider !== VideoAssetProvider.R2_IMAGE && provider !== VideoAssetProvider.YOUTUBE && provider !== VideoAssetProvider.BUNNY) {
|
||||
if (
|
||||
provider !== VideoAssetProvider.R2_IMAGE &&
|
||||
provider !== VideoAssetProvider.YOUTUBE &&
|
||||
provider !== VideoAssetProvider.BUNNY &&
|
||||
provider !== VideoAssetProvider.R2_AUDIO
|
||||
) {
|
||||
return apiErrors.badRequest('Invalid provider');
|
||||
}
|
||||
|
||||
@@ -271,7 +296,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
let sourceUrl = '';
|
||||
let providerVideoId: string | null = null;
|
||||
let thumbnailUrl: string | null = null;
|
||||
let kind: 'IMAGE' | 'VIDEO' = 'IMAGE';
|
||||
let kind: 'IMAGE' | 'VIDEO' | 'AUDIO' = 'IMAGE';
|
||||
|
||||
if (provider === VideoAssetProvider.R2_IMAGE) {
|
||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||
@@ -288,6 +313,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
kind = 'IMAGE';
|
||||
}
|
||||
|
||||
if (provider === VideoAssetProvider.R2_AUDIO) {
|
||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||
if (!SAFE_AUDIO_PROXY_PATH.test(sourceUrl)) {
|
||||
return apiErrors.badRequest('Audio URL must reference an uploaded audio file');
|
||||
}
|
||||
if (!(await isFreshAudioAttachment(sourceUrl))) {
|
||||
return apiErrors.badRequest('Audio upload expired. Please upload again.');
|
||||
}
|
||||
|
||||
const fileName = extractAudioFileNameFromProxyUrl(sourceUrl);
|
||||
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Voice Recording');
|
||||
kind = 'AUDIO';
|
||||
}
|
||||
|
||||
if (provider === VideoAssetProvider.YOUTUBE) {
|
||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||
const parsedSource = parseVideoUrl(sourceUrl);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { memo, type ReactNode } from 'react';
|
||||
import { Download, Image as ImageIcon, Loader2, Play, Trash2 } from 'lucide-react';
|
||||
import { Download, Image as ImageIcon, Loader2, Play, Trash2, Volume2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -103,15 +103,15 @@ export const AssetListSection = memo(function AssetListSection({
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
title={asset.kind === 'VIDEO' ? 'Play video' : 'View image'}
|
||||
aria-label={asset.kind === 'VIDEO' ? 'Play video' : 'View image'}
|
||||
title={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
|
||||
aria-label={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
|
||||
onClick={() => onViewAsset(asset)}
|
||||
>
|
||||
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : <Play className="h-3 w-3" />}
|
||||
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : asset.kind === 'AUDIO' ? <Volume2 className="h-3 w-3" /> : <Play className="h-3 w-3" />}
|
||||
</Button>
|
||||
|
||||
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
|
||||
asset.provider === 'BUNNY' ? (
|
||||
asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as tus from 'tus-js-client';
|
||||
import { toast } from 'sonner';
|
||||
import { Download, FileVideo, Image as ImageIcon, Loader2, Play, UploadCloud, X, Youtube } from 'lucide-react';
|
||||
import { Download, FileVideo, Image as ImageIcon, Loader2, Mic, Pause, Play, Square, UploadCloud, Volume2, X, Youtube } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -20,7 +20,14 @@ import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/
|
||||
import { AssetListSection } from '@/components/video-page/asset-list-section';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
|
||||
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const s = Math.floor(seconds);
|
||||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface AssetsPaneProps {
|
||||
videoId: string;
|
||||
@@ -31,9 +38,9 @@ interface AssetsPaneProps {
|
||||
activeDownloadAssetId: string | null;
|
||||
canUploadAssets: boolean;
|
||||
canDownloadAssets: boolean;
|
||||
getGuestUploadToken: (intent: 'image') => Promise<string | null>;
|
||||
getGuestUploadToken: (intent: 'image' | 'audio') => Promise<string | null>;
|
||||
createAsset: (payload: {
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
||||
displayName?: string;
|
||||
sourceUrl: string;
|
||||
providerVideoId?: string;
|
||||
@@ -68,7 +75,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
highlightedAssetId,
|
||||
onHighlightedAssetHandled,
|
||||
}: AssetsPaneProps) {
|
||||
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny'>('image');
|
||||
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny' | 'voice'>('image');
|
||||
const [imageTitle, setImageTitle] = useState('');
|
||||
const [pendingImageFile, setPendingImageFile] = useState<File | null>(null);
|
||||
const [youtubeUrl, setYoutubeUrl] = useState('');
|
||||
@@ -91,6 +98,25 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const bunnyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Voice recording state
|
||||
const [voiceTitle, setVoiceTitle] = useState('');
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
|
||||
const [pendingAudioFile, setPendingAudioFile] = useState<File | null>(null);
|
||||
const [isUploadingVoice, setIsUploadingVoice] = useState(false);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<BlobPart[]>([]);
|
||||
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Drag-drop state
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
// Audio playback for asset preview dialog and recording preview
|
||||
const { playingVoiceId, voiceProgress, voiceCurrentTime, voicePlaybackRate, playVoice, stopVoice, toggleVoiceSpeed } = useCommentMedia();
|
||||
|
||||
const sortedAssets = useMemo(() => {
|
||||
return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
|
||||
}, [assets]);
|
||||
@@ -227,7 +253,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }));
|
||||
}, [bunnyReadyByAssetId, selectedAsset]);
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
const handleImageUpload = useCallback(async (file: File) => {
|
||||
if (!file) return;
|
||||
|
||||
const imageError = validateImageFile(file);
|
||||
@@ -266,7 +292,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
console.error('Failed to upload image asset:', error);
|
||||
toast.error('Failed to upload image');
|
||||
}
|
||||
};
|
||||
}, [videoId, getGuestUploadToken, createAsset, imageTitle]);
|
||||
|
||||
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -307,9 +333,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const handleBunnyFileUpload = useCallback(async (file: File) => {
|
||||
if (!file.type.startsWith('video/')) {
|
||||
toast.error('Please select a video file');
|
||||
return;
|
||||
@@ -403,6 +427,12 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
setIsUploadingBunny(false);
|
||||
setBunnyProgress(0);
|
||||
}
|
||||
}, [videoId, bunnyTitle, bunnyCdnHostname, createAsset]);
|
||||
|
||||
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
await handleBunnyFileUpload(file);
|
||||
};
|
||||
|
||||
const handleBunnyThumbnailError = (assetId: string) => {
|
||||
@@ -425,7 +455,161 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
});
|
||||
};
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm';
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
audioChunksRef.current = [];
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) audioChunksRef.current.push(e.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: mimeType });
|
||||
setAudioBlob(blob);
|
||||
setAudioBlobUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
mediaRecorderRef.current = recorder;
|
||||
recorder.start(100);
|
||||
setIsRecording(true);
|
||||
setRecordingTime(0);
|
||||
recordingTimerRef.current = setInterval(() => setRecordingTime((t) => t + 1), 1000);
|
||||
} catch {
|
||||
toast.error('Could not access microphone');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
mediaRecorderRef.current?.stop();
|
||||
mediaRecorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
mediaRecorderRef.current?.stop();
|
||||
mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop());
|
||||
mediaRecorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
setRecordingTime(0);
|
||||
setAudioBlob(null);
|
||||
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
|
||||
setPendingAudioFile(null);
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVoiceUpload = useCallback(async () => {
|
||||
const uploadSource = pendingAudioFile ?? audioBlob;
|
||||
if (!uploadSource) return;
|
||||
setIsUploadingVoice(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (pendingAudioFile) {
|
||||
formData.append('audio', pendingAudioFile);
|
||||
} else {
|
||||
formData.append('audio', audioBlob!, 'recording.webm');
|
||||
}
|
||||
formData.append('videoId', videoId);
|
||||
const guestUploadToken = await getGuestUploadToken('audio');
|
||||
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
|
||||
|
||||
const uploadRes = await fetch('/api/upload/audio', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null;
|
||||
const uploadedUrl = uploadPayload?.data?.url;
|
||||
if (!uploadRes.ok || !uploadedUrl) {
|
||||
toast.error(uploadPayload?.error || 'Failed to upload voice recording');
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackName = pendingAudioFile
|
||||
? pendingAudioFile.name.replace(/\.[^/.]+$/, '')
|
||||
: 'Voice Recording';
|
||||
await createAsset({
|
||||
provider: 'R2_AUDIO',
|
||||
sourceUrl: uploadedUrl,
|
||||
displayName: voiceTitle.trim() || fallbackName,
|
||||
});
|
||||
setVoiceTitle('');
|
||||
setAudioBlob(null);
|
||||
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
|
||||
setPendingAudioFile(null);
|
||||
} catch {
|
||||
toast.error('Failed to upload voice recording');
|
||||
} finally {
|
||||
setIsUploadingVoice(false);
|
||||
}
|
||||
}, [pendingAudioFile, audioBlob, videoId, getGuestUploadToken, createAsset, voiceTitle]);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canUploadAssets) return;
|
||||
dragCounterRef.current += 1;
|
||||
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
|
||||
}, [canUploadAssets]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current === 0) setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
if (!canUploadAssets) return;
|
||||
|
||||
const file = Array.from(e.dataTransfer.files)[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
const imageError = validateImageFile(file);
|
||||
if (imageError) { toast.error(imageError); return; }
|
||||
// Stage the file so the user can optionally set a name before uploading
|
||||
setUploadTab('image');
|
||||
setPendingImageFile(file);
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
// Videos upload immediately (large files, no staging)
|
||||
setUploadTab('bunny');
|
||||
await handleBunnyFileUpload(file);
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
// Stage the file so the user can optionally set a name before uploading
|
||||
setUploadTab('voice');
|
||||
setPendingAudioFile(file);
|
||||
} else {
|
||||
toast.error('Unsupported file type. Drop an image, video, or audio file.');
|
||||
}
|
||||
}, [canUploadAssets, handleBunnyFileUpload]);
|
||||
|
||||
const renderAssetPreview = (asset: VideoAsset) => {
|
||||
if (asset.kind === 'AUDIO') {
|
||||
return (
|
||||
<div className="h-24 w-36 rounded border bg-muted flex flex-col items-center justify-center gap-1">
|
||||
<Volume2 className="h-6 w-6 text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Voice Recording</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.kind === 'IMAGE') {
|
||||
const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
|
||||
return (
|
||||
@@ -501,6 +685,10 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
setPreviewImageTitle(asset.displayName);
|
||||
return;
|
||||
}
|
||||
if (asset.kind === 'AUDIO') {
|
||||
setSelectedAsset(asset);
|
||||
return;
|
||||
}
|
||||
if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) {
|
||||
setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true }));
|
||||
}
|
||||
@@ -513,7 +701,14 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" onPaste={handleImagePaste}>
|
||||
<div
|
||||
className="space-y-4"
|
||||
onPaste={handleImagePaste}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Assets</span>
|
||||
@@ -522,12 +717,19 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
</div>
|
||||
|
||||
{canUploadAssets ? (
|
||||
<div className="rounded-lg border p-3 space-y-3">
|
||||
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny')}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<div className={cn('rounded-lg border p-3 space-y-3 relative transition-colors', isDragOver && 'border-primary bg-primary/5')}>
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-primary/10 border-2 border-dashed border-primary pointer-events-none">
|
||||
<UploadCloud className="h-8 w-8 text-primary" />
|
||||
<span className="text-sm font-medium text-primary">Drop to upload</span>
|
||||
</div>
|
||||
)}
|
||||
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny' | 'voice')}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="image">Image</TabsTrigger>
|
||||
<TabsTrigger value="youtube">YouTube</TabsTrigger>
|
||||
<TabsTrigger value="bunny">Video</TabsTrigger>
|
||||
<TabsTrigger value="voice">Voice</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@@ -636,6 +838,111 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadTab === 'voice' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Optional name for this recording"
|
||||
value={voiceTitle}
|
||||
onChange={(e) => setVoiceTitle(e.target.value)}
|
||||
disabled={isRecording || isUploadingVoice}
|
||||
/>
|
||||
{pendingAudioFile ? (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
|
||||
<span className="truncate">Attached: {pendingAudioFile.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setPendingAudioFile(null)}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={isUploadingVoice || isCreatingAsset}
|
||||
onClick={handleVoiceUpload}
|
||||
>
|
||||
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
|
||||
{isUploadingVoice ? 'Uploading...' : 'Upload File'}
|
||||
</Button>
|
||||
</div>
|
||||
) : isRecording ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
|
||||
</span>
|
||||
<span className="text-red-500 font-medium">Recording</span>
|
||||
<span className="ml-auto tabular-nums text-muted-foreground">
|
||||
{String(Math.floor(recordingTime / 60)).padStart(2, '0')}:{String(recordingTime % 60).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="icon" variant="outline" className="h-9 w-9 shrink-0" title="Stop recording" onClick={stopRecording}>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-9 w-9 shrink-0" title="Cancel recording" onClick={cancelRecording}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : audioBlob ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 p-2 bg-muted rounded">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => audioBlobUrl && playVoice('recording-preview', audioBlobUrl, recordingTime)}
|
||||
>
|
||||
{playingVoiceId === 'recording-preview' ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: playingVoiceId === 'recording-preview' ? `${voiceProgress}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||
{playingVoiceId === 'recording-preview'
|
||||
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
|
||||
: formatTime(recordingTime)}
|
||||
</span>
|
||||
{playingVoiceId === 'recording-preview' && (
|
||||
<button
|
||||
onClick={toggleVoiceSpeed}
|
||||
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
|
||||
>
|
||||
{voicePlaybackRate}x
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={isUploadingVoice || isCreatingAsset}
|
||||
onClick={handleVoiceUpload}
|
||||
>
|
||||
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
|
||||
{isUploadingVoice ? 'Uploading...' : 'Upload Recording'}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" title="Discard and re-record" onClick={cancelRecording}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full" onClick={startRecording} disabled={isUploadingVoice || isCreatingAsset}>
|
||||
<Mic className="h-4 w-4 mr-2" />
|
||||
Start Recording
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Or drag an audio file anywhere onto this panel.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border p-3 text-xs text-muted-foreground">
|
||||
@@ -672,6 +979,43 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={selectedAsset?.kind === 'AUDIO'} onOpenChange={(open) => { if (!open) { stopVoice(); setSelectedAsset(null); } }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogTitle>{selectedAsset?.displayName || 'Voice Recording'}</DialogTitle>
|
||||
{selectedAsset?.sourceUrl ? (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted rounded">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => selectedAsset.sourceUrl && playVoice(selectedAsset.id, selectedAsset.sourceUrl)}
|
||||
>
|
||||
{playingVoiceId === selectedAsset?.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: playingVoiceId === selectedAsset?.id ? `${voiceProgress}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||
{playingVoiceId === selectedAsset?.id ? formatTime(voiceCurrentTime) : '00:00'}
|
||||
</span>
|
||||
{playingVoiceId === selectedAsset?.id && (
|
||||
<button
|
||||
onClick={toggleVoiceSpeed}
|
||||
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
|
||||
>
|
||||
{voicePlaybackRate}x
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Audio preview unavailable.</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Image as ImageIcon, Video } from 'lucide-react';
|
||||
import { Image as ImageIcon, Video, Volume2 } from 'lucide-react';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
|
||||
@@ -50,7 +50,7 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
|
||||
const assetId = match[2] || '';
|
||||
const matchedAsset = assets.find((asset) => asset.id === assetId);
|
||||
const label = matchedAsset?.displayName || fallbackLabel;
|
||||
const isVideoAsset = matchedAsset?.kind === 'VIDEO';
|
||||
const assetKind = matchedAsset?.kind;
|
||||
|
||||
nodes.push(
|
||||
<button
|
||||
@@ -65,12 +65,14 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isVideoAsset
|
||||
assetKind === 'VIDEO'
|
||||
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-violet-500/25 text-violet-200'
|
||||
: assetKind === 'AUDIO'
|
||||
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-blue-500/25 text-blue-200'
|
||||
: 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-emerald-500/25 text-emerald-200'
|
||||
}
|
||||
>
|
||||
{isVideoAsset ? <Video className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
|
||||
{assetKind === 'VIDEO' ? <Video className="h-2.5 w-2.5" /> : assetKind === 'AUDIO' ? <Volume2 className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className="truncate max-w-[190px] sm:max-w-[240px]">
|
||||
@{label}
|
||||
|
||||
@@ -86,6 +86,17 @@ export function useCommentMedia() {
|
||||
void audio.play();
|
||||
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
|
||||
|
||||
const stopVoice = useCallback(() => {
|
||||
if (audioPlayerRef.current) {
|
||||
audioPlayerRef.current.pause();
|
||||
audioPlayerRef.current = null;
|
||||
}
|
||||
stopVoiceTracking();
|
||||
setPlayingVoiceId(null);
|
||||
setVoiceProgress(0);
|
||||
setVoiceCurrentTime(0);
|
||||
}, [stopVoiceTracking]);
|
||||
|
||||
const toggleVoiceSpeed = useCallback(() => {
|
||||
setVoicePlaybackRate((prev) => {
|
||||
const next = prev === 1 ? 2 : 1;
|
||||
@@ -112,6 +123,7 @@ export function useCommentMedia() {
|
||||
voiceCurrentTime,
|
||||
voicePlaybackRate,
|
||||
playVoice,
|
||||
stopVoice,
|
||||
toggleVoiceSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { VideoAsset } from '@/components/video-page/types';
|
||||
type BunnyDownloadPreference = 'original' | 'compressed';
|
||||
|
||||
type CreateAssetPayload = {
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
||||
displayName?: string;
|
||||
sourceUrl: string;
|
||||
providerVideoId?: string;
|
||||
@@ -240,7 +240,7 @@ export function useVideoAssets({
|
||||
}
|
||||
}, [canDownloadAssets, videoId]);
|
||||
|
||||
const getGuestUploadToken = useCallback(async (intent: 'image') => {
|
||||
const getGuestUploadToken = useCallback(async (intent: 'image' | 'audio') => {
|
||||
if (isAuthenticated) return null;
|
||||
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -21,8 +21,8 @@ export interface CommentTag {
|
||||
export interface VideoAsset {
|
||||
id: string;
|
||||
videoId: string;
|
||||
kind: 'IMAGE' | 'VIDEO';
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
||||
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
||||
displayName: string;
|
||||
sourceUrl: string | null;
|
||||
providerVideoId: string | null;
|
||||
|
||||
+29
-2
@@ -257,7 +257,7 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
||||
const snapshot = await getR2StorageSnapshot();
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
const [mediaComments, mediaAssets] = await Promise.all([
|
||||
const [mediaComments, imageAssets, audioAssets] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
||||
select: {
|
||||
@@ -287,6 +287,13 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
||||
billedUserId: true,
|
||||
},
|
||||
}),
|
||||
db.videoAsset.findMany({
|
||||
where: { provider: 'R2_AUDIO' },
|
||||
select: {
|
||||
sourceUrl: true,
|
||||
billedUserId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
for (const comment of mediaComments) {
|
||||
@@ -324,7 +331,7 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of mediaAssets) {
|
||||
for (const asset of imageAssets) {
|
||||
const billedUserId = asset.billedUserId;
|
||||
if (!billedUserId) continue;
|
||||
if (!userStorage[billedUserId]) {
|
||||
@@ -343,6 +350,26 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
||||
userStorage[billedUserId].image += size;
|
||||
userStorage[billedUserId].total += size;
|
||||
}
|
||||
|
||||
for (const asset of audioAssets) {
|
||||
const billedUserId = asset.billedUserId;
|
||||
if (!billedUserId) continue;
|
||||
if (!userStorage[billedUserId]) {
|
||||
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
|
||||
}
|
||||
|
||||
const keyParts = asset.sourceUrl.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
if (!filename) continue;
|
||||
const r2Key = `voice/${filename}`;
|
||||
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||
if (seenKeys.has(dedupeKey)) continue;
|
||||
seenKeys.add(dedupeKey);
|
||||
|
||||
const size = snapshot.fileSizes.get(r2Key) || 0;
|
||||
userStorage[billedUserId].voice += size;
|
||||
userStorage[billedUserId].total += size;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse user storage:', err);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const IMAGE_PROXY_PREFIX = '/api/upload/image/';
|
||||
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
||||
|
||||
export const SAFE_IMAGE_PROXY_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
export const SAFE_AUDIO_PROXY_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
||||
|
||||
export type VideoAssetAccessContext = {
|
||||
@@ -61,6 +62,19 @@ export function extractImageFileNameFromProxyUrl(url: string): string | null {
|
||||
return filename || null;
|
||||
}
|
||||
|
||||
export function extractAudioKeyFromProxyUrl(url: string): string | null {
|
||||
if (!SAFE_AUDIO_PROXY_PATH.test(url)) return null;
|
||||
const filename = url.slice(AUDIO_PROXY_PREFIX.length);
|
||||
if (!filename) return null;
|
||||
return `voice/${filename}`;
|
||||
}
|
||||
|
||||
export function extractAudioFileNameFromProxyUrl(url: string): string | null {
|
||||
if (!SAFE_AUDIO_PROXY_PATH.test(url)) return null;
|
||||
const filename = url.slice(AUDIO_PROXY_PREFIX.length);
|
||||
return filename || null;
|
||||
}
|
||||
|
||||
export function mediaUrlToR2Key(url: string): string | null {
|
||||
if (url.includes(IMAGE_PROXY_PREFIX)) {
|
||||
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "VideoAssetKind" ADD VALUE 'AUDIO';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "VideoAssetProvider" ADD VALUE 'R2_AUDIO';
|
||||
Reference in New Issue
Block a user