This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7790-6a29c59c6312da95826b269fca2a6a14006fb34a in repository https://gitbox.apache.org/repos/asf/texera.git
commit bb889a0f1e178daf5b393299aa9dd407e928e34e Author: Prateek Ganigi <[email protected]> AuthorDate: Wed Oct 14 20:04:04 2026 +0000 fix(amber, frontend): inline Hugging Face audio uploads as data URLs instead of a server temp file (#7790) ### What changes were proposed in this PR? The Hugging Face operator's **Audio Upload** widget now captures the selected file entirely in the browser and stores it as a self-contained `data:audio/…;base64,…` URL, instead of relying on a server-side temp file. Previously the upload ended up as a server-side temp file reference. In a distributed deployment the Python worker that runs the inference is a different process than the web server, so it could not read that path, the uploaded audio never reached the model. The widget now reads the file client-side (`FileReader.readAsDataURL`) and stores the data URL as the operator's `audioInput` value, so it travels inside the workflow JSON and the generated Python's `_read_audio_input` decodes it directly. This mirrors the existing **Image Upload** widget's data-URL approach. A 25 MB size cap and input guards ("Choose an audio file.", "Audio file is too large (max 25 MB).", "Could not read this audio file.") are added since base64 inlining bloats the workflow JSON, along with an inline `<audio>` preview and a Clear button. <img width="1275" height="549" alt="image" src="https://github.com/user-attachments/assets/2c43240d-86a8-4b64-9063-89a568a5444a" /> ### Any related issues? Closes #6968 ### How was this PR tested? Frontend unit tests, plus a manual UI check: on the `automatic-speech-recognition` task, selecting an audio file shows an inline player and stores the value as a `data:audio/…;base64,…` URL (verified in the console). The size cap, invalid-file guard, and Clear button all behave as expected. ### Was this PR authored or co-authored using generative AI tooling? Yes, this PR was co-authored with Claude in compliance with ASF policy. --------- Co-authored-by: Xuan Gu <[email protected]> --- .../web/resource/HuggingFaceModelResource.scala | 180 ---- .../resource/HuggingFaceModelResourceSpec.scala | 356 -------- .../hugging-face-audio-upload.component.html | 14 +- .../hugging-face-audio-upload.component.scss | 11 - .../hugging-face-audio-upload.component.spec.ts | 984 +++++++-------------- .../hugging-face-audio-upload.component.ts | 167 ++-- 6 files changed, 392 insertions(+), 1320 deletions(-) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/HuggingFaceModelResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/HuggingFaceModelResource.scala index bdda6e8ffc..0decacab5f 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/HuggingFaceModelResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/HuggingFaceModelResource.scala @@ -25,9 +25,7 @@ import com.google.common.cache.{Cache, CacheBuilder} import kong.unirest.Unirest import org.slf4j.{Logger, LoggerFactory} -import java.io.InputStream import java.net.URI -import java.nio.file.{Files, Path => NioPath, Paths} import java.util.concurrent.{Callable, ForkJoinPool, TimeUnit} import java.util.stream.Collectors import javax.annotation.security.RolesAllowed @@ -40,8 +38,6 @@ import scala.jdk.CollectionConverters._ * * - GET /api/huggingface/models?task=…[&search=…] browse or search HF models * - GET /api/huggingface/tasks list HF pipeline tags with hosted inference - * - POST /api/huggingface/upload-audio?filename=… stream-upload an audio file - * - GET /api/huggingface/audio-preview?path=… stream back an uploaded audio file * - GET /api/huggingface/media-proxy?url=… proxy an allowlisted remote media URL * * Token sourcing: the user supplies their own HF token via the `X-HF-Token` @@ -98,136 +94,6 @@ class HuggingFaceModelResource { } } - /** - * Streams an audio file from the request body to a temp file under - * `${java.io.tmpdir}/texera-hf-audio`. Enforces an extension allowlist - * and a max payload size (rejected with 413 once exceeded). Old files - * in the temp dir are best-effort cleaned on each upload. - */ - @POST - @Path("/upload-audio") - @Consumes(Array(MediaType.WILDCARD)) - def uploadAudioReference( - @QueryParam("filename") filename: String, - stream: InputStream - ): Response = { - try { - if (stream == null) { - return errorResponse(Response.Status.BAD_REQUEST, "Audio payload is required.") - } - - val safeFileName = Option(filename) - .map(_.trim) - .filter(_.nonEmpty) - .map(name => Paths.get(name).getFileName.toString) - .getOrElse("audio.bin") - val extension = { - val idx = safeFileName.lastIndexOf('.') - if (idx >= 0 && idx < safeFileName.length - 1) - safeFileName.substring(idx).toLowerCase - else "" - } - if (!ALLOWED_AUDIO_EXTENSIONS.contains(extension)) { - return errorResponse( - Response.Status.BAD_REQUEST, - "Unsupported audio file extension." - ) - } - - val tempDir = audioTempDir - Files.createDirectories(tempDir) - sweepOldAudioFiles(tempDir) - - val tempFile: NioPath = Files.createTempFile(tempDir, "hf-audio-", extension) - tempFile.toFile.deleteOnExit() - - val out = Files.newOutputStream(tempFile) - var totalBytes = 0L - try { - val buf = new Array[Byte](8 * 1024) - var read = stream.read(buf) - while (read != -1) { - totalBytes += read - if (totalBytes > MAX_AUDIO_BYTES) { - out.close() - Files.deleteIfExists(tempFile) - return errorResponse( - Response.Status.REQUEST_ENTITY_TOO_LARGE, - "Audio payload exceeds the size limit." - ) - } - out.write(buf, 0, read) - read = stream.read(buf) - } - } finally { - out.close() - } - - if (totalBytes == 0L) { - Files.deleteIfExists(tempFile) - return errorResponse(Response.Status.BAD_REQUEST, "Audio payload is empty.") - } - - val json = objectMapper.writeValueAsString( - Map( - "path" -> tempFile.toAbsolutePath.toString, - "fileName" -> safeFileName - ).asJava - ) - Response.ok(json).build() - } catch { - case e: Exception => - logger.error("Failed to upload audio", e) - errorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Failed to upload audio.") - } - } - - @GET - @Path("/audio-preview") - def previewUploadedAudio(@QueryParam("path") path: String): Response = { - try { - val trimmedPath = Option(path).map(_.trim).getOrElse("") - if (trimmedPath.isEmpty) { - return errorResponse(Response.Status.BAD_REQUEST, "Audio path is required.") - } - - val tempDir = audioTempDir.toAbsolutePath.normalize() - val requestedPath = Paths.get(trimmedPath).toAbsolutePath.normalize() - if (!requestedPath.startsWith(tempDir)) { - return errorResponse( - Response.Status.FORBIDDEN, - "Audio path is outside the allowed preview directory." - ) - } - if (!Files.exists(requestedPath) || !Files.isRegularFile(requestedPath)) { - return errorResponse(Response.Status.NOT_FOUND, "Uploaded audio file was not found.") - } - - // Defense-in-depth: even though /upload-audio enforces MAX_AUDIO_BYTES on - // ingest, refuse to buffer an oversized file into heap on the response - // side. Catches files placed via a future-bug or out-of-band write. - val size = Files.size(requestedPath) - if (size > MAX_AUDIO_BYTES) { - logger.warn( - s"Uploaded audio file size $size exceeds cap $MAX_AUDIO_BYTES; rejecting." - ) - return errorResponse( - Response.Status.REQUEST_ENTITY_TOO_LARGE, - "Uploaded audio file exceeds the size limit." - ) - } - - val contentType = Option(Files.probeContentType(requestedPath)) - .filter(_.trim.nonEmpty) - .getOrElse(inferAudioContentType(requestedPath)) - Response.ok(Files.readAllBytes(requestedPath), contentType).build() - } catch { - case e: Exception => - logger.error("Failed to read uploaded audio", e) - errorResponse(Response.Status.INTERNAL_SERVER_ERROR, "Failed to read uploaded audio.") - } - } - /** * Proxies a remote media URL to bypass browser CORS for HF inference responses. * Only http(s) URLs whose host is in ALLOWED_MEDIA_HOST_SUFFIXES are accepted, @@ -654,12 +520,6 @@ object HuggingFaceModelResource { private val TASK_FETCH_PARALLELISM = 4 private val taskCheckPool = new ForkJoinPool(TASK_FETCH_PARALLELISM) - // ── /upload-audio constraints ── - private[resource] val MAX_AUDIO_BYTES: Long = 25L * 1024L * 1024L // 25 MiB - private[resource] val ALLOWED_AUDIO_EXTENSIONS: Set[String] = - Set(".wav", ".mp3", ".mpeg", ".flac", ".ogg", ".oga", ".webm", ".opus", ".amr", ".m4a", ".aac") - private[resource] val AUDIO_TEMP_TTL_MS: Long = 60L * 60L * 1000L // 1 hour - // ── /media-proxy size cap: bounds the upstream response we buffer in heap ── // Sized to cover HF inference media outputs (text-to-image ~5 MiB, // text-to-video ~30 MiB) with headroom. Bumps should land in their own PR. @@ -681,33 +541,6 @@ object HuggingFaceModelResource { "replicate.com" ) - private[resource] def audioTempDir: NioPath = - Paths.get(System.getProperty("java.io.tmpdir"), "texera-hf-audio") - - /** Delete audio temp files older than AUDIO_TEMP_TTL_MS. Best-effort. */ - private[resource] def sweepOldAudioFiles(tempDir: NioPath): Unit = { - val cutoff = System.currentTimeMillis() - AUDIO_TEMP_TTL_MS - try { - val stream = Files.list(tempDir) - try { - stream.forEach { p => - try { - if (Files.isRegularFile(p) && Files.getLastModifiedTime(p).toMillis < cutoff) { - Files.deleteIfExists(p) - } - } catch { - case _: Exception => // skip files we can't stat/delete - } - } - } finally { - stream.close() - } - } catch { - case e: Exception => - logger.debug(s"Audio temp dir sweep failed: ${e.getMessage}") - } - } - /** Allow exact host or subdomain of any entry in ALLOWED_MEDIA_HOST_SUFFIXES. */ private[resource] def isAllowedMediaHost(host: String): Boolean = { if (host == null || host.isEmpty) return false @@ -729,19 +562,6 @@ object HuggingFaceModelResource { private def errorResponse(statusCode: Int, message: String): Response = Response.status(statusCode).entity(errorJson(message)).build() - private[resource] def inferAudioContentType(path: NioPath): String = { - val fileName = Option(path.getFileName).map(_.toString.toLowerCase).getOrElse("") - if (fileName.endsWith(".mp3") || fileName.endsWith(".mpeg")) "audio/mpeg" - else if (fileName.endsWith(".wav")) "audio/wav" - else if (fileName.endsWith(".flac")) "audio/flac" - else if (fileName.endsWith(".ogg") || fileName.endsWith(".oga")) "audio/ogg" - else if (fileName.endsWith(".webm")) "audio/webm" - else if (fileName.endsWith(".opus")) "audio/webm;codecs=opus" - else if (fileName.endsWith(".amr")) "audio/amr" - else if (fileName.endsWith(".m4a")) "audio/m4a" - else "application/octet-stream" - } - /** Result of a paginated fetch — `truncated` is true if pagination stopped early. */ private case class PageResult( models: java.util.List[java.util.Map[String, Object]], diff --git a/amber/src/test/scala/org/apache/texera/web/resource/HuggingFaceModelResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/HuggingFaceModelResourceSpec.scala index d195e11640..a4e0d14e5e 100644 --- a/amber/src/test/scala/org/apache/texera/web/resource/HuggingFaceModelResourceSpec.scala +++ b/amber/src/test/scala/org/apache/texera/web/resource/HuggingFaceModelResourceSpec.scala @@ -36,7 +36,6 @@ import org.scalatest.funsuite.AnyFunSuite import java.io.{ByteArrayInputStream, InputStream, InputStreamReader} import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Path, Paths} import java.util.function.{Function => JFunction} import javax.ws.rs.core.Response import scala.collection.mutable @@ -63,23 +62,9 @@ class HuggingFaceModelResourceSpec extends AnyFunSuite with BeforeAndAfterEach { // Reset caches between tests so cache hits from one test can't leak into another. modelCache.invalidateAll() taskCache.invalidateAll() - // Make sure the audio temp dir exists for tests that read from it. - Files.createDirectories(audioTempDir) } override def afterEach(): Unit = { - // Clean up any temp files this test created. - if (Files.exists(audioTempDir)) { - val stream = Files.list(audioTempDir) - try { - stream.forEach { p => - try Files.deleteIfExists(p) - catch { case _: Exception => () } - } - } finally { - stream.close() - } - } modelCache.invalidateAll() taskCache.invalidateAll() // Drop any stub HTTP client this test installed so the global Unirest @@ -105,14 +90,6 @@ class HuggingFaceModelResourceSpec extends AnyFunSuite with BeforeAndAfterEach { assert(node.has("error"), s"expected JSON error body, got: $body") } - // Helper: build a small in-memory InputStream from a UTF-8 string. - private def streamOf(s: String): InputStream = - new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8)) - - // Helper: build an InputStream of `n` zero-bytes. - private def streamOfBytes(n: Int): InputStream = - new ByteArrayInputStream(new Array[Byte](n)) - // ──────────────────────────────────────────────────────────────────────── // sanitizeToken // ──────────────────────────────────────────────────────────────────────── @@ -250,286 +227,6 @@ class HuggingFaceModelResourceSpec extends AnyFunSuite with BeforeAndAfterEach { assert(node.get("error").asText() == "") } - // ──────────────────────────────────────────────────────────────────────── - // inferAudioContentType — extension → MIME type - // ──────────────────────────────────────────────────────────────────────── - - test("inferAudioContentType returns audio/mpeg for .mp3") { - assert(inferAudioContentType(Paths.get("clip.mp3")) == "audio/mpeg") - } - - test("inferAudioContentType returns audio/mpeg for .mpeg") { - assert(inferAudioContentType(Paths.get("clip.mpeg")) == "audio/mpeg") - } - - test("inferAudioContentType returns audio/wav for .wav") { - assert(inferAudioContentType(Paths.get("clip.wav")) == "audio/wav") - } - - test("inferAudioContentType returns audio/flac for .flac") { - assert(inferAudioContentType(Paths.get("clip.flac")) == "audio/flac") - } - - test("inferAudioContentType returns audio/ogg for .ogg") { - assert(inferAudioContentType(Paths.get("clip.ogg")) == "audio/ogg") - } - - test("inferAudioContentType returns audio/ogg for .oga") { - assert(inferAudioContentType(Paths.get("clip.oga")) == "audio/ogg") - } - - test("inferAudioContentType returns audio/webm for .webm") { - assert(inferAudioContentType(Paths.get("clip.webm")) == "audio/webm") - } - - test("inferAudioContentType returns audio/webm;codecs=opus for .opus") { - assert(inferAudioContentType(Paths.get("clip.opus")) == "audio/webm;codecs=opus") - } - - test("inferAudioContentType returns audio/amr for .amr") { - assert(inferAudioContentType(Paths.get("clip.amr")) == "audio/amr") - } - - test("inferAudioContentType returns audio/m4a for .m4a") { - assert(inferAudioContentType(Paths.get("clip.m4a")) == "audio/m4a") - } - - test("inferAudioContentType falls back to octet-stream for unknown extension") { - assert(inferAudioContentType(Paths.get("clip.xyz")) == "application/octet-stream") - assert(inferAudioContentType(Paths.get("noextension")) == "application/octet-stream") - } - - test("inferAudioContentType is case-insensitive") { - assert(inferAudioContentType(Paths.get("clip.WAV")) == "audio/wav") - assert(inferAudioContentType(Paths.get("clip.MP3")) == "audio/mpeg") - } - - // ──────────────────────────────────────────────────────────────────────── - // uploadAudioReference — input validation & size cap - // ──────────────────────────────────────────────────────────────────────── - - test("uploadAudioReference returns 400 when stream is null") { - val response = resource.uploadAudioReference("voice.wav", null) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference returns 400 when stream is empty") { - val response = resource.uploadAudioReference("voice.wav", streamOfBytes(0)) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference rejects .sh extension") { - val response = resource.uploadAudioReference("evil.sh", streamOf("payload")) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference rejects .html extension") { - val response = resource.uploadAudioReference("trick.html", streamOf("<script>")) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference rejects .bat extension") { - val response = resource.uploadAudioReference("run.bat", streamOf("@echo off")) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference rejects .exe extension") { - val response = resource.uploadAudioReference("malware.exe", streamOf("MZ")) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference rejects files with no extension") { - val response = resource.uploadAudioReference("recording", streamOf("data")) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference rejects null filename (default audio.bin not in allowlist)") { - val response = resource.uploadAudioReference(null, streamOf("data")) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference rejects empty filename (default audio.bin not in allowlist)") { - val response = resource.uploadAudioReference("", streamOf("data")) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference rejects whitespace-only filename") { - val response = resource.uploadAudioReference(" ", streamOf("data")) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference accepts a valid .wav upload") { - val payload = "RIFF....WAVE....fake-wav-content".getBytes(StandardCharsets.UTF_8) - val response = resource.uploadAudioReference("voice.wav", new ByteArrayInputStream(payload)) - assert(response.getStatus == 200) - - val node = mapper.readTree(entityString(response)) - assert(node.has("path")) - assert(node.has("fileName")) - assert(node.get("fileName").asText() == "voice.wav") - - // Verify the file was actually written with the right contents. - val savedPath = Paths.get(node.get("path").asText()) - assert(Files.exists(savedPath)) - assert(Files.readAllBytes(savedPath).sameElements(payload)) - // The saved file should land inside the audioTempDir. - assert(savedPath.toAbsolutePath.normalize().startsWith(audioTempDir.toAbsolutePath.normalize())) - } - - test("uploadAudioReference lowercases the extension for the temp file") { - val response = resource.uploadAudioReference("voice.WAV", streamOf("RIFF")) - assert(response.getStatus == 200) - - val node = mapper.readTree(entityString(response)) - val savedPath = Paths.get(node.get("path").asText()) - assert(savedPath.getFileName.toString.endsWith(".wav")) - } - - test("uploadAudioReference strips path components from filename") { - // ?filename=../../etc/passwd should be reduced to passwd (no extension) — rejected - val response = resource.uploadAudioReference("../../etc/passwd", streamOf("data")) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("uploadAudioReference returns 413 for payload exceeding MAX_AUDIO_BYTES") { - val oversize = MAX_AUDIO_BYTES.toInt + 1 - val response = resource.uploadAudioReference("big.wav", streamOfBytes(oversize)) - assert(response.getStatus == 413) - assertErrorBody(response) - } - - test("uploadAudioReference cleans up partial file when size cap is exceeded") { - val sweepBefore = listAudioTempFiles() - val oversize = MAX_AUDIO_BYTES.toInt + 1 - val response = resource.uploadAudioReference("big.wav", streamOfBytes(oversize)) - assert(response.getStatus == 413) - val sweepAfter = listAudioTempFiles() - // No new file should remain after the rejection (existing files unchanged). - assert( - sweepAfter.length <= sweepBefore.length, - s"oversize upload left a partial file: before=$sweepBefore after=$sweepAfter" - ) - } - - test("uploadAudioReference accepts all allowlisted extensions") { - ALLOWED_AUDIO_EXTENSIONS.foreach { ext => - val response = resource.uploadAudioReference(s"clip$ext", streamOf("data")) - assert(response.getStatus == 200, s"extension $ext should have been accepted") - } - } - - private def listAudioTempFiles(): Array[Path] = { - if (!Files.exists(audioTempDir)) return Array.empty - val stream = Files.list(audioTempDir) - try { - val arr = stream.toArray.asInstanceOf[Array[Object]].map(_.asInstanceOf[Path]) - arr - } finally { - stream.close() - } - } - - // ──────────────────────────────────────────────────────────────────────── - // previewUploadedAudio — path validation - // ──────────────────────────────────────────────────────────────────────── - - test("previewUploadedAudio returns 400 when path is null") { - val response = resource.previewUploadedAudio(null) - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("previewUploadedAudio returns 400 when path is empty") { - val response = resource.previewUploadedAudio("") - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("previewUploadedAudio returns 400 when path is whitespace") { - val response = resource.previewUploadedAudio(" ") - assert(response.getStatus == 400) - assertErrorBody(response) - } - - test("previewUploadedAudio returns 403 when path is outside the temp directory") { - val response = resource.previewUploadedAudio("/etc/passwd") - assert(response.getStatus == 403) - assertErrorBody(response) - } - - test("previewUploadedAudio rejects path traversal attempts") { - val traversalPath = - audioTempDir.toAbsolutePath.toString + "/../../etc/passwd" - val response = resource.previewUploadedAudio(traversalPath) - assert(response.getStatus == 403) - assertErrorBody(response) - } - - test("previewUploadedAudio returns 404 for a non-existent file inside temp dir") { - val ghost = audioTempDir.resolve("does-not-exist.wav").toAbsolutePath.toString - val response = resource.previewUploadedAudio(ghost) - assert(response.getStatus == 404) - assertErrorBody(response) - } - - test("previewUploadedAudio returns 404 when path points to a directory, not a file") { - val response = resource.previewUploadedAudio(audioTempDir.toAbsolutePath.toString) - assert(response.getStatus == 404) - assertErrorBody(response) - } - - test("previewUploadedAudio streams back a valid file with correct content-type") { - val payload = "fake-wav-bytes".getBytes(StandardCharsets.UTF_8) - val file = Files.createTempFile(audioTempDir, "test-preview-", ".wav") - Files.write(file, payload) - - val response = resource.previewUploadedAudio(file.toAbsolutePath.toString) - assert(response.getStatus == 200) - val bytes = entityBytes(response) - assert(bytes.sameElements(payload)) - } - - test( - "previewUploadedAudio returns 413 when the on-disk file exceeds MAX_AUDIO_BYTES (defense-in-depth)" - ) { - // /upload-audio caps ingest at MAX_AUDIO_BYTES, but the preview endpoint - // shouldn't trust that invariant — a future bug or out-of-band write could - // leave an oversized file in the temp dir. Reads of those files must not - // OOM the JVM. - val file = Files.createTempFile(audioTempDir, "test-oversize-", ".wav") - // Create a sparse file of size MAX_AUDIO_BYTES + 1 without actually - // writing that many bytes to disk. - val raf = new java.io.RandomAccessFile(file.toFile, "rw") - try raf.setLength(MAX_AUDIO_BYTES + 1) - finally raf.close() - - val response = resource.previewUploadedAudio(file.toAbsolutePath.toString) - assert(response.getStatus == 413) - assertErrorBody(response) - } - - test("previewUploadedAudio normalizes the path before checking containment") { - val payload = "ok".getBytes(StandardCharsets.UTF_8) - val file = Files.createTempFile(audioTempDir, "test-norm-", ".wav") - Files.write(file, payload) - - // Same file referenced via a non-normalized path (extra slashes / dot-segments). - val weird = audioTempDir.toAbsolutePath.toString + "/./" + file.getFileName.toString - val response = resource.previewUploadedAudio(weird) - assert(response.getStatus == 200) - } - // ──────────────────────────────────────────────────────────────────────── // proxyRemoteMedia — input validation & SSRF // ──────────────────────────────────────────────────────────────────────── @@ -695,59 +392,6 @@ class HuggingFaceModelResourceSpec extends AnyFunSuite with BeforeAndAfterEach { assert(before == after, "user-token request should not alter the anonymous task cache slot") } - // ──────────────────────────────────────────────────────────────────────── - // sweepOldAudioFiles — temp directory cleanup - // ──────────────────────────────────────────────────────────────────────── - - test("sweepOldAudioFiles deletes files older than the TTL") { - val oldFile = Files.createTempFile(audioTempDir, "test-sweep-old-", ".wav") - Files.write(oldFile, "old".getBytes(StandardCharsets.UTF_8)) - // Force the lastModified time to be older than the TTL window. - val oldTime = java.nio.file.attribute.FileTime.fromMillis( - System.currentTimeMillis() - AUDIO_TEMP_TTL_MS - 60000L - ) - Files.setLastModifiedTime(oldFile, oldTime) - - sweepOldAudioFiles(audioTempDir) - - assert(!Files.exists(oldFile), "old file should have been swept") - } - - test("sweepOldAudioFiles preserves files newer than the TTL") { - val freshFile = Files.createTempFile(audioTempDir, "test-sweep-fresh-", ".wav") - Files.write(freshFile, "fresh".getBytes(StandardCharsets.UTF_8)) - // Default mtime is now; explicitly set to be safe. - val recentTime = java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis()) - Files.setLastModifiedTime(freshFile, recentTime) - - sweepOldAudioFiles(audioTempDir) - - assert(Files.exists(freshFile), "fresh file should have been preserved") - } - - test("sweepOldAudioFiles handles a missing directory gracefully") { - val ghostDir = - Paths.get(System.getProperty("java.io.tmpdir"), "texera-hf-audio-ghost-" + System.nanoTime()) - // Don't create it. The sweep should swallow the IOException and not throw. - sweepOldAudioFiles(ghostDir) - // (no assertion needed — reaching this line means no exception escaped) - succeed - } - - test("sweepOldAudioFiles only deletes regular files, not subdirectories") { - val subdir = Files.createTempDirectory(audioTempDir, "test-sweep-subdir-") - val oldTime = java.nio.file.attribute.FileTime.fromMillis( - System.currentTimeMillis() - AUDIO_TEMP_TTL_MS - 60000L - ) - Files.setLastModifiedTime(subdir, oldTime) - - sweepOldAudioFiles(audioTempDir) - - assert(Files.exists(subdir), "subdirectory should be preserved (sweep only deletes files)") - // cleanup - Files.deleteIfExists(subdir) - } - // ════════════════════════════════════════════════════════════════════════ // In-process Unirest stub — lets the upstream-facing paths (search, browse // pagination, task fan-out, media proxy) run for real without any network. diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.html b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.html index 507528e8d4..e44aad77be 100644 --- a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.html +++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.html @@ -18,17 +18,11 @@ --> <div class="hf-audio-upload"> - <div class="hf-audio-guidance"> - Audio files are uploaded to temporary backend storage and referenced from the operator, so larger clips can be used - without bloating the workflow JSON. - </div> - <input #fileInput type="file" accept="audio/*" class="hf-audio-upload-input" - [disabled]="isUploading" (change)="onFileSelected($event)" /> <div @@ -38,17 +32,11 @@ controls [src]="previewSrc"></audio> <div class="hf-audio-meta"> - <span>{{ fileName || "Selected audio" }}</span> - <span - *ngIf="isUploading" - class="hf-audio-status" - >Uploading...</span - > + <span>{{ displayFileName || "Selected audio" }}</span> <button nz-button nzSize="small" type="button" - [disabled]="isUploading" (click)="clearAudio(fileInput)"> Clear </button> diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.scss b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.scss index 0757524e04..a5245a5399 100644 --- a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.scss +++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.scss @@ -23,12 +23,6 @@ gap: 8px; } -.hf-audio-guidance { - color: #595959; - font-size: 12px; - line-height: 1.4; -} - .hf-audio-upload-input { width: 100%; } @@ -58,11 +52,6 @@ white-space: nowrap; } -.hf-audio-status { - color: #595959; - font-size: 12px; -} - .hf-audio-error { color: #cf1322; } diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts index d2ec9f681c..c8598b1224 100644 --- a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts +++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts @@ -18,743 +18,437 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { FormControl } from "@angular/forms"; -import { FieldTypeConfig } from "@ngx-formly/core"; import { By } from "@angular/platform-browser"; -import { AppSettings } from "../../../common/app-setting"; import { HuggingFaceAudioUploadComponent } from "./hugging-face-audio-upload.component"; +import { commonTestProviders } from "../../../common/testing/test-utils"; -const API = "api"; +const MAX_AUDIO_BYTES = 25 * 1024 * 1024; describe("HuggingFaceAudioUploadComponent", () => { let component: HuggingFaceAudioUploadComponent; - let httpTestingController: HttpTestingController; - let formControl: FormControl; - - function makeFileEvent(file: File | null): Event { - const input = document.createElement("input"); - if (file) { - Object.defineProperty(input, "files", { value: [file] }); - } - return { target: input } as unknown as Event; - } - - function makeFileEventWithInput(file: File | null): { event: Event; input: HTMLInputElement } { - const input = document.createElement("input"); - if (file) { - Object.defineProperty(input, "files", { value: [file] }); - } - return { event: { target: input } as unknown as Event, input }; - } + let fixture: ComponentFixture<HuggingFaceAudioUploadComponent>; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [HuggingFaceAudioUploadComponent, HttpClientTestingModule], + imports: [HuggingFaceAudioUploadComponent], + providers: [...commonTestProviders], }).compileComponents(); - vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API); - - const fixture = TestBed.createComponent(HuggingFaceAudioUploadComponent); + fixture = TestBed.createComponent(HuggingFaceAudioUploadComponent); component = fixture.componentInstance; - formControl = new FormControl(""); - component.field = { formControl, key: "audioInput", model: {} } as unknown as FieldTypeConfig; - httpTestingController = TestBed.inject(HttpTestingController); + component.field = { + props: {}, + formControl: new FormControl(""), + key: "audioInput", + model: {}, + } as any; + fixture.detectChanges(); }); - afterEach(() => { - httpTestingController.verify(); - }); + // ── Helpers ────────────────────────────────────────────────────────────── - it("should be defined", () => { - expect(component).toBeDefined(); - }); + function makeFileInput(file?: File): HTMLInputElement { + const input = document.createElement("input"); + input.type = "file"; + if (file) { + Object.defineProperty(input, "files", { + value: [file] as unknown as FileList, + configurable: true, + }); + } + return input; + } - // ── ngOnInit ── + function sizedFile(name: string, type: string, size: number, content = "audio"): File { + const file = new File([content], name, { type }); + Object.defineProperty(file, "size", { value: size, configurable: true }); + return file; + } - describe("ngOnInit", () => { - it("should set fileName from existing formControl value", () => { - formControl.setValue("/uploads/my-clip.wav"); - component.ngOnInit(); - expect(component.fileName).toBe("my-clip.wav"); - // ngOnInit fires an authenticated blob fetch for the server path - httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - }); + interface ReaderMockOptions { + /** Value returned by FileReader.result. Default: a valid audio data URL. */ + readerResult?: string | ArrayBuffer | null; + /** If true, FileReader fires onerror instead of onload. */ + readerError?: boolean; + } - it("should set fileName to 'Selected audio' for data:audio values", () => { - formControl.setValue("data:audio/wav;base64,abc123"); - component.ngOnInit(); - expect(component.fileName).toBe("Selected audio"); - }); + /** Installs a fake FileReader that resolves via microtask. Returns teardown. */ + function installReaderMock(opts: ReaderMockOptions = {}): () => void { + const savedFileReader = globalThis.FileReader; + const readerResult = "readerResult" in opts ? opts.readerResult : "data:audio/wav;base64,AAAA"; + + class FakeFileReader { + onload: ((e: Event) => void) | null = null; + onerror: ((e: Event) => void) | null = null; + error: unknown = null; + result: string | ArrayBuffer | null = readerResult!; + readAsDataURL() { + queueMicrotask(() => { + if (opts.readerError) { + this.error = new Error("read failed"); + this.onerror?.(new Event("error")); + } else { + this.onload?.(new Event("load")); + } + }); + } + } + (globalThis as any).FileReader = FakeFileReader; - it("should not set fileName when formControl is empty", () => { - formControl.setValue(""); - component.ngOnInit(); - expect(component.fileName).toBe(""); - }); + return () => { + (globalThis as any).FileReader = savedFileReader; + }; + } - it("should not set fileName when formControl is whitespace", () => { - formControl.setValue(" "); - component.ngOnInit(); - expect(component.fileName).toBe(""); - }); + it("should create", () => { + expect(component).toBeTruthy(); }); - // ── previewSrc ── + // ── Derived view state ───────────────────────────────────────────────── - describe("previewSrc", () => { - it("should return empty string when formControl is empty and no local preview", () => { + describe("derived view state", () => { + it("reports no audio when formControl is empty", () => { + expect(component.hasAudio).toBe(false); expect(component.previewSrc).toBe(""); + expect(component.displayFileName).toBe(""); }); - it("should return empty for a stored server path (blob URL loaded asynchronously)", () => { - formControl.setValue("/uploads/clip.wav"); - expect(component.previewSrc).toBe(""); + it("reports audio when formControl holds a data:audio URL", () => { + component.formControl.setValue("data:audio/wav;base64,AAA"); + expect(component.hasAudio).toBe(true); + expect(component.previewSrc).toBe("data:audio/wav;base64,AAA"); + expect(component.displayFileName).toBe("Selected audio"); }); - it("should return data:audio value as-is", () => { - const dataUrl = "data:audio/wav;base64,abc123"; - formControl.setValue(dataUrl); - expect(component.previewSrc).toBe(dataUrl); + it("prefers the explicit filename over the fallback label", () => { + component.formControl.setValue("data:audio/wav;base64,AAA"); + component.fileName = "clip.wav"; + expect(component.displayFileName).toBe("clip.wav"); }); - it("should return empty string for whitespace-only value", () => { - formControl.setValue(" "); + it("returns false for a leftover server path (legacy saved value)", () => { + component.formControl.setValue("/uploads/clip.wav"); + expect(component.hasAudio).toBe(false); expect(component.previewSrc).toBe(""); }); - it("should return localPreviewUrl when a file has been selected but upload is in progress", async () => { - vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview"); - vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined); - - const file = new File(["audio"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - expect(component.previewSrc).toBe("blob:fake-preview"); + it("returns false for a non-audio data URL", () => { + component.formControl.setValue("data:image/png;base64,AAA"); + expect(component.hasAudio).toBe(false); + }); - httpTestingController - .expectOne(r => r.url.includes("/huggingface/upload-audio")) - .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; + it("returns false for a null value", () => { + component.formControl.setValue(null); + expect(component.hasAudio).toBe(false); }); }); - // ── File upload ── + // ── onFileSelected ────────────────────────────────────────────────────── describe("onFileSelected", () => { - it("should reject a non-audio file", async () => { - const file = new File(["data"], "doc.pdf", { type: "application/pdf" }); - await component.onFileSelected(makeFileEvent(file)); - - expect(component.errorMessage).toBe("Choose an audio file."); - expect(formControl.value).toBe(""); - }); - - it("should upload an audio file and set formControl value", async () => { - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - const req = httpTestingController.expectOne( - r => r.method === "POST" && r.url.includes("/huggingface/upload-audio") - ); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - - expect(formControl.value).toBe("/tmp/clip.wav"); - expect(component.fileName).toBe("clip.wav"); - expect(component.isUploading).toBe(false); - }); - - it("should guard against concurrent uploads", async () => { - component.isUploading = true; - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - await component.onFileSelected(makeFileEvent(file)); - - httpTestingController.expectNone(r => r.url.includes("/huggingface/upload-audio")); - expect(formControl.value).toBe(""); - }); - - it("should do nothing when no file is selected", async () => { - await component.onFileSelected(makeFileEvent(null)); - - httpTestingController.expectNone(r => r.url.includes("/huggingface/upload-audio")); - expect(formControl.value).toBe(""); - expect(component.errorMessage).toBe(""); - }); - - it("should set isUploading while upload is in progress", async () => { - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - expect(component.isUploading).toBe(true); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - - expect(component.isUploading).toBe(false); - }); - - it("should clear error message before new upload", async () => { + it("clears prior error and returns early when no file is provided", async () => { component.errorMessage = "previous error"; - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - + const input = makeFileInput(); + await component.onFileSelected({ target: input } as unknown as Event); expect(component.errorMessage).toBe(""); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - }); - - it("should show error on upload failure", async () => { - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.error(new ProgressEvent("error")); - await uploadPromise; - - expect(component.errorMessage).toBe("Could not upload this audio file."); - expect(component.isUploading).toBe(false); - expect(formControl.value).toBe(""); + expect(component.formControl.value).toBe(""); }); - it("should use file.name as fallback when response.fileName is empty", async () => { - const file = new File(["audio-data"], "my-clip.mp3", { type: "audio/mp3" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.flush({ path: "/tmp/my-clip.mp3", fileName: "" }); - await uploadPromise; - - expect(component.fileName).toBe("my-clip.mp3"); - }); - - it("should update the model when key is a string", async () => { + it("rejects a non-audio file and resets the input", async () => { + const file = sizedFile("doc.pdf", "application/pdf", 100); + const input = makeFileInput(file); + await component.onFileSelected({ target: input } as unknown as Event); + expect(component.errorMessage).toBe("Choose an audio file."); + expect(component.hasAudio).toBe(false); + expect(input.value).toBe(""); + }); + + it("rejects a file over the size cap", async () => { + const file = sizedFile("big.wav", "audio/wav", MAX_AUDIO_BYTES + 1); + const input = makeFileInput(file); + await component.onFileSelected({ target: input } as unknown as Event); + expect(component.errorMessage).toBe("Audio file is too large (max 25 MB)."); + expect(component.formControl.value).toBe(""); + }); + + it("accepts a file exactly at the size cap", async () => { + const teardown = installReaderMock(); + try { + const file = sizedFile("edge.wav", "audio/wav", MAX_AUDIO_BYTES); + const input = makeFileInput(file); + await component.onFileSelected({ target: input } as unknown as Event); + expect(component.errorMessage).toBe(""); + expect(component.formControl.value).toBe("data:audio/wav;base64,AAAA"); + } finally { + teardown(); + } + }); + + it("reads an audio file into a data URL and sets form + model state", async () => { + const teardown = installReaderMock(); + try { + const file = sizedFile("clip.wav", "audio/wav", 2048); + const input = makeFileInput(file); + await component.onFileSelected({ target: input } as unknown as Event); + + expect(component.formControl.value).toBe("data:audio/wav;base64,AAAA"); + expect(component.fileName).toBe("clip.wav"); + expect(component.hasAudio).toBe(true); + expect(component.formControl.dirty).toBe(true); + expect(component.formControl.touched).toBe(true); + expect((component.model as any).audioInput).toBe("data:audio/wav;base64,AAAA"); + expect(component.errorMessage).toBe(""); + } finally { + teardown(); + } + }); + + it("clears a previous error on a successful read", async () => { + component.errorMessage = "previous failure"; + const teardown = installReaderMock(); + try { + const file = sizedFile("ok.mp3", "audio/mpeg", 2048); + const input = makeFileInput(file); + await component.onFileSelected({ target: input } as unknown as Event); + expect(component.errorMessage).toBe(""); + } finally { + teardown(); + } + }); + + it("rejects when FileReader yields a non-audio data URL", async () => { + const teardown = installReaderMock({ readerResult: "data:text/plain;base64,AAAA" }); + try { + const file = sizedFile("weird.wav", "audio/wav", 2048); + const input = makeFileInput(file); + await component.onFileSelected({ target: input } as unknown as Event); + expect(component.errorMessage).toBe("Could not read this audio file."); + expect(component.hasAudio).toBe(false); + } finally { + teardown(); + } + }); + + it("rejects when FileReader.result is not a string (ArrayBuffer)", async () => { + const teardown = installReaderMock({ readerResult: new ArrayBuffer(8) }); + try { + const file = sizedFile("clip.wav", "audio/wav", 2048); + const input = makeFileInput(file); + await component.onFileSelected({ target: input } as unknown as Event); + expect(component.errorMessage).toBe("Could not read this audio file."); + } finally { + teardown(); + } + }); + + it("rejects when FileReader fires onerror", async () => { + const teardown = installReaderMock({ readerError: true }); + try { + const file = sizedFile("broken.wav", "audio/wav", 2048); + const input = makeFileInput(file); + await component.onFileSelected({ target: input } as unknown as Event); + expect(component.errorMessage).toBe("Could not read this audio file."); + expect(component.hasAudio).toBe(false); + } finally { + teardown(); + } + }); + + it("does not update the model when key is not a string", async () => { const model: Record<string, unknown> = {}; - component.field = { formControl, key: "audioInput", model } as unknown as FieldTypeConfig; - - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - - expect(model["audioInput"]).toBe("/tmp/clip.wav"); - }); - - it("should send correct Content-Type and URL", async () => { - const file = new File(["audio-data"], "my clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - expect(req.request.url).toContain("filename=my%20clip.wav"); - expect(req.request.headers.get("Content-Type")).toBe("application/octet-stream"); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - }); - - it("should discard stale upload response when cleared during upload", async () => { - vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview"); - vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined); - - const file = new File(["audio"], "clip.wav", { type: "audio/wav" }); - const { event, input } = makeFileEventWithInput(file); - const uploadPromise = component.onFileSelected(event); + component.field = { + props: {}, + formControl: component.formControl, + key: 42 as any, + model, + } as any; + + const teardown = installReaderMock(); + try { + const file = sizedFile("clip.wav", "audio/wav", 2048); + const input = makeFileInput(file); + await component.onFileSelected({ target: input } as unknown as Event); + expect(component.formControl.value).toBe("data:audio/wav;base64,AAAA"); + expect(model[42 as any]).toBeUndefined(); + } finally { + teardown(); + } + }); + + // Review feedback on #7790: with a clip already accepted, every reject path + // must drop it. Leaving it behind shows an error while the <audio> preview + // keeps playing and the operator silently runs the previous audio. + async function selectValidClip(): Promise<void> { + const teardown = installReaderMock({ readerResult: "data:audio/wav;base64,FIRST" }); + try { + await component.onFileSelected({ + target: makeFileInput(sizedFile("good.wav", "audio/wav", 2048)), + } as unknown as Event); + } finally { + teardown(); + } + expect(component.hasAudio).toBe(true); + } - component.clearAudio(input); + it("drops a previously accepted clip when a re-selection is not audio", async () => { + (component.field as any).model = { audioInput: "" }; + await selectValidClip(); - httpTestingController - .expectOne(r => r.url.includes("/huggingface/upload-audio")) - .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; + const input = makeFileInput(sizedFile("doc.pdf", "application/pdf", 100)); + await component.onFileSelected({ target: input } as unknown as Event); - expect(formControl.value).toBe(""); + expect(component.errorMessage).toBe("Choose an audio file."); + expect(component.formControl.value).toBe(""); expect(component.fileName).toBe(""); + expect(component.hasAudio).toBe(false); + expect(input.value).toBe(""); + expect((component.field as any).model.audioInput).toBe(""); }); - it("should discard stale upload error when cleared during upload", async () => { - vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview"); - vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined); - - const file = new File(["audio"], "clip.wav", { type: "audio/wav" }); - const { event, input } = makeFileEventWithInput(file); - const uploadPromise = component.onFileSelected(event); - - component.clearAudio(input); + it("drops a previously accepted clip when a re-selection is over the size cap", async () => { + await selectValidClip(); - httpTestingController - .expectOne(r => r.url.includes("/huggingface/upload-audio")) - .error(new ProgressEvent("error")); - await uploadPromise; + const input = makeFileInput(sizedFile("big.wav", "audio/wav", MAX_AUDIO_BYTES + 1)); + await component.onFileSelected({ target: input } as unknown as Event); - expect(component.errorMessage).toBe(""); + expect(component.errorMessage).toBe("Audio file is too large (max 25 MB)."); + expect(component.formControl.value).toBe(""); + expect(component.fileName).toBe(""); + expect(component.hasAudio).toBe(false); + expect(input.value).toBe(""); + }); + + it("drops a previously accepted clip when the re-selection fails to read", async () => { + await selectValidClip(); + + const teardown = installReaderMock({ readerError: true }); + try { + const input = makeFileInput(sizedFile("broken.wav", "audio/wav", 2048)); + await component.onFileSelected({ target: input } as unknown as Event); + expect(component.errorMessage).toBe("Could not read this audio file."); + expect(component.formControl.value).toBe(""); + expect(component.fileName).toBe(""); + expect(component.hasAudio).toBe(false); + expect(input.value).toBe(""); + } finally { + teardown(); + } + }); + + it("replaces a previous upload value with a new one", async () => { + const teardown = installReaderMock({ readerResult: "data:audio/wav;base64,FIRST" }); + try { + const input1 = makeFileInput(sizedFile("first.wav", "audio/wav", 2048)); + await component.onFileSelected({ target: input1 } as unknown as Event); + expect(component.formControl.value).toBe("data:audio/wav;base64,FIRST"); + expect(component.fileName).toBe("first.wav"); + } finally { + teardown(); + } + + const teardown2 = installReaderMock({ readerResult: "data:audio/mpeg;base64,SECOND" }); + try { + const input2 = makeFileInput(sizedFile("second.mp3", "audio/mpeg", 2048)); + await component.onFileSelected({ target: input2 } as unknown as Event); + expect(component.formControl.value).toBe("data:audio/mpeg;base64,SECOND"); + expect(component.fileName).toBe("second.mp3"); + } finally { + teardown2(); + } }); }); - // ── clearAudio ── + // ── clearAudio ────────────────────────────────────────────────────────── describe("clearAudio", () => { - it("should reset all state", () => { + it("resets file state, the form control, and any model value", () => { + (component.field as any).model = { audioInput: "data:audio/wav;base64,AAA" }; + component.formControl.setValue("data:audio/wav;base64,AAA"); component.fileName = "clip.wav"; component.errorMessage = "some error"; - formControl.setValue("/tmp/clip.wav"); const input = document.createElement("input"); + input.type = "file"; component.clearAudio(input); expect(component.fileName).toBe(""); expect(component.errorMessage).toBe(""); - expect(component.isUploading).toBe(false); - expect(formControl.value).toBe(""); - }); - - it("should preserve error message when clearError is false", () => { - component.errorMessage = "upload failed"; - const input = document.createElement("input"); - component.clearAudio(input, false); - - expect(component.errorMessage).toBe("upload failed"); - }); - - it("should clear model value when key is a string", () => { - const model: Record<string, unknown> = { audioInput: "/tmp/clip.wav" }; - component.field = { formControl, key: "audioInput", model } as unknown as FieldTypeConfig; - - const input = document.createElement("input"); - component.clearAudio(input); - - expect(model["audioInput"]).toBe(""); - }); + expect(input.value).toBe(""); + expect(component.formControl.value).toBe(""); + expect(component.formControl.dirty).toBe(true); + expect(component.formControl.touched).toBe(true); + expect((component.model as any).audioInput).toBe(""); + }); + + it("does not update the model when key is not a string", () => { + const model: Record<string, unknown> = { someKey: "value" }; + component.field = { + props: {}, + formControl: component.formControl, + key: 42 as any, + model, + } as any; + component.formControl.setValue("data:audio/wav;base64,AAA"); - it("should mark formControl as dirty and touched", () => { const input = document.createElement("input"); component.clearAudio(input); - expect(formControl.dirty).toBe(true); - expect(formControl.touched).toBe(true); - }); - }); - - // ── loadServerAudioPreview (via ngOnInit) ── - - describe("loadServerAudioPreview", () => { - it("should set localPreviewUrl on successful blob fetch", async () => { - const blobUrl = "blob:http://localhost/fake-audio"; - vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl); - - formControl.setValue("/uploads/clip.wav"); - component.ngOnInit(); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - expect(req.request.responseType).toBe("blob"); - req.flush(new Blob(["audio-data"], { type: "audio/wav" })); - - // Allow microtask (promise .then) to settle - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(component.previewSrc).toBe(blobUrl); - }); - - it("should set errorMessage on blob fetch failure", async () => { - formControl.setValue("/uploads/clip.wav"); - component.ngOnInit(); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - req.error(new ProgressEvent("error")); - - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(component.errorMessage).toBe("Could not load audio preview."); - }); - - it("should discard blob fetch result if formControl value changed", async () => { - const blobUrl = "blob:http://localhost/fake-audio"; - vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl); - - formControl.setValue("/uploads/clip.wav"); - component.ngOnInit(); - - // User cleared the field before fetch completes - formControl.setValue(""); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - req.flush(new Blob(["audio-data"], { type: "audio/wav" })); - - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(component.previewSrc).toBe(""); - }); - - it("should discard error if formControl value changed before fetch fails", async () => { - formControl.setValue("/uploads/clip.wav"); - component.ngOnInit(); - - formControl.setValue(""); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - req.error(new ProgressEvent("error")); - - await new Promise(resolve => setTimeout(resolve, 0)); - - expect(component.errorMessage).toBe(""); - }); - - it("should not fetch for data:audio values in ngOnInit", () => { - formControl.setValue("data:audio/wav;base64,abc123"); - component.ngOnInit(); - - httpTestingController.expectNone(r => r.url.includes("/huggingface/audio-preview")); - }); - - it("should encode server path in the fetch URL", () => { - formControl.setValue("/uploads/my clip.wav"); - component.ngOnInit(); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - expect(req.request.url).toContain("path=%2Fuploads%2Fmy%20clip.wav"); - }); - }); - - // ── previewSrc with localPreviewUrl ── - - describe("previewSrc with localPreviewUrl", () => { - it("should return localPreviewUrl when set via file upload", async () => { - const blobUrl = "blob:http://localhost/local-preview"; - vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl); - vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); - - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - // After file selection, localPreviewUrl should be set - expect(component.previewSrc).toBe(blobUrl); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - }); - }); - - // ── Stale upload guards ── - - describe("stale upload guards", () => { - it("should discard successful upload if cleared during flight", async () => { - const blobUrl = "blob:http://localhost/local-preview"; - vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl); - vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); - - const { event, input } = makeFileEventWithInput(new File(["audio-data"], "clip.wav", { type: "audio/wav" })); - const uploadPromise = component.onFileSelected(event); - - // Clear while upload is in flight - component.clearAudio(input); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - - // Upload result should be discarded — formControl stays empty - expect(formControl.value).toBe(""); - }); - - it("should discard upload error if cleared during flight", async () => { - const blobUrl = "blob:http://localhost/local-preview"; - vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl); - vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); - - const { event, input } = makeFileEventWithInput(new File(["audio-data"], "clip.wav", { type: "audio/wav" })); - const uploadPromise = component.onFileSelected(event); - - component.clearAudio(input); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.error(new ProgressEvent("error")); - await uploadPromise; - - // Error should be discarded — errorMessage stays empty (clearAudio clears it) - expect(component.errorMessage).toBe(""); - }); - }); - - // ── ngOnDestroy ── - - describe("ngOnDestroy", () => { - it("should not throw on destroy", () => { - expect(() => component.ngOnDestroy()).not.toThrow(); - }); - - it("should revoke localPreviewUrl on destroy when upload preview exists", async () => { - const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined); - vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview"); - - const file = new File(["audio"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - httpTestingController - .expectOne(r => r.url.includes("/huggingface/upload-audio")) - .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - - component.ngOnDestroy(); - expect(revokeSpy).toHaveBeenCalledWith("blob:fake-preview"); - }); - - it("should revoke localPreviewUrl on destroy", async () => { - const blobUrl = "blob:http://localhost/local-preview"; - vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl); - const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); - - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - - component.ngOnDestroy(); - expect(revokeSpy).toHaveBeenCalledWith(blobUrl); - }); - }); - - // ── getDisplayName edge cases ── - - describe("getDisplayName (via ngOnInit)", () => { - it("should extract filename from path with forward slashes", () => { - formControl.setValue("/path/to/my-clip.wav"); - component.ngOnInit(); - expect(component.fileName).toBe("my-clip.wav"); - httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - }); - - it("should extract filename from path with backslashes", () => { - formControl.setValue("C:\\uploads\\my-clip.wav"); - component.ngOnInit(); - expect(component.fileName).toBe("my-clip.wav"); - httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - }); - - it("should return 'Selected audio' for path ending with separator", () => { - formControl.setValue("/uploads/"); - component.ngOnInit(); - expect(component.fileName).toBe("Selected audio"); - httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - }); - - it("should return filename for flat name without path", () => { - formControl.setValue("clip.wav"); - component.ngOnInit(); - expect(component.fileName).toBe("clip.wav"); - httpTestingController.expectOne(r => r.url.includes("/huggingface/audio-preview")); - }); - }); - - // ── Upload marks formControl as dirty and touched ── - - describe("formControl state after upload", () => { - it("should mark formControl as dirty and touched after successful upload", async () => { - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - - expect(formControl.dirty).toBe(true); - expect(formControl.touched).toBe(true); - }); - - it("should mark formControl as dirty and touched after clear", () => { - formControl.setValue("/tmp/clip.wav"); - const input = document.createElement("input"); - component.clearAudio(input); - - expect(formControl.dirty).toBe(true); - expect(formControl.touched).toBe(true); - }); - }); - - // ── Upload updates model ── - - describe("model update on upload", () => { - it("should not update model when key is not a string", async () => { - const model: Record<string, unknown> = {}; - component.field = { formControl, key: 42 as any, model } as unknown as FieldTypeConfig; - - const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); - const uploadPromise = component.onFileSelected(makeFileEvent(file)); - - const req = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" }); - await uploadPromise; - - expect(formControl.value).toBe("/tmp/clip.wav"); + expect(component.formControl.value).toBe(""); expect(model[42 as any]).toBeUndefined(); }); }); - // ── revokePreviewUrl no-op ── - - describe("revokePreviewUrl", () => { - it("should not throw when destroying without any preview", () => { - expect(() => component.ngOnDestroy()).not.toThrow(); - }); - }); - - // ── previewSrc with non-audio data URL ── - - describe("previewSrc edge cases", () => { - it("should return empty for non-audio data URL", () => { - formControl.setValue("data:image/png;base64,abc123"); - expect(component.previewSrc).toBe(""); - }); - - it("should return data:audio value with different audio type", () => { - const mp3DataUrl = "data:audio/mp3;base64,abc123"; - formControl.setValue(mp3DataUrl); - expect(component.previewSrc).toBe(mp3DataUrl); - }); - }); - - // ── Multiple consecutive uploads ── - - describe("consecutive uploads", () => { - it("should replace previous upload value with new upload", async () => { - // First upload - const file1 = new File(["audio-1"], "first.wav", { type: "audio/wav" }); - const upload1 = component.onFileSelected(makeFileEvent(file1)); - const req1 = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req1.flush({ path: "/tmp/first.wav", fileName: "first.wav" }); - await upload1; - - expect(formControl.value).toBe("/tmp/first.wav"); - expect(component.fileName).toBe("first.wav"); - - // Second upload - const file2 = new File(["audio-2"], "second.wav", { type: "audio/wav" }); - const upload2 = component.onFileSelected(makeFileEvent(file2)); - const req2 = httpTestingController.expectOne(r => r.url.includes("/huggingface/upload-audio")); - req2.flush({ path: "/tmp/second.wav", fileName: "second.wav" }); - await upload2; - - expect(formControl.value).toBe("/tmp/second.wav"); - expect(component.fileName).toBe("second.wav"); - }); - }); - - // ── Template rendering ── + // ── Template rendering ──────────────────────────────────────────────── describe("template rendering", () => { - let templateFixture: ComponentFixture<HuggingFaceAudioUploadComponent>; - let templateComponent: HuggingFaceAudioUploadComponent; - - beforeEach(() => { - templateFixture = TestBed.createComponent(HuggingFaceAudioUploadComponent); - templateComponent = templateFixture.componentInstance; - const fc = new FormControl(""); - templateComponent.field = { formControl: fc, key: "audioInput", model: {} } as unknown as FieldTypeConfig; - }); - - it("should render the file input", () => { - templateFixture.detectChanges(); - expect(templateFixture.debugElement.query(By.css("input[type='file']"))).toBeTruthy(); - }); - - it("should not render the preview section when there is no audio", () => { - templateFixture.detectChanges(); - expect(templateFixture.debugElement.query(By.css(".hf-audio-preview"))).toBeNull(); + it("renders the file input", () => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css("input[type='file']"))).toBeTruthy(); }); - it("should render the preview section when formControl has a data:audio value", () => { - templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); - templateFixture.detectChanges(); - expect(templateFixture.debugElement.query(By.css(".hf-audio-preview"))).toBeTruthy(); - expect(templateFixture.debugElement.query(By.css("audio"))).toBeTruthy(); + it("does not render the preview section when there is no audio", () => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css(".hf-audio-preview"))).toBeNull(); }); - it("should bind audio [src] to previewSrc for a data:audio value", () => { - templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); - templateFixture.detectChanges(); - const audio = templateFixture.debugElement.query(By.css("audio")).nativeElement as HTMLAudioElement; + it("renders the preview and audio element for a data:audio value", () => { + component.formControl.setValue("data:audio/wav;base64,AAA"); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css(".hf-audio-preview"))).toBeTruthy(); + const audio = fixture.debugElement.query(By.css("audio")).nativeElement as HTMLAudioElement; expect(audio.src).toContain("data:audio/wav"); }); - it("should show fileName in the preview meta", () => { - templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); - templateFixture.detectChanges(); // ngOnInit runs here - templateComponent.fileName = "clip.wav"; - templateFixture.detectChanges(); - const span = templateFixture.debugElement.query(By.css(".hf-audio-meta span")); + it("shows the display filename in the preview meta", () => { + component.formControl.setValue("data:audio/wav;base64,AAA"); + component.fileName = "clip.wav"; + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css(".hf-audio-meta span")); expect((span.nativeElement as HTMLElement).textContent?.trim()).toBe("clip.wav"); }); - it("should fall back to 'Selected audio' when fileName is empty and preview is shown", () => { - templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); - templateComponent.fileName = ""; - templateFixture.detectChanges(); - const span = templateFixture.debugElement.query(By.css(".hf-audio-meta span")); + it("falls back to 'Selected audio' when no filename is set", () => { + component.formControl.setValue("data:audio/wav;base64,AAA"); + component.fileName = ""; + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css(".hf-audio-meta span")); expect((span.nativeElement as HTMLElement).textContent?.trim()).toBe("Selected audio"); }); - it("should show the Uploading status span when isUploading is true and preview is visible", () => { - templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); - templateComponent.isUploading = true; - templateFixture.detectChanges(); - const status = templateFixture.debugElement.query(By.css(".hf-audio-status")); - expect(status).toBeTruthy(); - expect((status.nativeElement as HTMLElement).textContent?.trim()).toBe("Uploading..."); - }); - - it("should hide the Uploading status span when isUploading is false", () => { - templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); - templateComponent.isUploading = false; - templateFixture.detectChanges(); - expect(templateFixture.debugElement.query(By.css(".hf-audio-status"))).toBeNull(); - }); - - it("should disable the Clear button when isUploading is true", () => { - templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); - templateComponent.isUploading = true; - templateFixture.detectChanges(); - const btn = templateFixture.debugElement.query(By.css("button[nz-button]")).nativeElement as HTMLButtonElement; - expect(btn.disabled).toBe(true); - }); - - it("should enable the Clear button when isUploading is false", () => { - templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); - templateComponent.isUploading = false; - templateFixture.detectChanges(); - const btn = templateFixture.debugElement.query(By.css("button[nz-button]")).nativeElement as HTMLButtonElement; - expect(btn.disabled).toBe(false); - }); - - it("should disable the file input when isUploading is true", () => { - templateComponent.isUploading = true; - templateFixture.detectChanges(); - const input = templateFixture.debugElement.query(By.css("input[type='file']")).nativeElement as HTMLInputElement; - expect(input.disabled).toBe(true); - }); - - it("should show the error message when errorMessage is set", () => { - templateComponent.errorMessage = "Could not upload this audio file."; - templateFixture.detectChanges(); - const errorEl = templateFixture.debugElement.query(By.css(".hf-audio-error")); + it("shows the error message when errorMessage is set", () => { + component.errorMessage = "Could not read this audio file."; + fixture.detectChanges(); + const errorEl = fixture.debugElement.query(By.css(".hf-audio-error")); expect(errorEl).toBeTruthy(); - expect((errorEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Could not upload this audio file."); - }); - - it("should not render the error div when errorMessage is empty", () => { - templateFixture.detectChanges(); - expect(templateFixture.debugElement.query(By.css(".hf-audio-error"))).toBeNull(); + expect((errorEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Could not read this audio file."); }); - it("should call clearAudio when the Clear button is clicked", () => { - const clearSpy = vi.spyOn(templateComponent, "clearAudio"); - templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123"); - templateFixture.detectChanges(); - templateFixture.debugElement.query(By.css("button[nz-button]")).triggerEventHandler("click", null); + it("calls clearAudio when the Clear button is clicked", () => { + const clearSpy = vi.spyOn(component, "clearAudio"); + component.formControl.setValue("data:audio/wav;base64,AAA"); + fixture.detectChanges(); + fixture.debugElement.query(By.css("button[nz-button]")).triggerEventHandler("click", null); expect(clearSpy).toHaveBeenCalled(); }); }); diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.ts b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.ts index fc2563a263..6eb728dcdb 100644 --- a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.ts +++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.ts @@ -17,18 +17,17 @@ * under the License. */ -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Component } from "@angular/core"; import { CommonModule } from "@angular/common"; import { FieldType, FieldTypeConfig } from "@ngx-formly/core"; -import { HttpClient } from "@angular/common/http"; import { NzButtonModule } from "ng-zorro-antd/button"; -import { firstValueFrom } from "rxjs"; -import { AppSettings } from "../../../common/app-setting"; -interface HuggingFaceAudioUploadResponse { - path: string; - fileName: string; -} +// Cap the raw audio size before it is inlined as a base64 data URL into the +// workflow JSON. Mirrors the image-upload component's data-URL approach so the +// value the operator stores is directly consumable by the generated Python +// (`_read_audio_input` decodes `data:` URLs); avoids a server-side temp file +// the Python worker cannot read in distributed deployments. +const MAX_AUDIO_BYTES = 25 * 1024 * 1024; @Component({ selector: "texera-hugging-face-audio-upload", @@ -36,52 +35,26 @@ interface HuggingFaceAudioUploadResponse { styleUrls: ["./hugging-face-audio-upload.component.scss"], imports: [CommonModule, NzButtonModule], }) -export class HuggingFaceAudioUploadComponent extends FieldType<FieldTypeConfig> implements OnInit, OnDestroy { +export class HuggingFaceAudioUploadComponent extends FieldType<FieldTypeConfig> { fileName = ""; errorMessage = ""; - isUploading = false; - private localPreviewUrl = ""; - ngOnInit(): void { + get hasAudio(): boolean { const value = this.formControl.value; - if (typeof value === "string" && value.trim().length > 0) { - this.fileName = this.getDisplayName(value); - // If the saved value is a server path, fetch the audio via HttpClient - // (which carries the JWT) and create a blob URL for the <audio> element. - if (!value.startsWith("data:audio/")) { - this.loadServerAudioPreview(value); - } - } - } - - constructor(private http: HttpClient) { - super(); + return typeof value === "string" && value.startsWith("data:audio/"); } get previewSrc(): string { - if (this.localPreviewUrl) { - return this.localPreviewUrl; - } - const value = this.formControl.value; - if (typeof value !== "string" || value.trim().length === 0) { - return ""; - } - if (value.startsWith("data:audio/")) { - return value; - } - // Server path — blob URL is created asynchronously via loadServerAudioPreview. - // Return empty until it's ready; the <audio> element is hidden when previewSrc is empty. - return ""; + return this.hasAudio ? this.formControl.value : ""; } - ngOnDestroy(): void { - this.revokePreviewUrl(); + get displayFileName(): string { + if (this.fileName) return this.fileName; + if (this.hasAudio) return "Selected audio"; + return ""; } async onFileSelected(event: Event): Promise<void> { - if (this.isUploading) { - return; - } this.errorMessage = ""; const input = event.target as HTMLInputElement; const file = input.files?.[0]; @@ -91,54 +64,44 @@ export class HuggingFaceAudioUploadComponent extends FieldType<FieldTypeConfig> } if (!file.type.startsWith("audio/")) { this.errorMessage = "Choose an audio file."; - input.value = ""; + this.resetSelection(input); + return; + } + if (file.size > MAX_AUDIO_BYTES) { + this.errorMessage = "Audio file is too large (max 25 MB)."; + this.resetSelection(input); return; } - this.revokePreviewUrl(); - const previewUrl = URL.createObjectURL(file); - this.localPreviewUrl = previewUrl; - this.isUploading = true; try { - const response = await firstValueFrom( - this.http.post<HuggingFaceAudioUploadResponse>( - `${AppSettings.getApiEndpoint()}/huggingface/upload-audio?filename=${encodeURIComponent(file.name)}`, - file, - { - headers: { - "Content-Type": "application/octet-stream", - }, - } - ) - ); - // If the user clicked Clear while the upload was in flight, - // localPreviewUrl will have been revoked/reset — discard the stale response. - if (this.localPreviewUrl !== previewUrl) return; - this.fileName = response.fileName || file.name; - this.formControl.setValue(response.path); + const dataUrl = await this.readAsDataUrl(file); + if (!dataUrl.startsWith("data:audio/")) { + throw new Error("Not an audio data URL"); + } + this.fileName = file.name; + this.formControl.setValue(dataUrl); if (typeof this.key === "string" && this.model) { - this.model[this.key] = response.path; + this.model[this.key] = dataUrl; } this.formControl.markAsDirty(); this.formControl.markAsTouched(); this.formControl.updateValueAndValidity(); - } catch (err) { - console.error("Audio upload failed:", err); - if (this.localPreviewUrl !== previewUrl) return; - this.clearAudio(input, false); - this.errorMessage = "Could not upload this audio file."; - } finally { - this.isUploading = false; + } catch { + this.errorMessage = "Could not read this audio file."; + this.resetSelection(input); } } - clearAudio(input: HTMLInputElement, clearError: boolean = true): void { + clearAudio(input: HTMLInputElement): void { + this.errorMessage = ""; + this.resetSelection(input); + } + + // A rejected selection must not leave the previously accepted clip behind: + // the form value drives both the <audio> preview and what the operator runs, + // so keeping it would show an error while silently running the old audio. + private resetSelection(input: HTMLInputElement): void { this.fileName = ""; - if (clearError) { - this.errorMessage = ""; - } - this.isUploading = false; - this.revokePreviewUrl(); input.value = ""; this.formControl.setValue(""); if (typeof this.key === "string" && this.model) { @@ -149,44 +112,18 @@ export class HuggingFaceAudioUploadComponent extends FieldType<FieldTypeConfig> this.formControl.updateValueAndValidity(); } - private loadServerAudioPreview(serverPath: string): void { - firstValueFrom( - this.http.get( - `${AppSettings.getApiEndpoint()}/huggingface/audio-preview?path=${encodeURIComponent(serverPath)}`, - { - responseType: "blob", + private readAsDataUrl(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + } else { + reject(new Error("Unexpected FileReader result")); } - ) - ) - .then(blob => { - // Guard against clear/re-upload racing with the fetch - if (this.formControl.value !== serverPath) return; - this.revokePreviewUrl(); - this.localPreviewUrl = URL.createObjectURL(blob); - }) - .catch((err: unknown) => { - console.error("Failed to load audio preview:", err); - if (this.formControl.value !== serverPath) return; - this.errorMessage = "Could not load audio preview."; - }); - } - - private revokePreviewUrl(): void { - if (this.localPreviewUrl) { - URL.revokeObjectURL(this.localPreviewUrl); - this.localPreviewUrl = ""; - } - } - - private getDisplayName(value: string): string { - const trimmedValue = value.trim(); - if (!trimmedValue) { - return ""; - } - if (trimmedValue.startsWith("data:audio/")) { - return "Selected audio"; - } - const segments = trimmedValue.split(/[\\/]/); - return segments[segments.length - 1] || "Selected audio"; + }; + reader.onerror = () => reject(reader.error ?? new Error("Audio read failed")); + reader.readAsDataURL(file); + }); } }
