mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #76 from yusufipk/claude/webm-to-wav-conversion-0169ff
feat(voice-notes): convert recordings to WAV on download
This commit is contained in:
@@ -104,8 +104,10 @@ export function VideoPageContent({
|
|||||||
voiceProgress,
|
voiceProgress,
|
||||||
voiceCurrentTime,
|
voiceCurrentTime,
|
||||||
voicePlaybackRate,
|
voicePlaybackRate,
|
||||||
|
downloadingVoiceIds,
|
||||||
playVoice,
|
playVoice,
|
||||||
toggleVoiceSpeed,
|
toggleVoiceSpeed,
|
||||||
|
downloadVoice,
|
||||||
} = useCommentMedia();
|
} = useCommentMedia();
|
||||||
const [showResolved, setShowResolved] = useState(false);
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
|
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
|
||||||
@@ -926,6 +928,9 @@ export function VideoPageContent({
|
|||||||
handleEditComment={commentsActions.onEditComment}
|
handleEditComment={commentsActions.onEditComment}
|
||||||
handleDeleteComment={commentsActions.onDeleteComment}
|
handleDeleteComment={commentsActions.onDeleteComment}
|
||||||
playVoice={playVoice}
|
playVoice={playVoice}
|
||||||
|
downloadVoice={downloadVoice}
|
||||||
|
downloadingVoiceIds={downloadingVoiceIds}
|
||||||
|
canDownloadVoiceNotes={canDownloadAssets}
|
||||||
playingVoiceId={playingVoiceId}
|
playingVoiceId={playingVoiceId}
|
||||||
voiceProgress={voiceProgress}
|
voiceProgress={voiceProgress}
|
||||||
voiceCurrentTime={voiceCurrentTime}
|
voiceCurrentTime={voiceCurrentTime}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { VideoAsset } from '@/components/video-page/types';
|
import type { AssetDownloadPreference, VideoAsset } from '@/components/video-page/types';
|
||||||
|
|
||||||
interface AssetListSectionProps {
|
interface AssetListSectionProps {
|
||||||
assets: VideoAsset[];
|
assets: VideoAsset[];
|
||||||
@@ -25,12 +25,88 @@ interface AssetListSectionProps {
|
|||||||
hasMoreAssets: boolean;
|
hasMoreAssets: boolean;
|
||||||
isLoadingMoreAssets: boolean;
|
isLoadingMoreAssets: boolean;
|
||||||
onViewAsset: (asset: VideoAsset) => void;
|
onViewAsset: (asset: VideoAsset) => void;
|
||||||
onDownloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => void;
|
onDownloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => void;
|
||||||
onDeleteAsset: (assetId: string) => void;
|
onDeleteAsset: (assetId: string) => void;
|
||||||
onLoadMoreAssets: () => void;
|
onLoadMoreAssets: () => void;
|
||||||
renderAssetPreview: (asset: VideoAsset) => ReactNode;
|
renderAssetPreview: (asset: VideoAsset) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three shapes of download. A Bunny video offers the original or the compressed
|
||||||
|
* rendition; a voice note offers WAV (converted in the browser, because no
|
||||||
|
* editing suite opens the WebM/Opus we store) or the file as recorded;
|
||||||
|
* everything else is a single button.
|
||||||
|
*/
|
||||||
|
function AssetDownloadControl({
|
||||||
|
asset,
|
||||||
|
isBusy,
|
||||||
|
onDownloadAsset,
|
||||||
|
}: {
|
||||||
|
asset: VideoAsset;
|
||||||
|
isBusy: boolean;
|
||||||
|
onDownloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => void;
|
||||||
|
}) {
|
||||||
|
const options: { preference: AssetDownloadPreference; label: string; hint?: string }[] =
|
||||||
|
asset.provider === 'BUNNY' && asset.kind !== 'AUDIO'
|
||||||
|
? [
|
||||||
|
{ preference: 'original', label: 'Original' },
|
||||||
|
{ preference: 'compressed', label: 'Compressed' },
|
||||||
|
]
|
||||||
|
: asset.provider === 'R2_AUDIO'
|
||||||
|
? [
|
||||||
|
{ preference: 'wav', label: 'WAV', hint: 'for editing software' },
|
||||||
|
{ preference: 'original', label: 'Original' },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title="Download asset"
|
||||||
|
aria-label="Download asset"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => onDownloadAsset(asset)}
|
||||||
|
>
|
||||||
|
{isBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title="Download asset"
|
||||||
|
aria-label="Download asset"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{isBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
{options.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.preference}
|
||||||
|
onClick={() => onDownloadAsset(asset, option.preference)}
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3 mr-2" />
|
||||||
|
{option.label}
|
||||||
|
{option.hint && (
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">{option.hint}</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const AssetListSection = memo(function AssetListSection({
|
export const AssetListSection = memo(function AssetListSection({
|
||||||
assets,
|
assets,
|
||||||
isLoadingAssets,
|
isLoadingAssets,
|
||||||
@@ -130,54 +206,13 @@ export const AssetListSection = memo(function AssetListSection({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{canDownloadAssets &&
|
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
|
||||||
asset.provider !== 'YOUTUBE' &&
|
<AssetDownloadControl
|
||||||
(asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
|
asset={asset}
|
||||||
<DropdownMenu>
|
isBusy={activeDownloadAssetId === asset.id || isBunnyProcessing}
|
||||||
<DropdownMenuTrigger asChild>
|
onDownloadAsset={onDownloadAsset}
|
||||||
<Button
|
/>
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-7 w-7"
|
|
||||||
title="Download asset"
|
|
||||||
aria-label="Download asset"
|
|
||||||
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
|
|
||||||
>
|
|
||||||
{activeDownloadAssetId === asset.id ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-3 w-3" />
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'original')}>
|
|
||||||
<Download className="h-3 w-3 mr-2" />
|
|
||||||
Original
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'compressed')}>
|
|
||||||
<Download className="h-3 w-3 mr-2" />
|
|
||||||
Compressed
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-7 w-7"
|
|
||||||
title="Download asset"
|
|
||||||
aria-label="Download asset"
|
|
||||||
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
|
|
||||||
onClick={() => onDownloadAsset(asset)}
|
|
||||||
>
|
|
||||||
{activeDownloadAssetId === asset.id ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{asset.canDelete && (
|
{asset.canDelete && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ import {
|
|||||||
type BunnyPreviewPlayerHandle,
|
type BunnyPreviewPlayerHandle,
|
||||||
} from '@/components/video-page/bunny-preview-player';
|
} from '@/components/video-page/bunny-preview-player';
|
||||||
import { AssetListSection } from '@/components/video-page/asset-list-section';
|
import { AssetListSection } from '@/components/video-page/asset-list-section';
|
||||||
import type { DirectUploadProvider, VideoAsset } from '@/components/video-page/types';
|
import type {
|
||||||
|
AssetDownloadPreference,
|
||||||
|
DirectUploadProvider,
|
||||||
|
VideoAsset,
|
||||||
|
} from '@/components/video-page/types';
|
||||||
import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload';
|
import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload';
|
||||||
import {
|
import {
|
||||||
extractPastedImageFiles,
|
extractPastedImageFiles,
|
||||||
@@ -101,7 +105,7 @@ interface AssetsPaneProps {
|
|||||||
reservationId?: string | null;
|
reservationId?: string | null;
|
||||||
}) => Promise<VideoAsset | null>;
|
}) => Promise<VideoAsset | null>;
|
||||||
deleteAsset: (assetId: string) => Promise<boolean>;
|
deleteAsset: (assetId: string) => Promise<boolean>;
|
||||||
downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>;
|
downloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => Promise<void>;
|
||||||
hasMoreAssets: boolean;
|
hasMoreAssets: boolean;
|
||||||
isLoadingMoreAssets: boolean;
|
isLoadingMoreAssets: boolean;
|
||||||
loadMoreAssets: () => Promise<void>;
|
loadMoreAssets: () => Promise<void>;
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ interface CommentsPaneProps {
|
|||||||
handleEditComment: (commentId: string) => void;
|
handleEditComment: (commentId: string) => void;
|
||||||
handleDeleteComment: (commentId: string) => void;
|
handleDeleteComment: (commentId: string) => void;
|
||||||
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
|
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
|
||||||
|
downloadVoice: (commentId: string, voiceUrl: string, baseName: string) => void;
|
||||||
|
downloadingVoiceIds: ReadonlySet<string>;
|
||||||
|
/** Same gate as the video and asset downloads: a project or share link with
|
||||||
|
* downloads disabled must not offer to save voice notes either. */
|
||||||
|
canDownloadVoiceNotes: boolean;
|
||||||
playingVoiceId: string | null;
|
playingVoiceId: string | null;
|
||||||
voiceProgress: number;
|
voiceProgress: number;
|
||||||
voiceCurrentTime: number;
|
voiceCurrentTime: number;
|
||||||
@@ -136,6 +141,18 @@ interface CommentsPaneProps {
|
|||||||
assetsPane: ReactNode;
|
assetsPane: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the download after the reviewer and the frame they were talking about,
|
||||||
|
* so a folder of voice notes still makes sense next to the cut.
|
||||||
|
*/
|
||||||
|
function voiceNoteFileName(
|
||||||
|
entry: { timestamp: number; author: { name: string | null } | null; guestName: string | null },
|
||||||
|
formatTime: (seconds: number) => string
|
||||||
|
): string {
|
||||||
|
const who = entry.author?.name || entry.guestName || 'guest';
|
||||||
|
return `voice-${who}-${formatTime(entry.timestamp).replace(/:/g, '-')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const CommentsPane = memo(function CommentsPane({
|
export const CommentsPane = memo(function CommentsPane({
|
||||||
isMobileCommentsOpen,
|
isMobileCommentsOpen,
|
||||||
setIsMobileCommentsOpen,
|
setIsMobileCommentsOpen,
|
||||||
@@ -174,6 +191,9 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
handleEditComment,
|
handleEditComment,
|
||||||
handleDeleteComment,
|
handleDeleteComment,
|
||||||
playVoice,
|
playVoice,
|
||||||
|
downloadVoice,
|
||||||
|
downloadingVoiceIds,
|
||||||
|
canDownloadVoiceNotes,
|
||||||
playingVoiceId,
|
playingVoiceId,
|
||||||
voiceProgress,
|
voiceProgress,
|
||||||
voiceCurrentTime,
|
voiceCurrentTime,
|
||||||
@@ -680,6 +700,29 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
{voicePlaybackRate}x
|
{voicePlaybackRate}x
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canDownloadVoiceNotes && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
title="Download as WAV"
|
||||||
|
aria-label="Download voice note as WAV"
|
||||||
|
disabled={downloadingVoiceIds.has(comment.id)}
|
||||||
|
onClick={() =>
|
||||||
|
downloadVoice(
|
||||||
|
comment.id,
|
||||||
|
comment.voiceUrl!,
|
||||||
|
voiceNoteFileName(comment, formatTime)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{downloadingVoiceIds.has(comment.id) ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -904,6 +947,29 @@ export const CommentsPane = memo(function CommentsPane({
|
|||||||
{voicePlaybackRate}x
|
{voicePlaybackRate}x
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canDownloadVoiceNotes && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
title="Download as WAV"
|
||||||
|
aria-label="Download voice note as WAV"
|
||||||
|
disabled={downloadingVoiceIds.has(reply.id)}
|
||||||
|
onClick={() =>
|
||||||
|
downloadVoice(
|
||||||
|
reply.id,
|
||||||
|
reply.voiceUrl!,
|
||||||
|
voiceNoteFileName(reply, formatTime)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{downloadingVoiceIds.has(reply.id) ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { downloadAudioAsWav } from '@/lib/client/download-file';
|
||||||
|
|
||||||
export function useCommentMedia() {
|
export function useCommentMedia() {
|
||||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||||
const [voiceProgress, setVoiceProgress] = useState(0);
|
const [voiceProgress, setVoiceProgress] = useState(0);
|
||||||
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
|
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
|
||||||
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
|
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
|
||||||
|
// A set, not a single id: two downloads can be in flight at once, and one
|
||||||
|
// finishing must not clear the other's spinner and re-enable its button.
|
||||||
|
const [downloadingVoiceIds, setDownloadingVoiceIds] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set()
|
||||||
|
);
|
||||||
|
|
||||||
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const voiceRafRef = useRef<number | null>(null);
|
const voiceRafRef = useRef<number | null>(null);
|
||||||
@@ -121,13 +128,41 @@ export function useCommentMedia() {
|
|||||||
};
|
};
|
||||||
}, [stopVoiceTracking]);
|
}, [stopVoiceTracking]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice notes are stored the way MediaRecorder wrote them, and an editor
|
||||||
|
* cannot import WebM/Opus. Hand over a WAV instead, converted in the browser
|
||||||
|
* from the file it already knows how to decode.
|
||||||
|
*/
|
||||||
|
const downloadVoice = useCallback(
|
||||||
|
async (commentId: string, voiceUrl: string, baseName: string) => {
|
||||||
|
setDownloadingVoiceIds((prev) => new Set(prev).add(commentId));
|
||||||
|
try {
|
||||||
|
const result = await downloadAudioAsWav(voiceUrl, baseName);
|
||||||
|
if (result === 'failed') {
|
||||||
|
toast.error('Failed to download voice note');
|
||||||
|
} else if (result === 'conversion-unsupported') {
|
||||||
|
toast.warning('This browser cannot convert the recording. Downloaded the original.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDownloadingVoiceIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(commentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playingVoiceId,
|
playingVoiceId,
|
||||||
voiceProgress,
|
voiceProgress,
|
||||||
voiceCurrentTime,
|
voiceCurrentTime,
|
||||||
voicePlaybackRate,
|
voicePlaybackRate,
|
||||||
|
downloadingVoiceIds,
|
||||||
playVoice,
|
playVoice,
|
||||||
stopVoice,
|
stopVoice,
|
||||||
toggleVoiceSpeed,
|
toggleVoiceSpeed,
|
||||||
|
downloadVoice,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
downloadProgressPercent,
|
downloadProgressPercent,
|
||||||
extensionFromUrl,
|
extensionFromUrl,
|
||||||
navigateDownload,
|
navigateDownload,
|
||||||
|
sanitizeDownloadFileName,
|
||||||
} from '@/lib/client/download-file';
|
} from '@/lib/client/download-file';
|
||||||
import {
|
import {
|
||||||
createDownloadProgressToast,
|
createDownloadProgressToast,
|
||||||
@@ -24,13 +25,6 @@ import {
|
|||||||
} from '@/components/download-progress-toast';
|
} from '@/components/download-progress-toast';
|
||||||
import { beginUnloadGuard } from '@/lib/client/unload-guard';
|
import { beginUnloadGuard } from '@/lib/client/unload-guard';
|
||||||
|
|
||||||
function sanitizeDownloadFileName(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllowedHosts() {
|
function getAllowedHosts() {
|
||||||
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { VideoAsset } from '@/components/video-page/types';
|
import type { AssetDownloadPreference, VideoAsset } from '@/components/video-page/types';
|
||||||
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
|
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||||
|
import { downloadAudioAsWav } from '@/lib/client/download-file';
|
||||||
type BunnyDownloadPreference = 'original' | 'compressed';
|
|
||||||
|
|
||||||
type CreateAssetPayload = {
|
type CreateAssetPayload = {
|
||||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
|
||||||
@@ -234,7 +233,7 @@ export function useVideoAssets({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const downloadAsset = useCallback(
|
const downloadAsset = useCallback(
|
||||||
async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
|
async (asset: VideoAsset, preference: AssetDownloadPreference = 'compressed') => {
|
||||||
if (!canDownloadAssets) {
|
if (!canDownloadAssets) {
|
||||||
toast.error('Asset downloads require an authenticated account');
|
toast.error('Asset downloads require an authenticated account');
|
||||||
return;
|
return;
|
||||||
@@ -248,6 +247,20 @@ export function useVideoAssets({
|
|||||||
try {
|
try {
|
||||||
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
|
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
|
||||||
|
|
||||||
|
// A voice note is stored as MediaRecorder wrote it, and no editing suite
|
||||||
|
// reads WebM/Opus. Convert it in the browser so the download opens in the
|
||||||
|
// timeline it was recorded for; 'original' is there for anyone who wants
|
||||||
|
// the stored bytes instead.
|
||||||
|
if (asset.provider === 'R2_AUDIO' && preference !== 'original') {
|
||||||
|
const result = await downloadAudioAsWav(downloadUrl, asset.displayName);
|
||||||
|
if (result === 'failed') {
|
||||||
|
toast.error('Failed to download voice note');
|
||||||
|
} else if (result === 'conversion-unsupported') {
|
||||||
|
toast.warning('This browser cannot convert the recording. Downloaded the original.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (asset.provider === 'BUNNY') {
|
if (asset.provider === 'BUNNY') {
|
||||||
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, {
|
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ export interface BunnyQualityOption {
|
|||||||
export type BunnyPlaybackState = 'none' | 'processing' | 'error';
|
export type BunnyPlaybackState = 'none' | 'processing' | 'error';
|
||||||
export type BunnyDownloadPreference = 'original' | 'compressed';
|
export type BunnyDownloadPreference = 'original' | 'compressed';
|
||||||
export type DownloadTarget = BunnyDownloadPreference | 'direct';
|
export type DownloadTarget = BunnyDownloadPreference | 'direct';
|
||||||
|
/** Voice notes add one more option: converted to WAV in the browser on the way out. */
|
||||||
|
export type AssetDownloadPreference = BunnyDownloadPreference | 'wav';
|
||||||
|
|
||||||
export interface CommentMarker {
|
export interface CommentMarker {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* MediaRecorder hands us WebM/Opus (MP4/AAC on Safari). Browsers and desktop
|
||||||
|
* players read both; editing suites read neither. DaVinci Resolve, Premiere and
|
||||||
|
* Final Cut all refuse the container outright, so a voice note downloaded
|
||||||
|
* byte-for-byte is useless to the editor it was recorded for.
|
||||||
|
*
|
||||||
|
* The browser already decodes these formats in order to play them, so the whole
|
||||||
|
* conversion costs us is a RIFF header: decode to PCM through the Web Audio API,
|
||||||
|
* then write the samples back out as a WAV. Nothing is transcoded server side
|
||||||
|
* and the stored object stays the small Opus file, which is why this runs at
|
||||||
|
* download time rather than at record time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WAV_HEADER_BYTES = 44;
|
||||||
|
const BYTES_PER_SAMPLE = 2; // 16-bit PCM
|
||||||
|
const PCM_FORMAT_TAG = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoding holds the float PCM and the encoded copy in memory at once, roughly
|
||||||
|
* three times the size of the WAV. A 10MB Opus upload is over half an hour of
|
||||||
|
* speech, so cap the output rather than let a long recording take the tab down.
|
||||||
|
*/
|
||||||
|
export const MAX_WAV_OUTPUT_BYTES = 400 * 1024 * 1024;
|
||||||
|
|
||||||
|
function writeAscii(view: DataView, offset: number, text: string): void {
|
||||||
|
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wavByteLength(frameCount: number, channelCount: number): number {
|
||||||
|
return WAV_HEADER_BYTES + frameCount * channelCount * BYTES_PER_SAMPLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interleaved 16-bit PCM in a RIFF container: the one audio format every NLE on
|
||||||
|
* the market imports without an argument. `channels` holds one Float32Array of
|
||||||
|
* samples per channel, all the same length.
|
||||||
|
*/
|
||||||
|
export function encodeWav(channels: Float32Array[], sampleRate: number): Blob {
|
||||||
|
const channelCount = channels.length;
|
||||||
|
if (channelCount === 0 || !Number.isFinite(sampleRate) || sampleRate <= 0) {
|
||||||
|
throw new Error('encodeWav needs at least one channel and a positive sample rate');
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameCount = channels[0].length;
|
||||||
|
const blockAlign = channelCount * BYTES_PER_SAMPLE;
|
||||||
|
const dataBytes = frameCount * blockAlign;
|
||||||
|
const buffer = new ArrayBuffer(WAV_HEADER_BYTES + dataBytes);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
writeAscii(view, 0, 'RIFF');
|
||||||
|
// Everything after this field, i.e. the file minus the 8-byte RIFF preamble.
|
||||||
|
view.setUint32(4, 36 + dataBytes, true);
|
||||||
|
writeAscii(view, 8, 'WAVE');
|
||||||
|
writeAscii(view, 12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true); // fmt chunk payload size for PCM
|
||||||
|
view.setUint16(20, PCM_FORMAT_TAG, true);
|
||||||
|
view.setUint16(22, channelCount, true);
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||||
|
view.setUint16(32, blockAlign, true);
|
||||||
|
view.setUint16(34, BYTES_PER_SAMPLE * 8, true);
|
||||||
|
writeAscii(view, 36, 'data');
|
||||||
|
view.setUint32(40, dataBytes, true);
|
||||||
|
|
||||||
|
let offset = WAV_HEADER_BYTES;
|
||||||
|
for (let frame = 0; frame < frameCount; frame++) {
|
||||||
|
for (let channel = 0; channel < channelCount; channel++) {
|
||||||
|
// Decoded samples can overshoot ±1. Scaled unclamped they wrap to the
|
||||||
|
// opposite rail, and a loud passage comes out as a burst of noise.
|
||||||
|
const sample = Math.max(-1, Math.min(1, channels[channel][frame] ?? 0));
|
||||||
|
// The negative rail reaches one step further than the positive one, so the
|
||||||
|
// two directions take different scale factors to stay symmetric.
|
||||||
|
view.setInt16(offset, Math.round(sample < 0 ? sample * 0x8000 : sample * 0x7fff), true);
|
||||||
|
offset += BYTES_PER_SAMPLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([buffer], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfflineAudioContextConstructor = new (
|
||||||
|
channels: number,
|
||||||
|
length: number,
|
||||||
|
sampleRate: number
|
||||||
|
) => OfflineAudioContext;
|
||||||
|
|
||||||
|
function getOfflineAudioContext(): OfflineAudioContextConstructor | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const scope = window as unknown as {
|
||||||
|
OfflineAudioContext?: OfflineAudioContextConstructor;
|
||||||
|
webkitOfflineAudioContext?: OfflineAudioContextConstructor;
|
||||||
|
};
|
||||||
|
return scope.OfflineAudioContext ?? scope.webkitOfflineAudioContext ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes any audio blob the browser can play and returns it as a WAV, or null
|
||||||
|
* when this browser cannot decode that format (older Safari has no WebM/Opus
|
||||||
|
* decoder) or the result would be too large to hold. Callers fall back to the
|
||||||
|
* original file, so a failure costs the download nothing but the extension.
|
||||||
|
*/
|
||||||
|
export async function convertAudioBlobToWav(blob: Blob): Promise<Blob | null> {
|
||||||
|
const OfflineCtx = getOfflineAudioContext();
|
||||||
|
if (!OfflineCtx) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// decodeAudioData resamples to the context's rate, and 48 kHz is what both
|
||||||
|
// Opus and AAC recordings already run at, so this decodes them untouched.
|
||||||
|
// We read the rate back off the result anyway in case a browser ignores it.
|
||||||
|
const context = new OfflineCtx(1, 1, 48000);
|
||||||
|
const decoded = await context.decodeAudioData(await blob.arrayBuffer());
|
||||||
|
if (!decoded || decoded.length === 0) return null;
|
||||||
|
if (wavByteLength(decoded.length, decoded.numberOfChannels) > MAX_WAV_OUTPUT_BYTES) return null;
|
||||||
|
|
||||||
|
const channels: Float32Array[] = [];
|
||||||
|
for (let i = 0; i < decoded.numberOfChannels; i++) channels.push(decoded.getChannelData(i));
|
||||||
|
return encodeWav(channels, decoded.sampleRate);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { convertAudioBlobToWav } from '@/lib/audio-to-wav';
|
||||||
|
|
||||||
// Above this size we don't buffer the file in memory to rename it — the caller
|
// Above this size we don't buffer the file in memory to rename it — the caller
|
||||||
// falls back to a plain navigation so the browser streams it straight to disk
|
// falls back to a plain navigation so the browser streams it straight to disk
|
||||||
// (with the CDN's own filename). 10 GiB.
|
// (with the CDN's own filename). 10 GiB.
|
||||||
@@ -27,12 +29,20 @@ export function extensionFromUrl(url: string): string {
|
|||||||
return ext.length >= 1 && ext.length <= 5 ? ext : '';
|
return ext.length >= 1 && ext.length <= 5 ? ext : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceExtension(fileName: string, ext: string): string {
|
export function replaceExtension(fileName: string, ext: string): string {
|
||||||
const dot = fileName.lastIndexOf('.');
|
const dot = fileName.lastIndexOf('.');
|
||||||
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
|
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
|
||||||
return `${stem}.${ext}`;
|
return `${stem}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strips the characters Windows and macOS reject in a file name. */
|
||||||
|
export function sanitizeDownloadFileName(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
export function formatBytes(bytes: number): string {
|
||||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
|
||||||
const mb = bytes / (1024 * 1024);
|
const mb = bytes / (1024 * 1024);
|
||||||
@@ -146,6 +156,88 @@ export async function downloadNamedFile(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUDIO_MIME_EXTENSION_MAP: Record<string, string> = {
|
||||||
|
'audio/webm': 'webm',
|
||||||
|
'audio/ogg': 'ogg',
|
||||||
|
'audio/opus': 'opus',
|
||||||
|
'audio/mp4': 'm4a',
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
|
'audio/wav': 'wav',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUDIO_EXTENSIONS = new Set(Object.values(AUDIO_MIME_EXTENSION_MAP).concat('mp4', 'oga'));
|
||||||
|
|
||||||
|
/** Display names are often the recorded file name, extension and all, and
|
||||||
|
* `recording.webm.wav` helps nobody. */
|
||||||
|
function stripAudioExtension(name: string): string {
|
||||||
|
const ext = extensionFromUrl(name);
|
||||||
|
return ext && AUDIO_EXTENSIONS.has(ext) ? name.slice(0, -(ext.length + 1)) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Containers an editing suite already opens. Audio assets are not only voice
|
||||||
|
* recordings, anyone can upload an audio file, and decoding one of these back out
|
||||||
|
* through the Web Audio API would resample it to 48 kHz and requantise it to 16
|
||||||
|
* bit for no gain. A file in this set is handed over exactly as stored.
|
||||||
|
*/
|
||||||
|
const EDITOR_READY_MIME_TYPES = new Set(['audio/wav', 'audio/mpeg', 'audio/mp4']);
|
||||||
|
|
||||||
|
export type AudioDownloadResult =
|
||||||
|
| 'wav'
|
||||||
|
| 'no-conversion-needed'
|
||||||
|
| 'conversion-unsupported'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice notes are stored exactly as MediaRecorder produced them: WebM/Opus,
|
||||||
|
* which browsers play and no editing suite imports. Convert on the way out so
|
||||||
|
* the file lands in the timeline it was recorded for.
|
||||||
|
*
|
||||||
|
* Saves the stored file untouched when it is already in an editable container,
|
||||||
|
* or when this browser cannot decode it, so the download always works. The
|
||||||
|
* return value says which of the three happened, or 'failed' when the file
|
||||||
|
* could not be fetched at all.
|
||||||
|
*/
|
||||||
|
export async function downloadAudioAsWav(
|
||||||
|
url: string,
|
||||||
|
baseName: string
|
||||||
|
): Promise<AudioDownloadResult> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { cache: 'no-store' });
|
||||||
|
} catch {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
if (!res.ok) return 'failed';
|
||||||
|
|
||||||
|
let source: Blob;
|
||||||
|
try {
|
||||||
|
source = await res.blob();
|
||||||
|
} catch {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const stem = stripAudioExtension(sanitizeDownloadFileName(baseName)) || 'voice-note';
|
||||||
|
// The proxy route names the real container; the URL usually carries no
|
||||||
|
// extension, so the content type is the better source for both decisions.
|
||||||
|
const contentType = (res.headers.get('content-type') || '').split(';')[0]?.trim() ?? '';
|
||||||
|
const ext = AUDIO_MIME_EXTENSION_MAP[contentType] || extensionFromUrl(url) || 'webm';
|
||||||
|
|
||||||
|
if (EDITOR_READY_MIME_TYPES.has(contentType)) {
|
||||||
|
saveBlobAs(source, `${stem}.${ext}`);
|
||||||
|
return 'no-conversion-needed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const wav = await convertAudioBlobToWav(source);
|
||||||
|
if (wav) {
|
||||||
|
saveBlobAs(wav, `${stem}.wav`);
|
||||||
|
return 'wav';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBlobAs(source, `${stem}.${ext}`);
|
||||||
|
return 'conversion-unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
/** Plain navigation download (streams to disk; filename controlled only for
|
/** Plain navigation download (streams to disk; filename controlled only for
|
||||||
* same-origin URLs via the download attribute). */
|
* same-origin URLs via the download attribute). */
|
||||||
export function navigateDownload(url: string, sameOriginFileName?: string): void {
|
export function navigateDownload(url: string, sameOriginFileName?: string): void {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// The assertions parse the encoded bytes back out of the RIFF header, because
|
||||||
|
// the failure mode that matters is a file an editor opens and plays wrong:
|
||||||
|
// half speed, one channel, or a burst of noise where a loud passage was.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { encodeWav, wavByteLength, MAX_WAV_OUTPUT_BYTES } from '@/lib/audio-to-wav';
|
||||||
|
|
||||||
|
const HEADER_BYTES = 44;
|
||||||
|
|
||||||
|
async function viewOf(blob: Blob): Promise<DataView> {
|
||||||
|
return new DataView(await blob.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function ascii(view: DataView, offset: number, length: number): string {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < length; i++) out += String.fromCharCode(view.getUint8(offset + i));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads back the interleaved samples as the signed 16-bit values on disk. */
|
||||||
|
function samples(view: DataView): number[] {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let offset = HEADER_BYTES; offset < view.byteLength; offset += 2) {
|
||||||
|
out.push(view.getInt16(offset, true));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('encodeWav', () => {
|
||||||
|
it('writes a RIFF/WAVE header describing the audio it was given', async () => {
|
||||||
|
const view = await viewOf(encodeWav([new Float32Array(480), new Float32Array(480)], 48000));
|
||||||
|
|
||||||
|
expect(ascii(view, 0, 4)).toBe('RIFF');
|
||||||
|
expect(ascii(view, 8, 4)).toBe('WAVE');
|
||||||
|
expect(ascii(view, 12, 4)).toBe('fmt ');
|
||||||
|
expect(view.getUint32(16, true)).toBe(16); // PCM fmt payload
|
||||||
|
expect(view.getUint16(20, true)).toBe(1); // format tag: PCM
|
||||||
|
expect(view.getUint16(22, true)).toBe(2); // channels
|
||||||
|
expect(view.getUint32(24, true)).toBe(48000); // sample rate
|
||||||
|
expect(view.getUint32(28, true)).toBe(48000 * 2 * 2); // byte rate
|
||||||
|
expect(view.getUint16(32, true)).toBe(4); // block align
|
||||||
|
expect(view.getUint16(34, true)).toBe(16); // bits per sample
|
||||||
|
expect(ascii(view, 36, 4)).toBe('data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares sizes that match the bytes actually written', async () => {
|
||||||
|
const blob = encodeWav([new Float32Array(100), new Float32Array(100)], 44100);
|
||||||
|
const view = await viewOf(blob);
|
||||||
|
|
||||||
|
const dataBytes = 100 * 2 * 2;
|
||||||
|
expect(blob.size).toBe(HEADER_BYTES + dataBytes);
|
||||||
|
expect(view.getUint32(4, true)).toBe(blob.size - 8);
|
||||||
|
expect(view.getUint32(40, true)).toBe(dataBytes);
|
||||||
|
expect(wavByteLength(100, 2)).toBe(blob.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interleaves the channels frame by frame', async () => {
|
||||||
|
const left = Float32Array.from([1, 1, 1]);
|
||||||
|
const right = Float32Array.from([-1, -1, -1]);
|
||||||
|
|
||||||
|
const view = await viewOf(encodeWav([left, right], 48000));
|
||||||
|
|
||||||
|
// L R L R L R, not LLL RRR: a planar layout plays as a channel of speech
|
||||||
|
// followed by a channel of silence.
|
||||||
|
expect(samples(view)).toEqual([32767, -32768, 32767, -32768, 32767, -32768]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps samples that overshoot the float range instead of wrapping them', async () => {
|
||||||
|
// Decoders routinely hand back values slightly outside ±1. Scaled unclamped
|
||||||
|
// these wrap to the opposite rail and the clip crackles.
|
||||||
|
const view = await viewOf(encodeWav([Float32Array.from([1.4, -1.4, 0])], 48000));
|
||||||
|
|
||||||
|
expect(samples(view)).toEqual([32767, -32768, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a mono recording mono', async () => {
|
||||||
|
const blob = encodeWav([new Float32Array(240)], 48000);
|
||||||
|
const view = await viewOf(blob);
|
||||||
|
|
||||||
|
expect(view.getUint16(22, true)).toBe(1);
|
||||||
|
expect(view.getUint16(32, true)).toBe(2); // block align: one 16-bit sample
|
||||||
|
expect(blob.size).toBe(HEADER_BYTES + 240 * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes an empty recording as a valid, empty WAV', async () => {
|
||||||
|
const blob = encodeWav([new Float32Array(0)], 48000);
|
||||||
|
const view = await viewOf(blob);
|
||||||
|
|
||||||
|
expect(blob.size).toBe(HEADER_BYTES);
|
||||||
|
expect(view.getUint32(40, true)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects input it cannot describe in the header', () => {
|
||||||
|
expect(() => encodeWav([], 48000)).toThrow();
|
||||||
|
expect(() => encodeWav([new Float32Array(10)], 0)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wavByteLength', () => {
|
||||||
|
it('puts the output cap beyond any plausible voice note', () => {
|
||||||
|
// The cap sits around 35 minutes of 48 kHz stereo. A 10MB Opus upload can
|
||||||
|
// just about exceed that, which is the case it exists for; an hour-long
|
||||||
|
// voice note is not a thing anyone records into a review comment.
|
||||||
|
expect(wavByteLength(48000 * 60 * 10, 2)).toBeLessThan(MAX_WAV_OUTPUT_BYTES);
|
||||||
|
expect(wavByteLength(48000 * 60 * 45, 2)).toBeGreaterThan(MAX_WAV_OUTPUT_BYTES);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user