You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/07/20 03:31:28 UTC

(texera) branch main updated: feat(frontend): add HuggingFace media output rendering in result panel (#5675)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 57f522f1dd feat(frontend): add HuggingFace media output rendering in result panel (#5675)
57f522f1dd is described below

commit 57f522f1dd2d3c32013765f52082db9ae5c20d28
Author: Julie Cao <11...@users.noreply.github.com>
AuthorDate: Sun Jul 19 20:18:23 2026 -0700

    feat(frontend): add HuggingFace media output rendering in result panel (#5675)
    
    ### What changes were proposed in this PR?
    
    Render image, audio, and video outputs from HuggingFace tasks inline in
    the workflow result panel instead of displaying raw data URLs as text.
    
    **New file — `media-type.util.ts`:**
    
    - `isImageUrl` — detects `data:image/` data URLs and common image
    extensions (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`)
    - `isAudioUrl` — detects `data:audio/` data URLs and common audio
    extensions (`.mp3`, `.wav`, `.ogg`, `.m4a`, `.flac`)
    - `isVideoUrl` — detects `data:video/` data URLs, common video
    extensions (`.mp4`, `.webm`, `.ogg`), and the `fal.media` CDN host used
    by fal.ai text-to-video outputs
    
    **Changes to `result-table-frame.component.{ts,html}`:**
    
    - Add `isImageCell` / `isAudioCell` / `isVideoCell` methods that
    delegate to the detection helpers
    - Show a media type icon (play/image/audio) in the table cell; the
    actual `<img>`, `<audio controls>`, `<video controls>` elements render
    inline in the row detail modal
    - Precompute each cell's media type once per row when result data
    arrives (`cellMediaTypes[rowIndex][columnIndex]`), instead of re-running
    the detection helpers and getCell on every change-detection cycle
    
    **Changes to `result-panel-modal.component.{ts,html}`:**
    
    - Build `rowEntries` with per-field media metadata on modal open
    - Render media fields inline in the row detail view with a
    copy-to-clipboard fallback for the raw URL
    - Fetch remote media through the backend's /huggingface/media-proxy
    endpoint (with JWT auth) rather than loading cross-origin URLs directly
    in the browser
    - On a proxy failure (e.g. the backend's SSRF allowlist rejects the
    URL), fall back to the plain-text view instead of loading the raw remote
    URL directly
    - Revoke allocated blob URLs on `ngOnDestroy` to avoid leaking object
    URLs for the life of the page
    
    **Changes to `result-panel-model.component.scss`:**
    
    - Add modal-toolbar and row-detail styles for media display
    
    **Changes to `operator-property-edit-frame.component.ts`:⋆
    
    - Show validation messages (`validation.show = true`) for the
    image/audio upload and prompt-column fields on HuggingFace operators
    
    **New file — `media-type.util.spec.ts`:**
    
    - 22 unit tests covering all three helpers across data URL prefixes,
    file extensions (including case-insensitivity and query strings), the
    `fal.media` CDN URL and empty/plain-string inputs
    
    ### Any related issues, documentation, discussions?
    
    - Tracking issue: #5674
    - Closes: #5674
    - Stacked on: #5568
    - Parent issue: #5041
    
    ### How was this PR tested?
    - `media-type.util.spec.ts` (22 cases): data URL prefixes, file
    extensions, case-insensitivity, query strings, the fal.media CDN URL,
    cross-type rejection, and non-string input handling
    - `result-table-frame.component.spec.ts`: media cell icon rendering —
    Play Video/Play Audio/View Image indicators and plain-text fallback
    - `result-panel-modal.component.spec.ts`: row-entry construction from
    data URLs vs. remote URLs, blob-fetch success/failure, the SSRF-safe
    proxy-failure fallback, clipboard copy, and inline video/image `src`
    binding
    - Run with `ng test`
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Co-authored with Claude Sonnet 4.6 in compliance with ASF guidelines
    
    ---------
    
    Signed-off-by: Elliot Lin <36...@users.noreply.github.com>
    Signed-off-by: Julie Cao <11...@users.noreply.github.com>
    Co-authored-by: Elliot <36...@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.6 (1M context) <no...@anthropic.com>
    Co-authored-by: Elliot Lin <36...@users.noreply.github.com>
    Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
    Co-authored-by: Anish Shivamurthy <an...@uci.edu>
    Co-authored-by: Anish Shivamurthy <an...@gmail.com>
    Co-authored-by: PG1204 <vi...@gmail.com>
    Co-authored-by: Matthew B. <mg...@uci.edu>
---
 .../src/app/common/util/media-type.util.spec.ts    | 148 +++++++++++++
 .../util/media-type.util.ts}                       |  22 +-
 .../hugging-face-audio-upload.component.spec.ts    | 186 +++++++++++++++-
 .../hugging-face/hugging-face.component.html       |   7 +-
 .../hugging-face/hugging-face.component.spec.ts    | 158 +++++++++++++-
 .../hugging-face/hugging-face.component.ts         |  27 +--
 .../operator-property-edit-frame.component.spec.ts | 223 ++++++++++++++++++++
 .../operator-property-edit-frame.component.ts      |  12 ++
 .../result-panel/result-panel-modal.component.html |  55 ++++-
 .../result-panel-modal.component.spec.ts           | 233 ++++++++++++++++++++-
 .../result-panel/result-panel-modal.component.ts   | 103 ++++++++-
 .../result-panel/result-panel-model.component.scss |  51 +++++
 .../result-table-frame.component.html              |  33 ++-
 .../result-table-frame.component.spec.ts           |  69 ++++++
 .../result-table-frame.component.ts                |  44 +++-
 15 files changed, 1320 insertions(+), 51 deletions(-)

diff --git a/frontend/src/app/common/util/media-type.util.spec.ts b/frontend/src/app/common/util/media-type.util.spec.ts
new file mode 100644
index 0000000000..457f05a39d
--- /dev/null
+++ b/frontend/src/app/common/util/media-type.util.spec.ts
@@ -0,0 +1,148 @@
+/**
+ * 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 { isAudioUrl, isImageUrl, isVideoUrl } from "./media-type.util";
+
+describe("isImageUrl", () => {
+  it("should return true for data:image/ data URLs", () => {
+    expect(isImageUrl("data:image/png;base64,abc123")).toBe(true);
+    expect(isImageUrl("data:image/jpeg;base64,abc123")).toBe(true);
+    expect(isImageUrl("data:image/webp;base64,abc123")).toBe(true);
+  });
+
+  it("should return true for common image file extensions", () => {
+    expect(isImageUrl("https://example.com/photo.png")).toBe(true);
+    expect(isImageUrl("https://example.com/photo.jpg")).toBe(true);
+    expect(isImageUrl("https://example.com/photo.jpeg")).toBe(true);
+    expect(isImageUrl("https://example.com/photo.gif")).toBe(true);
+    expect(isImageUrl("https://example.com/photo.webp")).toBe(true);
+  });
+
+  it("should be case-insensitive for extensions", () => {
+    expect(isImageUrl("https://example.com/photo.PNG")).toBe(true);
+    expect(isImageUrl("https://example.com/photo.JPG")).toBe(true);
+  });
+
+  it("should return true for URLs with query strings", () => {
+    expect(isImageUrl("https://example.com/photo.png?v=1")).toBe(true);
+  });
+
+  it("should return false for audio and video URLs", () => {
+    expect(isImageUrl("data:audio/mp3;base64,abc")).toBe(false);
+    expect(isImageUrl("data:video/mp4;base64,abc")).toBe(false);
+    expect(isImageUrl("https://example.com/clip.mp4")).toBe(false);
+  });
+
+  it("should return false for plain text strings", () => {
+    expect(isImageUrl("hello world")).toBe(false);
+    expect(isImageUrl("")).toBe(false);
+  });
+});
+
+describe("isAudioUrl", () => {
+  it("should return true for data:audio/ data URLs", () => {
+    expect(isAudioUrl("data:audio/mp3;base64,abc123")).toBe(true);
+    expect(isAudioUrl("data:audio/wav;base64,abc123")).toBe(true);
+  });
+
+  it("should return true for common audio file extensions", () => {
+    expect(isAudioUrl("https://example.com/clip.mp3")).toBe(true);
+    expect(isAudioUrl("https://example.com/clip.wav")).toBe(true);
+    expect(isAudioUrl("https://example.com/clip.ogg")).toBe(true);
+    expect(isAudioUrl("https://example.com/clip.m4a")).toBe(true);
+    expect(isAudioUrl("https://example.com/clip.flac")).toBe(true);
+  });
+
+  it("should be case-insensitive for extensions", () => {
+    expect(isAudioUrl("https://example.com/clip.MP3")).toBe(true);
+    expect(isAudioUrl("https://example.com/clip.WAV")).toBe(true);
+  });
+
+  it("should return true for URLs with query strings", () => {
+    expect(isAudioUrl("https://example.com/clip.mp3?token=xyz")).toBe(true);
+  });
+
+  it("should return false for image and video URLs", () => {
+    expect(isAudioUrl("data:image/png;base64,abc")).toBe(false);
+    expect(isAudioUrl("data:video/mp4;base64,abc")).toBe(false);
+    expect(isAudioUrl("https://example.com/photo.png")).toBe(false);
+  });
+
+  it("should return false for plain text strings", () => {
+    expect(isAudioUrl("hello world")).toBe(false);
+    expect(isAudioUrl("")).toBe(false);
+  });
+});
+
+describe("isVideoUrl", () => {
+  it("should return true for data:video/ data URLs", () => {
+    expect(isVideoUrl("data:video/mp4;base64,abc123")).toBe(true);
+    expect(isVideoUrl("data:video/webm;base64,abc123")).toBe(true);
+  });
+
+  it("should return true for common video file extensions", () => {
+    expect(isVideoUrl("https://example.com/clip.mp4")).toBe(true);
+    expect(isVideoUrl("https://example.com/clip.webm")).toBe(true);
+    expect(isVideoUrl("https://example.com/clip.ogv")).toBe(true);
+  });
+
+  it("should return true for fal.media CDN URLs", () => {
+    expect(isVideoUrl("https://v3b.fal.media/files/abc123/output.mp4")).toBe(true);
+  });
+
+  it("should be case-insensitive for extensions", () => {
+    expect(isVideoUrl("https://example.com/clip.MP4")).toBe(true);
+    expect(isVideoUrl("https://example.com/clip.WEBM")).toBe(true);
+  });
+
+  it("should return true for URLs with query strings", () => {
+    expect(isVideoUrl("https://example.com/clip.mp4?t=5")).toBe(true);
+  });
+
+  it("should return false for image and audio URLs", () => {
+    expect(isVideoUrl("data:image/png;base64,abc")).toBe(false);
+    expect(isVideoUrl("data:audio/mp3;base64,abc")).toBe(false);
+    expect(isVideoUrl("https://example.com/photo.jpg")).toBe(false);
+  });
+
+  it("should return false for plain text strings", () => {
+    expect(isVideoUrl("hello world")).toBe(false);
+    expect(isVideoUrl("")).toBe(false);
+  });
+
+  it("should return false for non-string types", () => {
+    expect(isVideoUrl(null as unknown as string)).toBe(false);
+    expect(isVideoUrl(undefined as unknown as string)).toBe(false);
+    expect(isVideoUrl(42 as unknown as string)).toBe(false);
+  });
+});
+
+describe("non-string type guard (shared)", () => {
+  it("isAudioUrl should return false for non-string types", () => {
+    expect(isAudioUrl(null as unknown as string)).toBe(false);
+    expect(isAudioUrl(undefined as unknown as string)).toBe(false);
+    expect(isAudioUrl(true as unknown as string)).toBe(false);
+  });
+
+  it("isImageUrl should return false for non-string types", () => {
+    expect(isImageUrl(null as unknown as string)).toBe(false);
+    expect(isImageUrl(undefined as unknown as string)).toBe(false);
+    expect(isImageUrl([] as unknown as string)).toBe(false);
+  });
+});
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss b/frontend/src/app/common/util/media-type.util.ts
similarity index 53%
copy from frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
copy to frontend/src/app/common/util/media-type.util.ts
index eb5ff4dece..26872f0bc1 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
+++ b/frontend/src/app/common/util/media-type.util.ts
@@ -17,9 +17,21 @@
  * under the License.
  */
 
-.modal-body.content-body {
-  overflow-x: auto;
-  overflow-y: auto;
-  height: 100%;
-  width: 100%;
+export function isVideoUrl(value: string): boolean {
+  if (typeof value !== "string") return false;
+  return (
+    /^https?:\/\/.+\.(mp4|webm|ogv)(\?.*)?$/i.test(value) ||
+    value.startsWith("data:video/") ||
+    /^https:\/\/[a-z0-9-]+\.fal\.media\/files\//i.test(value)
+  );
+}
+
+export function isAudioUrl(value: string): boolean {
+  if (typeof value !== "string") return false;
+  return /^https?:\/\/.+\.(mp3|wav|ogg|m4a|flac)(\?.*)?$/i.test(value) || value.startsWith("data:audio/");
+}
+
+export function isImageUrl(value: string): boolean {
+  if (typeof value !== "string") return false;
+  return /^https?:\/\/.+\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i.test(value) || value.startsWith("data:image/");
 }
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 f92300731a..d2ec9f681c 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
@@ -17,10 +17,11 @@
  * under the License.
  */
 
-import { TestBed } from "@angular/core/testing";
+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";
 
@@ -121,6 +122,21 @@ describe("HuggingFaceAudioUploadComponent", () => {
       formControl.setValue("   ");
       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");
+
+      httpTestingController
+        .expectOne(r => r.url.includes("/huggingface/upload-audio"))
+        .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+      await uploadPromise;
+    });
   });
 
   // ── File upload ──
@@ -239,6 +255,43 @@ describe("HuggingFaceAudioUploadComponent", () => {
       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.clearAudio(input);
+
+      httpTestingController
+        .expectOne(r => r.url.includes("/huggingface/upload-audio"))
+        .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+      await uploadPromise;
+
+      expect(formControl.value).toBe("");
+      expect(component.fileName).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);
+
+      httpTestingController
+        .expectOne(r => r.url.includes("/huggingface/upload-audio"))
+        .error(new ProgressEvent("error"));
+      await uploadPromise;
+
+      expect(component.errorMessage).toBe("");
+    });
   });
 
   // ── clearAudio ──
@@ -433,6 +486,22 @@ describe("HuggingFaceAudioUploadComponent", () => {
       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);
@@ -574,4 +643,119 @@ describe("HuggingFaceAudioUploadComponent", () => {
       expect(component.fileName).toBe("second.wav");
     });
   });
+
+  // ── 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("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("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;
+      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"));
+      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"));
+      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"));
+      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();
+    });
+
+    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);
+      expect(clearSpy).toHaveBeenCalled();
+    });
+  });
 });
diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
index b44624721a..777111cc3e 100644
--- a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
@@ -69,13 +69,8 @@
       (ngModelChange)="onSearchInput($event)" />
   </nz-input-group>
   <ng-template #searchClearTpl>
-    <nz-spin
-      *ngIf="searchLoading"
-      nzSimple
-      nzSize="small"
-      style="display: inline-block; margin-right: 4px"></nz-spin>
     <i
-      *ngIf="searchText && !searchLoading"
+      *ngIf="searchText"
       nz-icon
       nzType="close-circle"
       nzTheme="fill"
diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
index b85e71653e..51f7e8999c 100644
--- a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
+import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/testing";
 import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
 import { FormControl, FormGroup } from "@angular/forms";
 import { FieldTypeConfig } from "@ngx-formly/core";
@@ -660,6 +660,162 @@ describe("HuggingFaceComponent (TestBed)", () => {
       initComponent();
       expect(() => component.ngOnDestroy()).not.toThrow();
     });
+
+    it("should clear taskPollInterval on destroy", fakeAsync(() => {
+      // Create a second fixture so the first one is still fetching tasks when this one inits
+      invalidateHuggingFaceModelCache();
+      const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+      const component2 = fixture2.componentInstance;
+      const { field: field2 } = buildFieldWithFormGroup("text-generation");
+      component2.field = field2;
+      fixture2.detectChanges(); // triggers ngOnInit → loadTasks() (sets tasksFetchSubscription)
+      http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("<svg></svg>"));
+
+      // Now init our main component — tasks in flight, so it enters poll path
+      const { field } = buildFieldWithFormGroup("text-generation");
+      component.field = field;
+      fixture.detectChanges();
+      flushIconRequests();
+
+      // comp enters poll path for tasks — taskPollInterval is set
+      expect((component as any).taskPollInterval).not.toBeNull();
+
+      // Destroy while poll is still running
+      expect(() => component.ngOnDestroy()).not.toThrow();
+
+      // Clean up fixture2
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      http.match(req => req.url.startsWith(`${API}/huggingface/models`)).forEach(req => req.flush([]));
+      fixture2.destroy();
+      discardPeriodicTasks();
+    }));
+  });
+
+  // ── Cached error states ──
+
+  describe("cached error states", () => {
+    it("should show cached tasks error for a second component without re-fetching", () => {
+      const { field: field1 } = buildFieldWithFormGroup();
+      component.field = field1;
+      fixture.detectChanges();
+      flushIconRequests();
+
+      // comp1: tasks error, models succeed → models are cached
+      http.expectOne(`${API}/huggingface/tasks`).error(new ProgressEvent("error"));
+      http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush([]);
+
+      expect(component.tasksError).toBeTruthy();
+
+      const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+      const component2 = fixture2.componentInstance;
+      const { field: field2 } = buildFieldWithFormGroup();
+      component2.field = field2;
+      fixture2.detectChanges();
+      flushIconRequests();
+
+      // Tasks error cached (no new tasks request); models cached (no new model request)
+
+      expect(component2.tasksError).toBeTruthy();
+      expect(component2.taskOptions).toEqual(STATIC_TASK_OPTIONS);
+      expect(component2.tasksLoading).toBe(false);
+
+      fixture2.destroy();
+    });
+
+    it("should show cached model error for a second component without re-fetching models", () => {
+      const { field: field1 } = buildFieldWithFormGroup("text-generation");
+      component.field = field1;
+      fixture.detectChanges();
+      flushIconRequests();
+
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).error(new ProgressEvent("error"));
+
+      expect(component.errorMessage).toBeTruthy();
+
+      const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+      const component2 = fixture2.componentInstance;
+      const { field: field2 } = buildFieldWithFormGroup("text-generation");
+      component2.field = field2;
+      fixture2.detectChanges();
+      flushIconRequests();
+
+      // Tasks cached; model error cached — no new HTTP requests at all
+      expect(component2.errorMessage).toBeTruthy();
+      expect(component2.loading).toBe(false);
+      expect(component2.pagedModels.length).toBe(0);
+
+      fixture2.destroy();
+    });
+  });
+
+  // ── Polling ──
+
+  describe("polling", () => {
+    it("should enter model poll path (loading=true) when another instance is already fetching models", fakeAsync(() => {
+      // comp1 fetches tasks first, but leaves models in flight
+      const { field: field1 } = buildFieldWithFormGroup("text-generation");
+      component.field = field1;
+      fixture.detectChanges();
+      flushIconRequests();
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      // models still in flight for comp1
+
+      // comp2: tasks cached, but models are in-flight → enters model poll path
+      const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+      const component2 = fixture2.componentInstance;
+      const { field: field2 } = buildFieldWithFormGroup("text-generation");
+      component2.field = field2;
+      fixture2.detectChanges();
+      http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("<svg></svg>"));
+
+      expect(component2.loading).toBe(true); // poll path entered
+
+      // Clean up: flush comp1's pending model request, discard comp2's poll
+      http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(3));
+      fixture2.destroy();
+      discardPeriodicTasks();
+    }));
+
+    it("should enter task poll path (tasksLoading=true) when another instance is already fetching tasks", fakeAsync(() => {
+      // comp1 starts fetching tasks — leave in flight
+      const { field: field1 } = buildFieldWithFormGroup("text-generation");
+      component.field = field1;
+      fixture.detectChanges();
+      flushIconRequests();
+      // tasks and models both in flight for comp1
+
+      // comp2: tasksFetchSubscription not null → enters task poll path
+      const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+      const component2 = fixture2.componentInstance;
+      const { field: field2 } = buildFieldWithFormGroup("text-generation");
+      component2.field = field2;
+      fixture2.detectChanges();
+      http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("<svg></svg>"));
+
+      expect(component2.tasksLoading).toBe(true); // poll path entered
+
+      // Clean up: flush comp1's pending requests, discard comp2's polls
+      http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+      http.match(req => req.url.startsWith(`${API}/huggingface/models`)).forEach(req => req.flush([]));
+      fixture2.destroy();
+      discardPeriodicTasks();
+    }));
+
+    it("should clear error and re-fetch models on retryLoad", () => {
+      initComponent();
+
+      component.onTaskSelected("image-classification");
+      http.expectOne(`${API}/huggingface/models?task=image-classification`).error(new ProgressEvent("error"));
+
+      expect(component.errorMessage).toBeTruthy();
+
+      component.retryLoad();
+      http.expectOne(`${API}/huggingface/models?task=image-classification`).flush(buildModels(4, "img"));
+
+      expect(component.errorMessage).toBeNull();
+      expect(component.pagedModels.length).toBe(4);
+    });
   });
 
   // ── Pagination edge cases ──
diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
index f84d4f940f..f634c66a2a 100644
--- a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
@@ -252,15 +252,13 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
     tasksFetchSubscription = this.http
       .get<HuggingFaceTaskOption[]>(`${AppSettings.getApiEndpoint()}/huggingface/tasks`)
       .pipe(
-        takeUntil(this.destroy$),
         finalize(() => {
-          // If takeUntil fires before next/error, reset the module-level guard
-          // so the next component instance can start a fresh fetch.
           if (cachedTaskOptions === null && tasksFetchError === null) {
             tasksFetchSubscription = null;
           }
         })
       )
+      // eslint-disable-next-line rxjs-angular/prefer-takeuntil
       .subscribe({
         next: tasks => {
           tasksFetchSubscription = null;
@@ -296,8 +294,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
     this.restoreTaskState(tag);
     this.searchText = "";
     this.filteredModels = null;
-    // Cancel any in-flight server search for the previous task
-    this.searchSubject$.next("");
     this.loadAllModels();
   }
 
@@ -379,14 +375,8 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
         { observe: "response" }
       )
       .pipe(
-        takeUntil(this.destroy$),
-        finalize(() => {
-          // If takeUntil cancels before next/error fires, clear the in-flight
-          // guard so a later instance re-fetches instead of polling forever.
-          if (!allModelsByTag.has(tag) && !errorByTag.has(tag)) {
-            inFlightByTag.delete(tag);
-          }
-        })
+        finalize(() => inFlightByTag.delete(tag)),
+        takeUntil(this.destroy$)
       )
       .subscribe({
         next: resp => {
@@ -395,7 +385,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
             truncatedByTag.add(tag);
           }
           allModelsByTag.set(tag, models);
-          inFlightByTag.delete(tag);
           this.loading = false;
           this.truncated = truncatedByTag.has(tag);
           this.allModels = models;
@@ -405,7 +394,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
           console.error(`Failed to load HuggingFace models for task '${tag}':`, err);
           const msg = "Failed to load models. Click retry to try again.";
           errorByTag.set(tag, msg);
-          inFlightByTag.delete(tag);
           this.loading = false;
           this.errorMessage = msg;
           this.cdr.detectChanges();
@@ -459,11 +447,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
       .pipe(
         debounceTime(300),
         switchMap(query => {
-          if (!query.trim()) {
-            this.searchLoading = false;
-            this.cdr.detectChanges();
-            return of(null);
-          }
           const tag = this.selectedTaskTag || "text-generation";
           this.searchLoading = true;
           this.cdr.detectChanges();
@@ -497,8 +480,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
     if (!query.trim()) {
       this.filteredModels = null;
       this.searchLoading = false;
-      // Cancel any in-flight server search via switchMap
-      this.searchSubject$.next("");
       this.goToPage(0);
       return;
     }
@@ -517,8 +498,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
     this.searchText = "";
     this.filteredModels = null;
     this.searchLoading = false;
-    // Cancel any in-flight server search via switchMap
-    this.searchSubject$.next("");
     this.goToPage(0);
   }
 
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
index 267b686b10..e23aa7ae70 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
@@ -1861,4 +1861,227 @@ describe("OperatorPropertyEditFrameComponent", () => {
       expect(component.formData.marker).toBe("allowed");
     });
   });
+  describe("real template rendering", () => {
+    let realFixture: ComponentFixture<OperatorPropertyEditFrameComponent>;
+    let realComponent: OperatorPropertyEditFrameComponent;
+
+    beforeEach(async () => {
+      TestBed.resetTestingModule();
+      await TestBed.configureTestingModule({
+        providers: [
+          WorkflowActionService,
+          { provide: OperatorMetadataService, useClass: StubOperatorMetadataService },
+          { provide: ComputingUnitStatusService, useClass: MockComputingUnitStatusService },
+          DatePipe,
+          ...commonTestProviders,
+        ],
+        imports: [
+          OperatorPropertyEditFrameComponent,
+          BrowserAnimationsModule,
+          FormsModule,
+          FormlyModule.forRoot(TEXERA_FORMLY_CONFIG),
+          FormlyNgZorroAntdModule,
+          ReactiveFormsModule,
+          HttpClientTestingModule,
+        ],
+      }).compileComponents();
+
+      realFixture = TestBed.createComponent(OperatorPropertyEditFrameComponent);
+      realComponent = realFixture.componentInstance;
+    });
+
+    it("should render the title section when editingTitle is false and formTitle is set", () => {
+      realComponent.editingTitle = false;
+      realComponent.formTitle = "My Operator";
+      realFixture.detectChanges();
+      const titleEl = realFixture.debugElement.query(By.css("#formly-title"));
+      expect(titleEl).toBeTruthy();
+      const h3 = realFixture.debugElement.query(By.css("h3.texera-workspace-property-editor-title"));
+      expect(h3).toBeTruthy();
+      expect((h3.nativeElement as HTMLElement).textContent?.trim()).toBe("My Operator");
+    });
+
+    it("should not render the h3 title when formTitle is undefined", () => {
+      realComponent.editingTitle = false;
+      realComponent.formTitle = undefined;
+      realFixture.detectChanges();
+      expect(realFixture.debugElement.query(By.css("h3.texera-workspace-property-editor-title"))).toBeNull();
+    });
+
+    it("should disable the edit button when interactive is false", () => {
+      realComponent.editingTitle = false;
+      realComponent.interactive = false;
+      realFixture.detectChanges();
+      const btn = realFixture.debugElement.query(By.css("#formly-title button")).nativeElement as HTMLButtonElement;
+      expect(btn.disabled).toBe(true);
+    });
+
+    it("should enable the edit button when interactive is true", () => {
+      realComponent.editingTitle = false;
+      realComponent.interactive = true;
+      realFixture.detectChanges();
+      const btn = realFixture.debugElement.query(By.css("#formly-title button")).nativeElement as HTMLButtonElement;
+      expect(btn.disabled).toBe(false);
+    });
+
+    it("should hide the title section when editingTitle is true", () => {
+      realComponent.editingTitle = true;
+      realFixture.detectChanges();
+      expect(realFixture.debugElement.query(By.css("#formly-title"))).toBeNull();
+    });
+
+    it("should hide the customName div when editingTitle is false", () => {
+      realComponent.editingTitle = false;
+      realFixture.detectChanges();
+      const customName = realFixture.debugElement.query(By.css("#customName"));
+      expect(customName).toBeTruthy();
+      expect((customName.nativeElement as HTMLElement).hidden).toBe(true);
+    });
+
+    it("should show the customName div when editingTitle is true", () => {
+      realComponent.editingTitle = true;
+      realFixture.detectChanges();
+      const customName = realFixture.debugElement.query(By.css("#customName"));
+      expect(customName).toBeTruthy();
+      expect((customName.nativeElement as HTMLElement).hidden).toBe(false);
+    });
+
+    it("should show the PythonLambdaFunction icon when currentOperatorId includes PythonLambdaFunction", () => {
+      realComponent.editingTitle = false;
+      realComponent.currentOperatorId = "PythonLambdaFunction-abc";
+      realFixture.detectChanges();
+      expect(realFixture.debugElement.query(By.css(".question-circle-button"))).toBeTruthy();
+    });
+
+    it("should not show the PythonLambdaFunction icon for other operator types", () => {
+      realComponent.editingTitle = false;
+      realComponent.currentOperatorId = "ScanSource-abc";
+      realFixture.detectChanges();
+      expect(realFixture.debugElement.query(By.css(".question-circle-button"))).toBeNull();
+    });
+
+    it("should not render the form section when formlyFields is undefined", () => {
+      realFixture.detectChanges();
+      expect(realFixture.debugElement.query(By.css(".property-editor-form"))).toBeNull();
+    });
+
+    it("should render the operator version span", () => {
+      realComponent.operatorVersion = "v1.2.3";
+      realFixture.detectChanges();
+      const versionEl = realFixture.debugElement.query(By.css(".operator-version span"));
+      expect(versionEl).toBeTruthy();
+      expect((versionEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Operator Version: v1.2.3");
+    });
+
+    // ── HF task preview template nodes ──
+    // formlyFields=[] and formlyFormGroup=new FormGroup({}) satisfy the outer
+    // *ngIf without triggering Formly rendering; the getter is mocked directly.
+
+    function setupPreview(preview: object): void {
+      vi.spyOn(realComponent, "huggingFaceTaskPreview", "get").mockReturnValue(preview as any);
+      realComponent.formlyFields = [];
+      realComponent.formlyFormGroup = new FormGroup({});
+      realComponent.formData = {};
+      realFixture.detectChanges();
+    }
+
+    it("should not render the HF preview card when huggingFaceTaskPreview is null", () => {
+      vi.spyOn(realComponent, "huggingFaceTaskPreview", "get").mockReturnValue(null);
+      realComponent.formlyFields = [];
+      realComponent.formlyFormGroup = new FormGroup({});
+      realComponent.formData = {};
+      realFixture.detectChanges();
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview"))).toBeNull();
+    });
+
+    it("should render a video element for kind='video'", () => {
+      setupPreview({ kind: "video", title: "Video preview", assetSrc: "assets/sample.mp4" });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview"))).toBeTruthy();
+      expect(realFixture.debugElement.query(By.css("video.hf-task-preview-media"))).toBeTruthy();
+    });
+
+    it("should bind [src] and [muted] on the video element", () => {
+      setupPreview({ kind: "video", title: "Video preview", assetSrc: "assets/sample.mp4" });
+      const video = realFixture.debugElement.query(By.css("video")).nativeElement as HTMLVideoElement;
+      expect(video.muted).toBe(true);
+    });
+
+    it("should render an img element for kind='image'", () => {
+      setupPreview({ kind: "image", title: "Image preview", assetSrc: "assets/sample.png" });
+      expect(realFixture.debugElement.query(By.css("img.hf-task-preview-media"))).toBeTruthy();
+    });
+
+    it("should render an audio element for kind='audio'", () => {
+      setupPreview({ kind: "audio", title: "Audio preview", assetSrc: "assets/sample.mp3" });
+      expect(realFixture.debugElement.query(By.css("audio.hf-task-preview-audio"))).toBeTruthy();
+    });
+
+    it("should render the text surface for kind='text'", () => {
+      setupPreview({ kind: "text", title: "Text preview", body: "Some body" });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-text-surface"))).toBeTruthy();
+      const titleEl = realFixture.debugElement.query(By.css(".hf-task-preview-text-title"));
+      expect((titleEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Text preview");
+      const bodyEl = realFixture.debugElement.query(By.css(".hf-task-preview-text-body"));
+      expect((bodyEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Some body");
+    });
+
+    it("should render outputBody in the text surface when present", () => {
+      setupPreview({ kind: "text", title: "T", body: "B", outputBody: "Output" });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-text-output"))).toBeTruthy();
+    });
+
+    it("should not render outputBody in the text surface when absent", () => {
+      setupPreview({ kind: "text", title: "T", body: "B" });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-text-output"))).toBeNull();
+    });
+
+    it("should render the flow section when inputLabel and outputLabel are set", () => {
+      setupPreview({ kind: "image", title: "T", inputLabel: "Input", outputLabel: "Output" });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-flow"))).toBeTruthy();
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-arrow"))).toBeTruthy();
+      const chips = realFixture.debugElement.queryAll(By.css(".hf-task-preview-chip"));
+      expect(chips.length).toBeGreaterThanOrEqual(2);
+    });
+
+    it("should not render the flow section when inputLabel and outputLabel are absent", () => {
+      setupPreview({ kind: "image", title: "T" });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-flow"))).toBeNull();
+    });
+
+    it("should render the description for non-text kinds with body", () => {
+      setupPreview({ kind: "image", title: "T", body: "Some description" });
+      const desc = realFixture.debugElement.query(By.css(".hf-task-preview-description"));
+      expect(desc).toBeTruthy();
+      expect((desc.nativeElement as HTMLElement).textContent?.trim()).toBe("Some description");
+    });
+
+    it("should not render the description for kind='text'", () => {
+      setupPreview({ kind: "text", title: "T", body: "B" });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-description"))).toBeNull();
+    });
+
+    it("should render outputBody in meta for non-text kinds", () => {
+      setupPreview({ kind: "image", title: "T", outputBody: "Result" });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-output"))).toBeTruthy();
+    });
+
+    it("should not render outputBody in meta for kind='text'", () => {
+      setupPreview({ kind: "text", title: "T", body: "B", outputBody: "Result" });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-output"))).toBeNull();
+    });
+
+    it("should render pills when pills array is non-empty", () => {
+      setupPreview({ kind: "text", title: "T", pills: ["NLP", "Classification"] });
+      const pillsContainer = realFixture.debugElement.query(By.css(".hf-task-preview-pills"));
+      expect(pillsContainer).toBeTruthy();
+      const pills = realFixture.debugElement.queryAll(By.css(".hf-task-preview-pill"));
+      expect(pills.length).toBe(2);
+      expect((pills[0].nativeElement as HTMLElement).textContent?.trim()).toBe("NLP");
+    });
+
+    it("should not render pills when pills array is empty", () => {
+      setupPreview({ kind: "text", title: "T", pills: [] });
+      expect(realFixture.debugElement.query(By.css(".hf-task-preview-pills"))).toBeNull();
+    });
+  });
 });
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
index 40c74a60c2..128f674645 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
@@ -917,6 +917,10 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
               message: () => "Upload an image or select an Input Image Column for this task.",
             },
           };
+          mappedField.validation = {
+            ...mappedField.validation,
+            show: true,
+          };
         }
         if (hfKey === "audioInput") {
           mappedField.type = "huggingface-audio-upload";
@@ -945,6 +949,10 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
               message: () => "Upload audio or select an Input Audio Column for this task.",
             },
           };
+          mappedField.validation = {
+            ...mappedField.validation,
+            show: true,
+          };
         }
         if (hfKey === "inputImageColumn") {
           mappedField.expressions = {
@@ -986,6 +994,10 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
               message: () => "Select a prompt column for this task.",
             },
           };
+          mappedField.validation = {
+            ...mappedField.validation,
+            show: true,
+          };
         }
         if (["systemPrompt", "maxNewTokens", "temperature"].includes(hfKey)) {
           mappedField.expressions = {
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html
index 5f8fbe4674..71e4533591 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html
+++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html
@@ -18,7 +18,56 @@
 -->
 
 <div class="modal-body content-body">
-  <ngx-json-viewer
-    [expanded]="false"
-    [json]="currentDisplayRowData"></ngx-json-viewer>
+  <div class="modal-toolbar">
+    <button
+      nz-button
+      nzType="default"
+      type="button"
+      (click)="copyText(prettyRowJson)">
+      Copy Row JSON
+    </button>
+  </div>
+
+  <div class="row-detail-list">
+    <div
+      *ngFor="let entry of rowEntries; trackBy: trackByEntryKey"
+      class="row-detail-item">
+      <div class="row-detail-header">
+        <div class="row-detail-key">{{ entry.key }}</div>
+        <button
+          nz-button
+          nzType="link"
+          type="button"
+          (click)="copyText(entry.value)">
+          Copy
+        </button>
+      </div>
+
+      <video
+        *ngIf="entry.isVideo; else checkAudio"
+        controls
+        [src]="entry.mediaSrc"
+        style="max-width: 100%; max-height: 480px; display: block; border: 1px solid #eee; border-radius: 4px"></video>
+
+      <ng-template #checkAudio>
+        <audio
+          *ngIf="entry.isAudio; else checkImage"
+          controls
+          [src]="entry.mediaSrc"
+          style="width: 100%; max-width: 420px; display: block"></audio>
+      </ng-template>
+
+      <ng-template #checkImage>
+        <img
+          *ngIf="entry.isImage; else textValue"
+          [src]="entry.mediaSrc"
+          alt="image result"
+          style="max-width: 100%; max-height: 480px; display: block; border: 1px solid #eee; border-radius: 4px" />
+
+        <ng-template #textValue>
+          <pre class="row-detail-pre row-detail-value">{{ entry.value }}</pre>
+        </ng-template>
+      </ng-template>
+    </div>
+  </div>
 </div>
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts
index 8d7e1cc8f9..f82bd871c3 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts
+++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts
@@ -18,15 +18,20 @@
  */
 
 import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
 import { RowModalComponent } from "./result-panel-modal.component";
 import { PanelResizeService } from "../../service/workflow-result/panel-resize/panel-resize.service";
 import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service";
 import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
 import { of } from "rxjs";
+import { AppSettings } from "../../../common/app-setting";
+import { NotificationService } from "../../../common/service/notification/notification.service";
 
 describe("RowModalComponent", () => {
   let component: RowModalComponent;
   let fixture: ComponentFixture<RowModalComponent>;
+  let httpMock: HttpTestingController;
 
   const mockTupleResult = { tuple: { id: "123", value: "test_data" } };
   const workflowResultServiceSpy = {
@@ -41,7 +46,7 @@ describe("RowModalComponent", () => {
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
-      imports: [RowModalComponent],
+      imports: [RowModalComponent, HttpClientTestingModule],
       providers: [
         { provide: NZ_MODAL_DATA, useValue: { operatorId: "op-1", rowIndex: 3 } },
         { provide: NzModalRef, useValue: { getConfig: () => ({}), close: vi.fn() } },
@@ -49,6 +54,12 @@ describe("RowModalComponent", () => {
         { provide: PanelResizeService, useValue: resizeServiceSpy },
       ],
     }).compileComponents();
+
+    httpMock = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpMock.verify();
   });
 
   beforeEach(() => {
@@ -65,4 +76,224 @@ describe("RowModalComponent", () => {
     component.ngOnChanges();
     expect(component.currentDisplayRowData).toEqual(mockTupleResult.tuple);
   });
+
+  it("should use data URL directly without fetching for base64 media", () => {
+    const dataUrl = "data:image/png;base64,abc123";
+    (component as any).buildRowEntries({ img: dataUrl });
+    httpMock.expectNone(`${AppSettings.getApiEndpoint()}/huggingface/media-proxy`);
+    const entry = (component as any).buildRowEntries({ img: dataUrl })[0];
+    expect(entry.mediaSrc).toBe(dataUrl);
+    expect(entry.isImage).toBe(true);
+  });
+
+  it("should fetch remote image URL via media-proxy and set blob URL on success", () => {
+    const createObjectURLSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-url");
+    const remoteUrl = "https://example.com/photo.png";
+    const entries = (component as any).buildRowEntries({ img: remoteUrl });
+    const entry = entries[0];
+
+    expect(entry.mediaSrc).toBe("");
+    expect(entry.isImage).toBe(true);
+
+    const req = httpMock.expectOne(
+      `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`
+    );
+    req.flush(new Blob(["fake"], { type: "image/png" }));
+
+    expect(createObjectURLSpy).toHaveBeenCalled();
+    expect(entry.mediaSrc).toBe("blob:fake-url");
+    createObjectURLSpy.mockRestore();
+  });
+
+  it("should fall back to the text view (not the raw remote URL) when media-proxy request fails", () => {
+    const remoteUrl = "https://example.com/clip.mp4";
+    const entries = (component as any).buildRowEntries({ vid: remoteUrl });
+    const entry = entries[0];
+    expect(entry.isVideo).toBe(true);
+
+    const req = httpMock.expectOne(
+      `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`
+    );
+    req.error(new ProgressEvent("error"));
+
+    expect(entry.mediaSrc).toBe("");
+    expect(entry.isVideo).toBe(false);
+    expect(entry.isImage).toBe(false);
+    expect(entry.isAudio).toBe(false);
+  });
+
+  it("should not fetch media-proxy for non-media remote URLs", () => {
+    const remoteUrl = "https://example.com/some-text-value";
+    (component as any).buildRowEntries({ text: remoteUrl });
+    httpMock.expectNone(`${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`);
+  });
+
+  it("should revoke blob URLs on destroy", () => {
+    const revokeSpy = vi.spyOn(URL, "revokeObjectURL");
+    (component as any).allocatedBlobUrls.push("blob:url-1", "blob:url-2");
+    component.ngOnDestroy();
+    expect(revokeSpy).toHaveBeenCalledWith("blob:url-1");
+    expect(revokeSpy).toHaveBeenCalledWith("blob:url-2");
+    revokeSpy.mockRestore();
+  });
+
+  it("prettyRowJson should return pretty-printed JSON of currentDisplayRowData", () => {
+    component.currentDisplayRowData = { name: "test", value: 42 };
+    expect(component.prettyRowJson).toBe(JSON.stringify({ name: "test", value: 42 }, null, 2));
+  });
+
+  it("trackByEntryKey should return the entry key", () => {
+    expect(component.trackByEntryKey(0, { key: "myKey" })).toBe("myKey");
+    expect(component.trackByEntryKey(5, { key: "another" })).toBe("another");
+  });
+
+  it("should JSON-stringify non-string values in buildRowEntries", () => {
+    const entries = (component as any).buildRowEntries({ count: 42, arr: [1, 2, 3] });
+    expect(entries[0].value).toBe("42");
+    expect(entries[0].isImage).toBe(false);
+    expect(entries[0].isVideo).toBe(false);
+    expect(entries[0].isAudio).toBe(false);
+    expect(entries[1].value).toBe("[1,2,3]");
+    expect(entries[1].mediaSrc).toBe("[1,2,3]");
+  });
+
+  it("should not update row data when tuple is null", () => {
+    vi.spyOn(workflowResultServiceSpy, "getPaginatedResultService").mockReturnValueOnce({
+      selectTuple: vi.fn().mockReturnValue(of({ tuple: null })),
+    } as any);
+    component.currentDisplayRowData = { existing: "data" };
+    component.ngOnChanges();
+    expect(component.currentDisplayRowData).toEqual({ existing: "data" });
+  });
+
+  it("should call notificationService.success on successful clipboard copy", async () => {
+    const notifService = TestBed.inject(NotificationService);
+    const successSpy = vi.spyOn(notifService, "success").mockImplementation(() => undefined as any);
+    Object.defineProperty(navigator, "clipboard", {
+      value: { writeText: vi.fn().mockResolvedValue(undefined) },
+      configurable: true,
+      writable: true,
+    });
+    component.copyText("hello world");
+    await new Promise(r => setTimeout(r, 0));
+    expect(successSpy).toHaveBeenCalledWith("Copied to clipboard");
+    successSpy.mockRestore();
+  });
+
+  it("should call notificationService.error on clipboard copy failure", async () => {
+    const notifService = TestBed.inject(NotificationService);
+    const errorSpy = vi.spyOn(notifService, "error").mockImplementation(() => undefined as any);
+    Object.defineProperty(navigator, "clipboard", {
+      value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) },
+      configurable: true,
+      writable: true,
+    });
+    component.copyText("hello world");
+    await new Promise(r => setTimeout(r, 0));
+    expect(errorSpy).toHaveBeenCalledWith("Failed to copy");
+    errorSpy.mockRestore();
+  });
+});
+
+describe("RowModalComponent (with pre-loaded rowData)", () => {
+  let component: RowModalComponent;
+  let httpMock: HttpTestingController;
+
+  const rowData = { id: "123", imgUrl: "data:image/png;base64,abc" };
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [RowModalComponent, HttpClientTestingModule],
+      providers: [
+        { provide: NZ_MODAL_DATA, useValue: { operatorId: "op-2", rowIndex: 0, rowData } },
+        { provide: NzModalRef, useValue: { getConfig: () => ({}), close: vi.fn() } },
+        {
+          provide: WorkflowResultService,
+          useValue: { getPaginatedResultService: vi.fn().mockReturnValue(null) },
+        },
+        { provide: PanelResizeService, useValue: { pageSize: 10 } },
+      ],
+    }).compileComponents();
+
+    httpMock = TestBed.inject(HttpTestingController);
+    const fixture = TestBed.createComponent(RowModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  afterEach(() => {
+    httpMock.verify();
+  });
+
+  it("should initialize currentDisplayRowData from rowData in modal data", () => {
+    expect(component.currentDisplayRowData).toEqual(rowData);
+  });
+
+  it("should build rowEntries immediately for data URL images in rowData", () => {
+    const imageEntry = component.rowEntries.find(e => e.key === "imgUrl");
+    expect(imageEntry?.isImage).toBe(true);
+    expect(imageEntry?.mediaSrc).toBe(rowData.imgUrl);
+  });
+
+  it("should build rowEntries for all fields in rowData", () => {
+    expect(component.rowEntries.length).toBe(2);
+    const idEntry = component.rowEntries.find(e => e.key === "id");
+    expect(idEntry?.value).toBe("123");
+  });
+});
+
+describe("RowModalComponent (template rendering)", () => {
+  let component: RowModalComponent;
+  let fixture: ComponentFixture<RowModalComponent>;
+  let httpMock: HttpTestingController;
+
+  const rowData = {
+    video: "data:video/mp4;base64,vid123",
+    image: "data:image/png;base64,img123",
+  };
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [RowModalComponent, HttpClientTestingModule],
+      providers: [
+        { provide: NZ_MODAL_DATA, useValue: { operatorId: "op-3", rowIndex: 0, rowData } },
+        { provide: NzModalRef, useValue: { getConfig: () => ({}), close: vi.fn() } },
+        {
+          provide: WorkflowResultService,
+          useValue: { getPaginatedResultService: vi.fn().mockReturnValue(null) },
+        },
+        { provide: PanelResizeService, useValue: { pageSize: 10 } },
+      ],
+    }).compileComponents();
+
+    httpMock = TestBed.inject(HttpTestingController);
+    fixture = TestBed.createComponent(RowModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  afterEach(() => {
+    httpMock.verify();
+  });
+
+  it("should bind the video element's src to the video entry's mediaSrc", () => {
+    const videoEl = fixture.debugElement.query(By.css("video")).nativeElement as HTMLVideoElement;
+    expect(videoEl.src).toBe(rowData.video);
+  });
+
+  it("should bind the image element's src to the image entry's mediaSrc", () => {
+    const imgEl = fixture.debugElement.query(By.css("img")).nativeElement as HTMLImageElement;
+    expect(imgEl.src).toBe(rowData.image);
+  });
+
+  it("should call copyText with the entry's value when its Copy button is clicked", () => {
+    const copyTextSpy = vi.spyOn(component, "copyText").mockImplementation(() => undefined);
+    const copyButtons = fixture.debugElement.queryAll(By.css(".row-detail-header button"));
+    const videoEntryButton = copyButtons[0];
+
+    videoEntryButton.triggerEventHandler("click", null);
+
+    expect(copyTextSpy).toHaveBeenCalledWith(rowData.video);
+    copyTextSpy.mockRestore();
+  });
 });
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts
index 278a01dd5a..dddb30caf2 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts
+++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts
@@ -17,12 +17,18 @@
  * under the License.
  */
 
-import { Component, inject, OnChanges } from "@angular/core";
+import { Component, inject, OnChanges, OnDestroy } from "@angular/core";
+import { CommonModule } from "@angular/common";
 import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal";
+import { NzButtonModule } from "ng-zorro-antd/button";
+import { NzIconModule } from "ng-zorro-antd/icon";
+import { HttpClient } from "@angular/common/http";
 import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service";
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 import { PanelResizeService } from "../../service/workflow-result/panel-resize/panel-resize.service";
-import { NgxJsonViewerModule } from "ngx-json-viewer";
+import { NotificationService } from "../../../common/service/notification/notification.service";
+import { isAudioUrl, isVideoUrl, isImageUrl } from "src/app/common/util/media-type.util";
+import { AppSettings } from "../../../common/app-setting";
 
 /**
  *
@@ -42,29 +48,110 @@ import { NgxJsonViewerModule } from "ngx-json-viewer";
   selector: "texera-row-modal-content",
   templateUrl: "./result-panel-modal.component.html",
   styleUrls: ["./result-panel-model.component.scss"],
-  imports: [NgxJsonViewerModule],
+  imports: [CommonModule, NzButtonModule, NzIconModule],
 })
-export class RowModalComponent implements OnChanges {
+export class RowModalComponent implements OnChanges, OnDestroy {
+  rowEntries: { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] =
+    [];
+  private readonly allocatedBlobUrls: string[] = [];
   // Index of current displayed row in currentResult
-  readonly operatorId: string = inject(NZ_MODAL_DATA).operatorId;
-  rowIndex: number = inject(NZ_MODAL_DATA).rowIndex;
+  private readonly modalData: { operatorId: string; rowIndex: number; rowData?: Record<string, unknown> } =
+    inject(NZ_MODAL_DATA);
+  readonly operatorId: string = this.modalData.operatorId;
+  rowIndex: number = this.modalData.rowIndex;
   currentDisplayRowData: Record<string, unknown> = {};
 
   constructor(
     public modal: NzModalRef<any, number>,
+    private http: HttpClient,
     private workflowResultService: WorkflowResultService,
-    private resizeService: PanelResizeService
+    private resizeService: PanelResizeService,
+    private notificationService: NotificationService
   ) {
+    if (this.modalData.rowData) {
+      this.currentDisplayRowData = this.modalData.rowData;
+      this.rowEntries = this.buildRowEntries(this.currentDisplayRowData);
+    }
     this.ngOnChanges();
   }
 
+  get prettyRowJson(): string {
+    return JSON.stringify(this.currentDisplayRowData, null, 2);
+  }
+
+  copyText(text: string): void {
+    navigator.clipboard.writeText(text).then(
+      () => this.notificationService.success("Copied to clipboard"),
+      () => this.notificationService.error("Failed to copy")
+    );
+  }
+
   ngOnChanges(): void {
     this.workflowResultService
       .getPaginatedResultService(this.operatorId)
       ?.selectTuple(this.rowIndex, this.resizeService.pageSize)
       .pipe(untilDestroyed(this))
       .subscribe(res => {
-        this.currentDisplayRowData = res.tuple;
+        if (res?.tuple) {
+          this.currentDisplayRowData = res.tuple;
+          this.rowEntries = this.buildRowEntries(this.currentDisplayRowData);
+        }
+      });
+  }
+
+  trackByEntryKey(_index: number, entry: { key: string }): string {
+    return entry.key;
+  }
+
+  ngOnDestroy(): void {
+    for (const url of this.allocatedBlobUrls) {
+      URL.revokeObjectURL(url);
+    }
+  }
+
+  private fetchBlobSrc(
+    entry: { mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean },
+    remoteUrl: string
+  ): void {
+    const proxyUrl = `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`;
+    this.http
+      .get(proxyUrl, { responseType: "blob" })
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: blob => {
+          const blobUrl = URL.createObjectURL(blob);
+          this.allocatedBlobUrls.push(blobUrl);
+          entry.mediaSrc = blobUrl;
+        },
+        error: () => {
+          // The proxy rejected this URL (e.g. the SSRF allowlist blocked it). Don't fall
+          // back to loading the raw remote URL directly in the browser — fall back to the
+          // text view instead so the entry never bypasses the proxy's allowlist.
+          entry.isVideo = false;
+          entry.isImage = false;
+          entry.isAudio = false;
+        },
       });
   }
+
+  private buildRowEntries(
+    rowData: Record<string, unknown>
+  ): { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] {
+    return Object.entries(rowData).map(([key, val]) => {
+      const value = typeof val === "string" ? val : JSON.stringify(val) ?? String(val);
+      const isRemote = value.startsWith("http://") || value.startsWith("https://");
+      const entry = {
+        key,
+        value,
+        mediaSrc: isRemote ? "" : value,
+        isVideo: typeof val === "string" && isVideoUrl(val),
+        isImage: typeof val === "string" && isImageUrl(val),
+        isAudio: typeof val === "string" && isAudioUrl(val),
+      };
+      if (isRemote && (entry.isVideo || entry.isImage || entry.isAudio)) {
+        this.fetchBlobSrc(entry, value);
+      }
+      return entry;
+    });
+  }
 }
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss b/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
index eb5ff4dece..aafefae2d3 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
+++ b/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
@@ -23,3 +23,54 @@
   height: 100%;
   width: 100%;
 }
+
+.modal-toolbar {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: 12px;
+}
+
+.row-detail-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.row-detail-item {
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+  padding: 12px;
+  background: #fff;
+}
+
+.row-detail-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.row-detail-key {
+  font-weight: 600;
+  word-break: break-word;
+}
+
+.row-detail-pre {
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-family: monospace;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.row-detail-value {
+  max-height: none;
+  overflow: visible;
+  padding: 8px;
+  border: 1px solid #d9d9d9;
+  border-radius: 4px;
+  background: #fafafa;
+  user-select: text;
+}
diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html
index 5400d978ee..e9c5f83ee4 100644
--- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html
+++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html
@@ -161,7 +161,38 @@
             class="table-cell"
             nzEllipsis
             (click)="open(i, row)">
-            <span class="cell-content">{{ column.getCell(row) }}</span>
+            <span class="cell-content">
+              <ng-container [ngSwitch]="getCellMediaType(row, columnIndex)">
+                <ng-container *ngSwitchCase="'video'">
+                  <i
+                    nz-icon
+                    nzType="play-circle"
+                    nzTheme="outline"
+                    title="Play video"
+                    style="margin-right: 4px"></i>
+                  Play Video
+                </ng-container>
+                <ng-container *ngSwitchCase="'audio'">
+                  <i
+                    nz-icon
+                    nzType="sound"
+                    nzTheme="outline"
+                    title="Play audio"
+                    style="margin-right: 4px"></i>
+                  Play Audio
+                </ng-container>
+                <ng-container *ngSwitchCase="'image'">
+                  <i
+                    nz-icon
+                    nzType="eye"
+                    nzTheme="outline"
+                    title="View image"
+                    style="margin-right: 4px"></i>
+                  View Image
+                </ng-container>
+                <ng-container *ngSwitchDefault>{{ column.getCell(row) }}</ng-container>
+              </ng-container>
+            </span>
             <button
               (click)="downloadData(currentResult[i][column.columnDef], i, columnIndex, column.columnDef); $event.stopPropagation()"
               nz-button
diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts
index 5171c82d3c..0265490ac1 100644
--- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts
+++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts
@@ -31,6 +31,7 @@ import { By, DomSanitizer } from "@angular/platform-browser";
 import { of, Subject } from "rxjs";
 import { commonTestProviders } from "../../../../common/testing/test-utils";
 import { GuiConfigService } from "../../../../common/service/gui-config.service";
+import { isAudioUrl, isImageUrl, isVideoUrl } from "../../../../common/util/media-type.util";
 import {
   OperatorPaginationResultService,
   WorkflowResultService,
@@ -647,4 +648,72 @@ describe("ResultTableFrameComponent", () => {
       expect(downloadSpy).toHaveBeenCalledWith("alice", 0, 0, "name");
     });
   });
+
+  it("should detect media URLs for result cells", () => {
+    expect(component.isVideoCell("https://example.com/clip.mp4")).toBe(true);
+    expect(component.isAudioCell("https://example.com/sound.wav")).toBe(true);
+    expect(component.isImageCell("data:image/png;base64,AAAA")).toBe(true);
+  });
+
+  it("should reject non-media values for result cells", () => {
+    expect(component.isVideoCell("plain text")).toBe(false);
+    expect(component.isAudioCell(123 as unknown)).toBe(false);
+    expect(component.isImageCell(null as unknown)).toBe(false);
+  });
+
+  it("media-type util helpers should classify URLs consistently", () => {
+    expect(isVideoUrl("https://example.com/clip.webm")).toBe(true);
+    expect(isAudioUrl("https://example.com/track.flac")).toBe(true);
+    expect(isImageUrl("https://example.com/image.webp")).toBe(true);
+    expect(isVideoUrl("text")).toBe(false);
+    expect(isAudioUrl("text")).toBe(false);
+    expect(isImageUrl("text")).toBe(false);
+  });
+
+  describe("media cell rendering in table", () => {
+    beforeEach(() => {
+      component.operatorId = "test-op";
+    });
+
+    it("should render Play Video indicator for video URL cells", () => {
+      (component as any).setupResultTable([{ media: "https://example.com/clip.mp4" }], 1);
+      fixture.detectChanges();
+
+      const el = fixture.nativeElement as HTMLElement;
+      expect(el.textContent).toContain("Play Video");
+    });
+
+    it("should render Play Audio indicator for audio URL cells", () => {
+      (component as any).setupResultTable([{ media: "https://example.com/clip.mp3" }], 1);
+      fixture.detectChanges();
+
+      const el = fixture.nativeElement as HTMLElement;
+      expect(el.textContent).toContain("Play Audio");
+    });
+
+    it("should render View Image indicator for image URL cells", () => {
+      (component as any).setupResultTable([{ media: "https://example.com/photo.jpg" }], 1);
+      fixture.detectChanges();
+
+      const el = fixture.nativeElement as HTMLElement;
+      expect(el.textContent).toContain("View Image");
+    });
+
+    it("should render plain text for non-media cell values", () => {
+      (component as any).setupResultTable([{ label: "just text" }], 1);
+      fixture.detectChanges();
+
+      const el = fixture.nativeElement as HTMLElement;
+      expect(el.textContent).toContain("just text");
+    });
+
+    it("should render column headers matching the row keys", () => {
+      (component as any).setupResultTable([{ score: "0.95", url: "https://example.com/a.png" }], 1);
+      fixture.detectChanges();
+
+      const el = fixture.nativeElement as HTMLElement;
+      expect(el.textContent).toContain("score");
+      expect(el.textContent).toContain("url");
+    });
+  });
 });
diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts
index 72a0dbbf72..e46fa49f51 100644
--- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts
+++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts
@@ -37,10 +37,11 @@ import { IndexableObject, TableColumn } from "../../../types/result-table.interf
 import { RowModalComponent } from "../result-panel-modal.component";
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
+import { isAudioUrl, isVideoUrl, isImageUrl } from "../../../../common/util/media-type.util";
 import { ResultExportationComponent } from "../../result-exportation/result-exportation.component";
 import { WorkflowStatusService } from "../../../service/workflow-status/workflow-status.service";
 import { GuiConfigService } from "../../../../common/service/gui-config.service";
-import { NgIf, NgFor, NgClass } from "@angular/common";
+import { NgIf, NgFor, NgClass, NgSwitch, NgSwitchCase, NgSwitchDefault } from "@angular/common";
 import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space";
 import { NzInputDirective } from "ng-zorro-antd/input";
 import { NzButtonComponent } from "ng-zorro-antd/button";
@@ -48,6 +49,8 @@ import { NzWaveDirective } from "ng-zorro-antd/core/wave";
 import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
 import { NzIconDirective } from "ng-zorro-antd/icon";
 
+export type MediaCellType = "video" | "audio" | "image" | "text";
+
 /**
  * The Component will display the result in an excel table format,
  *  where each row represents a result from the workflow,
@@ -63,6 +66,9 @@ import { NzIconDirective } from "ng-zorro-antd/icon";
   styleUrls: ["./result-table-frame.component.scss"],
   imports: [
     NgIf,
+    NgSwitch,
+    NgSwitchCase,
+    NgSwitchDefault,
     NzSpaceCompactItemDirective,
     NzInputDirective,
     NzButtonComponent,
@@ -109,6 +115,13 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
   widthPercent: string = "";
   isOperatorFinished: boolean = false;
 
+  // Media type of each cell, precomputed once per row when result data arrives so the
+  // template doesn't re-run getCell + regex-based classification on every change
+  // detection cycle. Keyed by row object (not row index) because *ngFor iterates
+  // basicTable.data, which under front-end pagination is a page-local slice of
+  // currentResult whose indices don't line up with currentResult's indices.
+  cellMediaTypes: Map<IndexableObject, MediaCellType[]> = new Map();
+
   constructor(
     private modalService: NzModalService,
     private workflowActionService: WorkflowActionService,
@@ -431,6 +444,10 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
     // generate columnDef from first row, column definition is in order
     this.currentColumns = this.generateColumns(columns);
     this.totalNumTuples = totalRowCount;
+
+    this.cellMediaTypes = new Map(
+      this.currentResult.map(row => [row, this.currentColumns!.map(column => this.classifyCell(column.getCell(row)))])
+    );
   }
 
   /**
@@ -477,6 +494,31 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
     }
   }
 
+  isVideoCell(value: unknown): boolean {
+    return typeof value === "string" && isVideoUrl(value);
+  }
+
+  isAudioCell(value: unknown): boolean {
+    return typeof value === "string" && isAudioUrl(value);
+  }
+
+  isImageCell(value: unknown): boolean {
+    return typeof value === "string" && isImageUrl(value);
+  }
+
+  private classifyCell(value: unknown): MediaCellType {
+    if (this.isVideoCell(value)) return "video";
+    if (this.isAudioCell(value)) return "audio";
+    if (this.isImageCell(value)) return "image";
+    return "text";
+  }
+
+  // O(1) lookup into the precomputed cellMediaTypes map, used by the template
+  // instead of calling isVideoCell/isAudioCell/isImageCell on every change detection cycle.
+  getCellMediaType(row: IndexableObject, columnIndex: number): MediaCellType {
+    return this.cellMediaTypes.get(row)?.[columnIndex] ?? "text";
+  }
+
   onColumnSearch(event: Event): void {
     const input = event.target as HTMLInputElement;
     this.columnSearch = input.value;