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-5567-d10e1a29b8704ed9453134cb87e20828ed7684c4 in repository https://gitbox.apache.org/repos/asf/texera.git
commit fcb9710a1a40e5996a451ba823c0fc3abc61dfbd Author: Elliot Lin <[email protected]> AuthorDate: Mon Jun 29 15:20:31 2026 -0700 feat(frontend): add HuggingFace audio upload component (#5567) ### What changes were proposed in this PR? Add `HuggingFaceAudioUploadComponent`, a custom formly field type (`huggingface-audio-upload`) that provides: - An audio file picker that uploads to the Texera backend's `/huggingface/upload-audio` endpoint for server-side storage - Authenticated audio preview/playback — fetches server-stored audio via `HttpClient` (which carries the JWT) and creates a blob URL for the `<audio>` element, avoiding 401s on workflow reload - Local audio preview using a browser Object URL while upload is in progress - Stale response guards — discards upload results and preview fetches if the user clears the field while a request is in flight - Concurrent upload protection — disables the file input and Clear button during upload - Storage of the returned server path in the formly form control - A guidance message explaining why audio is uploaded to backend storage rather than embedded in the workflow JSON This PR also registers `HuggingFaceComponent` and `HuggingFaceAudioUploadComponent` in `formly-config.ts` and declares them in `AppModule`. The `jsonSchemaMapIntercept` mapping that routes the `audioInput` field to this component is added in the follow-up property-editor PR (PR 7). ### Any related issues, documentation, discussions? - Tracking issue: #5314 - Closes: #5314 - Stacked on: #5566 - Parent issue: #5041 ### How was this PR tested? 38 unit tests in `hugging-face-audio-upload.component.spec.ts` covering: - `ngOnInit` — filename extraction from server paths, data URLs, empty/whitespace values - `previewSrc` — returns data URLs as-is, empty for server paths (loaded async), empty for blank values - `onFileSelected` — successful upload, non-audio rejection, concurrent upload guard, no-file guard, upload-in-progress state, error handling, filename fallback, model updates, Content-Type/URL encoding - `loadServerAudioPreview` — successful blob fetch, fetch failure error message, stale response discard on both success and error, no-fetch for data URLs, URL encoding - `clearAudio` — full state reset, error preservation, model clearing, dirty/touched marking - Stale upload guards — discard success/error when cleared during flight - `ngOnDestroy` — blob URL revocation - `getDisplayName` — forward/backslash paths, trailing separator, flat filename Run with `ng test`. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.6 --------- Co-authored-by: Elliot <[email protected]> Co-authored-by: Anish Shivamurthy <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> --- frontend/src/app/app.module.ts | 2 + frontend/src/app/common/formly/formly-config.ts | 2 + .../hugging-face-audio-upload.component.html | 63 +++ .../hugging-face-audio-upload.component.scss | 68 +++ .../hugging-face-audio-upload.component.spec.ts | 484 +++++++++++++++++++++ .../hugging-face-audio-upload.component.ts | 192 ++++++++ 6 files changed, 811 insertions(+) diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index bdebbfba2e..8f5023b50a 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -108,6 +108,7 @@ import { AgentChatComponent } from "./workspace/component/agent/agent-panel/agen import { AgentRegistrationComponent } from "./workspace/component/agent/agent-panel/agent-registration/agent-registration.component"; import { HuggingFaceImageUploadComponent } from "./workspace/component/hugging-face-image-upload/hugging-face-image-upload.component"; import { HuggingFaceComponent } from "./workspace/component/hugging-face/hugging-face.component"; +import { HuggingFaceAudioUploadComponent } from "./workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component"; import { DatasetFileSelectorComponent } from "./workspace/component/dataset-file-selector/dataset-file-selector.component"; import { DatasetVersionSelectorComponent } from "./workspace/component/dataset-version-selector/dataset-version-selector.component"; import { DatasetSelectionModalComponent } from "./workspace/component/dataset-selection-modal/dataset-selection-modal.component"; @@ -333,6 +334,7 @@ registerLocaleData(en); AgentRegistrationComponent, AgentInteractionComponent, HuggingFaceComponent, + HuggingFaceAudioUploadComponent, HuggingFaceImageUploadComponent, DatasetFileSelectorComponent, DatasetVersionSelectorComponent, diff --git a/frontend/src/app/common/formly/formly-config.ts b/frontend/src/app/common/formly/formly-config.ts index f385cf0359..c4fc54fd77 100644 --- a/frontend/src/app/common/formly/formly-config.ts +++ b/frontend/src/app/common/formly/formly-config.ts @@ -31,6 +31,7 @@ import { UiUdfParametersComponent } from "../../workspace/component/ui-udf-param import { DatasetVersionSelectorComponent } from "../../workspace/component/dataset-version-selector/dataset-version-selector.component"; import { HuggingFaceImageUploadComponent } from "../../workspace/component/hugging-face-image-upload/hugging-face-image-upload.component"; import { HuggingFaceComponent } from "../../workspace/component/hugging-face/hugging-face.component"; +import { HuggingFaceAudioUploadComponent } from "../../workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component"; /** * Configuration for using Json Schema with Formly. @@ -83,6 +84,7 @@ export const TEXERA_FORMLY_CONFIG = { { name: "inputautocomplete", component: DatasetFileSelectorComponent, wrappers: ["form-field"] }, { name: "datasetversionselector", component: DatasetVersionSelectorComponent, wrappers: ["form-field"] }, { name: "huggingface", component: HuggingFaceComponent, wrappers: ["form-field"] }, + { name: "huggingface-audio-upload", component: HuggingFaceAudioUploadComponent, wrappers: ["form-field"] }, { name: "huggingface-image-upload", component: HuggingFaceImageUploadComponent, wrappers: ["form-field"] }, { name: "repeat-section-dnd", component: FormlyRepeatDndComponent }, { name: "ui-udf-parameters", component: UiUdfParametersComponent, wrappers: ["form-field"] }, 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 new file mode 100644 index 0000000000..507528e8d4 --- /dev/null +++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.html @@ -0,0 +1,63 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> + +<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 + *ngIf="previewSrc" + class="hf-audio-preview"> + <audio + controls + [src]="previewSrc"></audio> + <div class="hf-audio-meta"> + <span>{{ fileName || "Selected audio" }}</span> + <span + *ngIf="isUploading" + class="hf-audio-status" + >Uploading...</span + > + <button + nz-button + nzSize="small" + type="button" + [disabled]="isUploading" + (click)="clearAudio(fileInput)"> + Clear + </button> + </div> + </div> + + <div + *ngIf="errorMessage" + class="hf-audio-error"> + {{ errorMessage }} + </div> +</div> 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 new file mode 100644 index 0000000000..0757524e04 --- /dev/null +++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.scss @@ -0,0 +1,68 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.hf-audio-upload { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hf-audio-guidance { + color: #595959; + font-size: 12px; + line-height: 1.4; +} + +.hf-audio-upload-input { + width: 100%; +} + +.hf-audio-preview { + border: 1px solid #d9d9d9; + border-radius: 4px; + padding: 8px; +} + +.hf-audio-preview audio { + display: block; + width: 100%; +} + +.hf-audio-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 8px; +} + +.hf-audio-meta span { + overflow: hidden; + text-overflow: ellipsis; + 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 new file mode 100644 index 0000000000..bb7ebeb619 --- /dev/null +++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts @@ -0,0 +1,484 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { 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 { AppSettings } from "../../../common/app-setting"; +import { HuggingFaceAudioUploadComponent } from "./hugging-face-audio-upload.component"; + +const API = "api"; + +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 }; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HuggingFaceAudioUploadComponent, HttpClientTestingModule], + }).compileComponents(); + + vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API); + + const fixture = TestBed.createComponent(HuggingFaceAudioUploadComponent); + component = fixture.componentInstance; + formControl = new FormControl(""); + component.field = { formControl, key: "audioInput", model: {} } as unknown as FieldTypeConfig; + httpTestingController = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it("should be defined", () => { + expect(component).toBeDefined(); + }); + + // ── ngOnInit ── + + 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")); + }); + + 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"); + }); + + it("should not set fileName when formControl is empty", () => { + formControl.setValue(""); + component.ngOnInit(); + expect(component.fileName).toBe(""); + }); + + it("should not set fileName when formControl is whitespace", () => { + formControl.setValue(" "); + component.ngOnInit(); + expect(component.fileName).toBe(""); + }); + }); + + // ── previewSrc ── + + describe("previewSrc", () => { + it("should return empty string when formControl is empty and no local preview", () => { + expect(component.previewSrc).toBe(""); + }); + + it("should return empty for a stored server path (blob URL loaded asynchronously)", () => { + formControl.setValue("/uploads/clip.wav"); + expect(component.previewSrc).toBe(""); + }); + + it("should return data:audio value as-is", () => { + const dataUrl = "data:audio/wav;base64,abc123"; + formControl.setValue(dataUrl); + expect(component.previewSrc).toBe(dataUrl); + }); + + it("should return empty string for whitespace-only value", () => { + formControl.setValue(" "); + expect(component.previewSrc).toBe(""); + }); + }); + + // ── File upload ── + + 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 () => { + component.errorMessage = "previous error"; + const file = new File(["audio-data"], "clip.wav", { type: "audio/wav" }); + const uploadPromise = component.onFileSelected(makeFileEvent(file)); + + 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(""); + }); + + 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 () => { + 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; + }); + }); + + // ── clearAudio ── + + describe("clearAudio", () => { + it("should reset all state", () => { + component.fileName = "clip.wav"; + component.errorMessage = "some error"; + formControl.setValue("/tmp/clip.wav"); + + const input = document.createElement("input"); + 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(""); + }); + + 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", 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")); + }); + }); +}); 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 new file mode 100644 index 0000000000..fc2563a263 --- /dev/null +++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.ts @@ -0,0 +1,192 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, OnDestroy, OnInit } 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; +} + +@Component({ + selector: "texera-hugging-face-audio-upload", + templateUrl: "./hugging-face-audio-upload.component.html", + styleUrls: ["./hugging-face-audio-upload.component.scss"], + imports: [CommonModule, NzButtonModule], +}) +export class HuggingFaceAudioUploadComponent extends FieldType<FieldTypeConfig> implements OnInit, OnDestroy { + fileName = ""; + errorMessage = ""; + isUploading = false; + private localPreviewUrl = ""; + + ngOnInit(): void { + 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(); + } + + 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 ""; + } + + ngOnDestroy(): void { + this.revokePreviewUrl(); + } + + async onFileSelected(event: Event): Promise<void> { + if (this.isUploading) { + return; + } + this.errorMessage = ""; + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + + if (!file) { + return; + } + if (!file.type.startsWith("audio/")) { + this.errorMessage = "Choose an audio file."; + input.value = ""; + 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); + if (typeof this.key === "string" && this.model) { + this.model[this.key] = response.path; + } + 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; + } + } + + clearAudio(input: HTMLInputElement, clearError: boolean = true): void { + this.fileName = ""; + if (clearError) { + this.errorMessage = ""; + } + this.isUploading = false; + this.revokePreviewUrl(); + input.value = ""; + this.formControl.setValue(""); + if (typeof this.key === "string" && this.model) { + this.model[this.key] = ""; + } + this.formControl.markAsDirty(); + this.formControl.markAsTouched(); + this.formControl.updateValueAndValidity(); + } + + private loadServerAudioPreview(serverPath: string): void { + firstValueFrom( + this.http.get( + `${AppSettings.getApiEndpoint()}/huggingface/audio-preview?path=${encodeURIComponent(serverPath)}`, + { + responseType: "blob", + } + ) + ) + .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"; + } +}
