Webapp: mobile audio ref fixes (#1816)

- **Fixed: picking an audio reference on mobile opened the photo/video
picker.** iPhones don't have an audio library, so the browser fell back
to Photos - the only thing you could "attach as audio" was a video, and
it silently uploaded as an audio ref. Audio pickers now open the Files
app filtered to real audio files (MP3, WAV, M4A, FLAC…).
- **Non-audio files are now rejected with a clear error toast** instead
of being silently attached (also covers drag & drop and Android's "any
file" picker).
- **Fixed: limit warnings never appeared in the web app.** Errors like
"Total audio duration cannot exceed 15s" from shared components were
firing into a toast system the web app doesn't render. They're now
routed to the app's own toasts, so length/limit errors actually show up.
- Applies to the create video, create audio, and desktop reference deck
flows
This commit is contained in:
BFlat authored and GitHub committed 2026-07-29 22:17:04 +07:00
1 parent e1b16cf2de
commit 4ff5bdf475
10 files changed
+217 -28

No files matched your search

@@ -10,6 +10,11 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
import {
AUDIO_FILE_ACCEPT,
AUDIO_FILE_TYPE_ERROR,
isAudioFile,
} from "@storyteller/ui-promptbox";
import { toast } from "../toast/toast";
import { AddButton } from "./ImagePromptRow";
import type { RefVideo, RefAudio } from "./types";
@@ -137,6 +142,10 @@ export const MediaReferenceRow = ({
const baseAudios = [...referenceAudios];
const file = files[0];
if (!isAudioFile(file)) {
toast.error(AUDIO_FILE_TYPE_ERROR);
return;
}
const duration = await getAudioDuration(file);
if (duration <= 0) {
@@ -208,7 +217,7 @@ export const MediaReferenceRow = ({
type="file"
ref={audioInputRef}
className="hidden"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
onChange={handleAudioUpload}
/>
<div
@@ -55,15 +55,15 @@
{
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
},
{
"path": "../../libs/components/toaster/tsconfig.lib.json"
},
{
"path": "../../libs/omni-gen/tsconfig.lib.json"
},
{
"path": "../../libs/components/pricing-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/promptbox/tsconfig.lib.json"
},
{
"path": "../../libs/components/model-selector/tsconfig.lib.json"
},
@@ -73,15 +73,18 @@
{
"path": "../../libs/components/pagescene/tsconfig.lib.json"
},
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/lightbox-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/switch/tsconfig.lib.json"
},
{
"path": "../../libs/components/promptbox/tsconfig.lib.json"
},
{
"path": "../../libs/components/audio-player/tsconfig.lib.json"
},
+12 -9
View File
@@ -20,15 +20,15 @@
{
"path": "../../libs/components/slider-v2"
},
{
"path": "../../libs/components/toaster"
},
{
"path": "../../libs/omni-gen"
},
{
"path": "../../libs/components/pricing-modal"
},
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/model-selector"
},
@@ -38,15 +38,21 @@
{
"path": "../../libs/components/pagescene"
},
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/lightbox-modal"
},
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/switch"
},
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/audio-player"
},
{
"path": "../../libs/components/viewer-3d"
},
@@ -68,9 +74,6 @@
{
"path": "../../libs/components/generation-list"
},
{
"path": "../../libs/components/audio-player"
},
{
"path": "../../libs/components/tab-selector"
},
@@ -20,6 +20,48 @@ import {
getAudioDuration,
} from "./upload-media";
// iOS Safari has no system audio library, so a bare `accept="audio/*"` falls
// back to the Photos picker (photos and videos only). Concrete MIME types and
// extensions make iOS open the Files picker filtered to audio instead.
const AUDIO_FILE_ACCEPT = [
"audio/mpeg",
"audio/wav",
"audio/x-wav",
"audio/mp4",
"audio/x-m4a",
"audio/aac",
"audio/ogg",
"audio/opus",
"audio/flac",
"audio/webm",
".mp3",
".wav",
".m4a",
".aac",
".ogg",
".oga",
".opus",
".flac",
].join(",");
const AUDIO_FILE_EXTENSIONS = new Set([
"mp3",
"wav",
"m4a",
"aac",
"ogg",
"oga",
"opus",
"flac",
]);
// Some platforms report .m4a as `video/mp4`, so check the extension too.
const isAudioFile = (file: File): boolean => {
if (file.type.startsWith("audio/")) return true;
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
return AUDIO_FILE_EXTENSIONS.has(extension);
};
interface MediaReferenceRowProps {
referenceVideos: RefVideo[];
onReferenceVideosChange: (videos: RefVideo[]) => void;
@@ -133,6 +175,10 @@ export const MediaReferenceRow = ({
const baseAudios = [...referenceAudios];
const file = files[0];
if (!isAudioFile(file)) {
toast.error("Please choose an audio file (MP3, WAV, M4A, FLAC…)");
return;
}
const duration = await getAudioDuration(file);
if (duration <= 0) {
@@ -204,7 +250,7 @@ export const MediaReferenceRow = ({
type="file"
ref={audioInputRef}
className="hidden"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
onChange={handleAudioUpload}
/>
<div
@@ -1,4 +1,5 @@
export * from "./lib/common/AspectRatioIcon";
export * from "./lib/common/audioFiles";
export * from "./lib/PromptBox2D";
export * from "./lib/PromptBox3D";
export * from "./lib/PromptBoxEdit";
@@ -25,6 +25,11 @@ import { RefImage, RefVideo, RefAudio } from "./promptStore";
import { toast } from "@storyteller/ui-toaster";
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
import {
AUDIO_FILE_ACCEPT,
AUDIO_FILE_TYPE_ERROR,
isAudioFile,
} from "./common/audioFiles";
import {
DndContext,
closestCenter,
@@ -500,13 +505,18 @@ export const ImagePromptRow = ({
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
const audioFiles = files.filter(isAudioFile);
if (audioFiles.length < files.length) {
toast.error(AUDIO_FILE_TYPE_ERROR);
}
const availableSlots = Math.max(0, maxAudioCount - referenceAudios.length);
if (availableSlots <= 0) {
if (audioFiles.length === 0 || availableSlots <= 0) {
if (audioFileInputRef.current) audioFileInputRef.current.value = "";
return;
}
const filesToProcess = files.slice(0, availableSlots);
const filesToProcess = audioFiles.slice(0, availableSlots);
for (const file of filesToProcess) {
const duration = await getAudioDuration(file);
@@ -819,7 +829,7 @@ export const ImagePromptRow = ({
type="file"
ref={audioFileInputRef}
className="hidden"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
onChange={handleAudioFileUpload}
multiple={maxAudioCount > 1}
/>
@@ -13,6 +13,11 @@ import { toast } from "@storyteller/ui-toaster";
import { UploaderStates } from "@storyteller/common";
import type { UploadMediaFn } from "@storyteller/api";
import type { RefAudio, RefImage } from "../promptStore";
import {
AUDIO_FILE_ACCEPT,
AUDIO_FILE_TYPE_ERROR,
isAudioFile,
} from "./audioFiles";
export interface AudioReferenceRowProps {
referenceAudios: RefAudio[];
@@ -67,8 +72,13 @@ export function AudioReferenceRow({
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
const audioFiles = files.filter(isAudioFile);
if (audioFiles.length < files.length) {
toast.error(AUDIO_FILE_TYPE_ERROR);
}
const availableSlots = Math.max(0, maxAudioCount - referenceAudios.length);
const filesToProcess = files.slice(0, availableSlots);
const filesToProcess = audioFiles.slice(0, availableSlots);
for (const file of filesToProcess) {
const duration = await getAudioFileDuration(file);
@@ -160,7 +170,7 @@ export function AudioReferenceRow({
<input
ref={audioInputRef}
type="file"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
className="hidden"
multiple={maxAudioCount > 1}
onChange={handleAudioFileUpload}
@@ -0,0 +1,56 @@
// iOS Safari has no system audio library, so a bare `accept="audio/*"` makes
// the file input fall back to the Photos picker (photos and videos only) —
// on a phone the only thing users could "attach as audio" was a video.
// Listing concrete audio MIME types and extensions instead makes iOS open the
// Files picker filtered to audio; desktop browsers filter identically.
export const AUDIO_FILE_ACCEPT = [
"audio/mpeg",
"audio/wav",
"audio/x-wav",
"audio/mp4",
"audio/x-m4a",
"audio/aac",
"audio/ogg",
"audio/opus",
"audio/flac",
"audio/webm",
"audio/aiff",
"audio/x-aiff",
".mp3",
".wav",
".m4a",
".aac",
".ogg",
".oga",
".opus",
".flac",
".weba",
".aiff",
".aif",
].join(",");
export const AUDIO_FILE_TYPE_ERROR =
"Please choose an audio file (MP3, WAV, M4A, FLAC…)";
const AUDIO_FILE_EXTENSIONS = new Set([
"mp3",
"wav",
"m4a",
"aac",
"ogg",
"oga",
"opus",
"flac",
"weba",
"aiff",
"aif",
]);
// Guards the audio-ref paths against non-audio picks (Android "any file"
// pickers, drag-and-drop, older iOS). Checks the extension as well as the
// MIME type because some platforms report .m4a as `video/mp4`.
export function isAudioFile(file: File): boolean {
if (file.type.startsWith("audio/")) return true;
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
return AUDIO_FILE_EXTENSIONS.has(extension);
}
@@ -3,6 +3,11 @@ import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl, type UploadMediaFn } from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import { UploaderStates } from "@storyteller/common";
import {
AUDIO_FILE_ACCEPT,
AUDIO_FILE_TYPE_ERROR,
isAudioFile,
} from "../common/audioFiles";
/** Minimal structural shapes so both apps' ref types fit. */
export interface DeckRefLike {
@@ -368,12 +373,18 @@ export function useDeckMedia<
};
const processAudioFiles = async (files: File[]) => {
const audioFiles = files.filter(isAudioFile);
if (audioFiles.length < files.length) {
toast.error(AUDIO_FILE_TYPE_ERROR, { id: "audio-ref-type" });
}
if (audioFiles.length === 0) return;
const availableSlots = Math.max(0, maxAudios - referenceAudios.length);
if (availableSlots <= 0) {
return;
}
const filesToProcess = files.slice(0, availableSlots);
const filesToProcess = audioFiles.slice(0, availableSlots);
for (const file of filesToProcess) {
const duration = await getAudioDuration(file);
@@ -441,8 +452,12 @@ export function useDeckMedia<
if (files.length === 0) return;
const images = files.filter((f) => f.type.startsWith("image/"));
const videos = files.filter((f) => f.type.startsWith("video/"));
const audios = files.filter((f) => f.type.startsWith("audio/"));
// isAudioFile also claims .m4a files that platforms report as video/mp4,
// so exclude them from the video bucket.
const audios = files.filter(isAudioFile);
const videos = files.filter(
(f) => f.type.startsWith("video/") && !isAudioFile(f),
);
if (images.length > 0) processImageFiles(images, "start");
if (videos.length > 0 && setReferenceVideos) processVideoFiles(videos);
@@ -453,7 +468,7 @@ export function useDeckMedia<
const anyUploadAccept = [
...(maxImages > 0 ? ["image/*"] : []),
...(setReferenceVideos ? ["video/mp4", ".mp4"] : []),
...(setReferenceAudios ? ["audio/*"] : []),
...(setReferenceAudios ? [AUDIO_FILE_ACCEPT] : []),
].join(",");
const handleGalleryClose = () => {
@@ -681,7 +696,7 @@ export function useDeckMedia<
type="file"
ref={audioFileInputRef}
className="hidden"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
onChange={handleAudioFileUpload}
multiple={maxAudios > 1}
/>
@@ -1,4 +1,40 @@
import toast, { Toaster as RHToaster } from "react-hot-toast";
import rhToast, { Toaster as RHToaster } from "react-hot-toast";
// Host apps that render their own toast UI instead of mounting <Toaster />
// (e.g. the webapp) register a delegate here. Shared-lib `toast.success` /
// `toast.error` calls are forwarded to it — without this they dispatch into
// react-hot-toast, which renders nothing when its container isn't mounted.
export interface ToastDelegate {
success: (message: string) => void;
error: (message: string) => void;
}
let toastDelegate: ToastDelegate | null = null;
export function setToastDelegate(delegate: ToastDelegate | null) {
toastDelegate = delegate;
}
const toast: typeof rhToast = Object.assign(
((...args: Parameters<typeof rhToast>) => rhToast(...args)) as typeof rhToast,
rhToast,
{
success: ((message, opts) => {
if (toastDelegate && typeof message === "string") {
toastDelegate.success(message);
return "";
}
return rhToast.success(message, opts);
}) as typeof rhToast.success,
error: ((message, opts) => {
if (toastDelegate && typeof message === "string") {
toastDelegate.error(message);
return "";
}
return rhToast.error(message, opts);
}) as typeof rhToast.error,
},
);
interface ToasterProps {
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";