collective

Browse / github.com/storytold/artcraft / patch

fix(promptbox): route M4A references as audio despite video MIME

fix(promptbox): route M4A references as audio despite video MIME ## Summary Route dropped and pasted audio references with the existing audio-file guard before trusting their MIME container type. Some platforms report `.m4a` as `video/mp4`, a case already documented by the audio picker guard. The prompt drop router currently rejects those files in audio prompts or sends them to the video bucket when video is supported. ## Verification - Actual mounted `usePromptBoxDrop` hook, ReactDOM, DOM drop and clipboard events: four failures plus five passing controls before the fix; all nine pass afterward. - Controls preserve genuine MP4 video, audio MIME without

Status
open
Score
0.00
Adopters
0 other operators
Author
rudycelekli (imported from pull request #2101, open)
Base
28a39a0de4a2fc2eccc237df2046c24245bd12ab
Size
+111 −1 in 2 files
Review
pending
Shared
2026-10-28
Id
b928d71667d6c008a7131fac

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/promptbox/src/lib/deck/usePromptBoxDrop.spec.tsx frontend/libs/components/promptbox/src/lib/deck/usePromptBoxDrop.tsx

diff --git a/frontend/libs/components/promptbox/src/lib/deck/usePromptBoxDrop.spec.tsx b/frontend/libs/components/promptbox/src/lib/deck/usePromptBoxDrop.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/promptbox/src/lib/deck/usePromptBoxDrop.spec.tsx@@ -0,0 +1,105 @@+import { act } from "react";+import { createRoot, Root } from "react-dom/client";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import { usePromptBoxDrop, UsePromptBoxDropOptions } from "./usePromptBoxDrop";++const { error } = vi.hoisted(() => ({ error: vi.fn() }));+vi.mock("@storyteller/ui-toaster", () => ({ toast: { error } }));+vi.mock("@storyteller/tauri-utils", () => ({ IsDesktopApp: () => false }));+vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null }));+vi.mock("lucide-react", () => ({ ImagesIcon: () => null, MusicIcon: () => null, VideoIcon: () => null }));+vi.mock("tailwind-merge", () => ({ twMerge: (...values: string[]) => values.join(" ") }));++let root: Root;+let container: HTMLDivElement;++beforeEach(() => {+  globalThis.IS_REACT_ACT_ENVIRONMENT = true;+  error.mockReset();+  container = document.createElement("div");+  document.body.appendChild(container);+  root = createRoot(container);+});++afterEach(() => {+  act(() => root.unmount());+  container.remove();+});++describe("prompt reference file routing", () => {+  it.each(["drop", "paste"] as const)("accepts M4A reported as video/mp4 through %s", (transport) => {+    const receive = mount({ acceptsAudio: true });+    const file = new File(["audio"], "voice.m4a", { type: "video/mp4" });+    deliver(transport, [file]);+    expect(receive).toHaveBeenCalledWith({ images: [], videos: [], audios: [file] });+    expect(error).not.toHaveBeenCalled();+  });++  it("routes M4A into audio when both video and audio are supported", () => {+    const receive = mount({ acceptsAudio: true, acceptsVideos: true });+    const file = new File(["audio"], "VOICE.M4A", { type: "video/mp4" });+    deliver("drop", [file]);+    expect(receive).toHaveBeenCalledWith({ images: [], videos: [], audios: [file] });+  });++  it("does not send M4A to a video-only model", () => {+    const receive = mount({ acceptsVideos: true });+    deliver("drop", [new File(["audio"], "voice.m4a", { type: "video/mp4" })]);+    expect(receive).not.toHaveBeenCalled();+    expect(error).toHaveBeenCalledWith("Audio references aren't available here");+  });++  it("preserves genuine MP4 video references", () => {+    const receive = mount({ acceptsVideos: true });+    const file = new File(["video"], "clip.mp4", { type: "video/mp4" });+    deliver("drop", [file]);+    expect(receive).toHaveBeenCalledWith({ images: [], videos: [file], audios: [] });+  });++  it("preserves audio MIME routing without a recognized extension", () => {+    const receive = mount({ acceptsAudio: true });+    const file = new File(["audio"], "recording", { type: "audio/mpeg" });+    deliver("paste", [file]);+    expect(receive).toHaveBeenCalledWith({ images: [], videos: [], audios: [file] });+  });++  it("preserves extension fallback for empty MIME", () => {+    const receive = mount({ acceptsAudio: true });+    const file = new File(["audio"], "voice.m4a");+    deliver("drop", [file]);+    expect(receive).toHaveBeenCalledWith({ images: [], videos: [], audios: [file] });+  });++  it("preserves image routing", () => {+    const receive = mount({ acceptsImages: true });+    const file = new File(["image"], "photo.png", { type: "image/png" });+    deliver("drop", [file]);+    expect(receive).toHaveBeenCalledWith({ images: [file], videos: [], audios: [] });+  });++  it("still rejects audio when the model only accepts images", () => {+    const receive = mount({ acceptsImages: true });+    deliver("paste", [new File(["audio"], "voice.mp3", { type: "audio/mpeg" })]);+    expect(receive).not.toHaveBeenCalled();+    expect(error).toHaveBeenCalledWith("Audio references aren't available here");+  });+});++function mount(capabilities: Partial<UsePromptBoxDropOptions>) {+  const receive = vi.fn();+  function Zone() {+    const drop = usePromptBoxDrop({ acceptsAudio: false, acceptsVideos: false, acceptsImages: false, ...capabilities, onDropFiles: receive });+    return <div {...drop.dropZoneProps} data-testid="zone" />;+  }+  act(() => root.render(<Zone />));+  return receive;+}++function deliver(transport: "drop" | "paste", files: File[]) {+  const event = new Event(transport, { bubbles: true, cancelable: true });+  Object.defineProperty(event, transport === "drop" ? "dataTransfer" : "clipboardData", { value: { files, types: ["Files"] } });+  act(() => {+    const target = transport === "drop" ? container.querySelector('[data-testid="zone"]')! : window;+    target.dispatchEvent(event);+  });+}diff --git a/frontend/libs/components/promptbox/src/lib/deck/usePromptBoxDrop.tsx b/frontend/libs/components/promptbox/src/lib/deck/usePromptBoxDrop.tsx--- a/frontend/libs/components/promptbox/src/lib/deck/usePromptBoxDrop.tsx+++ b/frontend/libs/components/promptbox/src/lib/deck/usePromptBoxDrop.tsx@@ -4,6 +4,7 @@ import { ImagesIcon, MusicIcon, VideoIcon } from "lucide-react"; import { DynamicIcon } from "@storyteller/icons"; import { toast } from "@storyteller/ui-toaster"; import { IsDesktopApp } from "@storyteller/tauri-utils";+import { isAudioFile } from "../common/audioFiles";  // ── Types ─────────────────────────────────────────────────────────────── @@ -175,7 +176,11 @@ export function usePromptBoxDrop({     const rejectedKinds = new Set<DropKind>();     let unknownCount = 0;     for (const file of files) {-      const kind = kindOfMime(file.type) ?? kindOfPath(file.name);+      // Some platforms report M4A as video/mp4; use the same audio guard+      // as the reference picker before trusting that container MIME type.+      const kind = isAudioFile(file)+        ? "audio"+        : (kindOfMime(file.type) ?? kindOfPath(file.name));       if (kind === null) {         unknownCount++;       } else if (!acceptsKind(kind)) {