fix(studio): the timeline takes m4v video and flac audio (#4649)

* fix(studio): the timeline takes m4v video and flac audio

* fix(studio-server): check and label flac uploads as audio
This commit is contained in:
Miguel Ángel
2026-09-28 08:49:57 +00:00
committed by GitHub
parent 2119b188a2
commit 5eec513281
7 changed files with 71 additions and 10 deletions
@@ -111,6 +111,15 @@ describe("probeMediaMetadata", () => {
expect(audioFile).not.toHaveProperty("hasAudio");
});
it("reads a flac file as audio", async () => {
const flac = await probeMediaMetadata("/tmp/music.flac", () => ({
status: 0,
stdout: "{}",
stderr: "",
}));
expect(flac.kind).toBe("audio");
});
it("ignores attached cover art and reads the real video stream", async () => {
const metadata = await probeMediaMetadata("/tmp/clip.mp4", () => ({
status: 0,
@@ -96,7 +96,7 @@ const VIDEO_EXT = new Set([
".ts",
]);
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"]);
function lower(value: string | undefined): string {
return value?.toLowerCase() ?? "";
@@ -38,6 +38,19 @@ describe("validateUploadedMedia", () => {
).toEqual({ ok: false, reason: "no supported video stream found" });
});
it.each(["/tmp/test.flac", "/tmp/test.m4v"])(
"probes %s instead of passing it through",
(path) => {
expect(
validateUploadedMedia(path, () => ({
status: 0,
stdout: JSON.stringify({ streams: [] }),
stderr: "",
})),
).toMatchObject({ ok: false });
},
);
it("accepts audio files with an audio stream", () => {
expect(
validateUploadedMedia("/tmp/test.wav", () => ({
@@ -5,7 +5,7 @@ import { basename, join } from "node:path";
import { FFPROBE_PATH_ENV, findFfBinary } from "@hyperframes/parsers/ff-binaries";
const VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac|flac)$/i;
type FfprobeRunner = (
command: string,
@@ -16,6 +16,7 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
// ---------------------------------------------------------------------------
import type { FeedbackContext } from "./feedbackTrigger";
import { isMediaFile } from "../../utils/mediaTypes";
/** Written by `hyperframes init`; absent in a hand-made or copied project. */
const CONFIG_FILE = "hyperframes.json";
@@ -23,12 +24,10 @@ const CONFIG_FILE = "hyperframes.json";
/** Matches the CLI's own slug gate, so a hand-edited value cannot leak text. */
const SKILL_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MEDIA_EXTENSIONS = /\.(mp4|mov|webm|m4v|mp3|wav|m4a|aac|ogg|png|jpe?g|gif|webp|svg|avif)$/i;
let snapshot: FeedbackContext = {};
function countMedia(files: string[]): number {
return files.filter((f) => MEDIA_EXTENSIONS.test(f)).length;
return files.filter(isMediaFile).length;
}
/**
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { AUDIO_EXT, IMAGE_EXT, isMediaFile, MEDIA_EXT, VIDEO_EXT } from "./mediaTypes";
import { getTimelineAssetKind } from "./timelineAssetDrop";
describe("media types", () => {
it.each([
["clip.m4v", "video"],
["assets/Take 2.M4V", "video"],
["song.flac", "audio"],
["voice.FLAC", "audio"],
["clip.mp4", "video"],
["music.mp3", "audio"],
["still.png", "image"],
] as const)("the timeline takes %s as %s", (path, kind) => {
expect(getTimelineAssetKind(path)).toBe(kind);
expect(isMediaFile(path)).toBe(true);
});
it("still refuses files that are not media", () => {
for (const path of ["notes.pdf", "font.woff2", "grade.cube", "clip.m4v.txt", "flac"]) {
expect(getTimelineAssetKind(path)).toBeNull();
expect(isMediaFile(path)).toBe(false);
}
});
it("counts a file as media exactly when it is an image, video or audio file", () => {
const paths = ["a.mp4", "a.m4v", "a.flac", "a.png", "a.ico", "a.woff", "a.pdf", "a.cube"];
for (const path of paths) {
const kinds = [IMAGE_EXT, VIDEO_EXT, AUDIO_EXT].filter((re) => re.test(path)).length;
expect(MEDIA_EXT.test(path)).toBe(kinds > 0);
}
});
});
+12 -5
View File
@@ -1,10 +1,17 @@
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
const IMAGE_EXTS = ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "ico"];
const VIDEO_EXTS = ["mp4", "webm", "mov", "m4v"];
const AUDIO_EXTS = ["mp3", "wav", "ogg", "m4a", "aac", "flac"];
function extensionPattern(extensions: readonly string[]): RegExp {
return new RegExp(`\\.(${extensions.join("|")})$`, "i");
}
export const IMAGE_EXT = extensionPattern(IMAGE_EXTS);
export const VIDEO_EXT = extensionPattern(VIDEO_EXTS);
export const AUDIO_EXT = extensionPattern(AUDIO_EXTS);
export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
export const LUT_EXT = /\.cube$/i;
export const MEDIA_EXT =
/\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
export const MEDIA_EXT = extensionPattern([...VIDEO_EXTS, ...AUDIO_EXTS, ...IMAGE_EXTS]);
export function isMediaFile(path: string): boolean {
return MEDIA_EXT.test(path);