Skip to content

Commit a2f92c5

Browse files
committed
feat(frontend): render HuggingFace media results inline in the result panel
Add isImageUrl/isAudioUrl/isVideoUrl detection helpers and wire them into the result table and row detail modal so image, audio, and video outputs from HuggingFace tasks render inline instead of as raw URLs. Gate backend string truncation on output mode so HF data URLs are never cut off.
1 parent ad58847 commit a2f92c5

8 files changed

Lines changed: 398 additions & 9 deletions
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { isAudioUrl, isImageUrl, isVideoUrl } from "./media-type.util";
21+
22+
describe("isImageUrl", () => {
23+
it("should return true for data:image/ data URLs", () => {
24+
expect(isImageUrl("data:image/png;base64,abc123")).toBeTrue();
25+
expect(isImageUrl("data:image/jpeg;base64,abc123")).toBeTrue();
26+
expect(isImageUrl("data:image/webp;base64,abc123")).toBeTrue();
27+
});
28+
29+
it("should return true for common image file extensions", () => {
30+
expect(isImageUrl("https://example.com/photo.png")).toBeTrue();
31+
expect(isImageUrl("https://example.com/photo.jpg")).toBeTrue();
32+
expect(isImageUrl("https://example.com/photo.jpeg")).toBeTrue();
33+
expect(isImageUrl("https://example.com/photo.gif")).toBeTrue();
34+
expect(isImageUrl("https://example.com/photo.webp")).toBeTrue();
35+
});
36+
37+
it("should be case-insensitive for extensions", () => {
38+
expect(isImageUrl("https://example.com/photo.PNG")).toBeTrue();
39+
expect(isImageUrl("https://example.com/photo.JPG")).toBeTrue();
40+
});
41+
42+
it("should return true for URLs with query strings", () => {
43+
expect(isImageUrl("https://example.com/photo.png?v=1")).toBeTrue();
44+
});
45+
46+
it("should return false for audio and video URLs", () => {
47+
expect(isImageUrl("data:audio/mp3;base64,abc")).toBeFalse();
48+
expect(isImageUrl("data:video/mp4;base64,abc")).toBeFalse();
49+
expect(isImageUrl("https://example.com/clip.mp4")).toBeFalse();
50+
});
51+
52+
it("should return false for plain text strings", () => {
53+
expect(isImageUrl("hello world")).toBeFalse();
54+
expect(isImageUrl("")).toBeFalse();
55+
});
56+
});
57+
58+
describe("isAudioUrl", () => {
59+
it("should return true for data:audio/ data URLs", () => {
60+
expect(isAudioUrl("data:audio/mp3;base64,abc123")).toBeTrue();
61+
expect(isAudioUrl("data:audio/wav;base64,abc123")).toBeTrue();
62+
});
63+
64+
it("should return true for common audio file extensions", () => {
65+
expect(isAudioUrl("https://example.com/clip.mp3")).toBeTrue();
66+
expect(isAudioUrl("https://example.com/clip.wav")).toBeTrue();
67+
expect(isAudioUrl("https://example.com/clip.ogg")).toBeTrue();
68+
expect(isAudioUrl("https://example.com/clip.m4a")).toBeTrue();
69+
expect(isAudioUrl("https://example.com/clip.flac")).toBeTrue();
70+
});
71+
72+
it("should be case-insensitive for extensions", () => {
73+
expect(isAudioUrl("https://example.com/clip.MP3")).toBeTrue();
74+
expect(isAudioUrl("https://example.com/clip.WAV")).toBeTrue();
75+
});
76+
77+
it("should return true for URLs with query strings", () => {
78+
expect(isAudioUrl("https://example.com/clip.mp3?token=xyz")).toBeTrue();
79+
});
80+
81+
it("should return false for image and video URLs", () => {
82+
expect(isAudioUrl("data:image/png;base64,abc")).toBeFalse();
83+
expect(isAudioUrl("data:video/mp4;base64,abc")).toBeFalse();
84+
expect(isAudioUrl("https://example.com/photo.png")).toBeFalse();
85+
});
86+
87+
it("should return false for plain text strings", () => {
88+
expect(isAudioUrl("hello world")).toBeFalse();
89+
expect(isAudioUrl("")).toBeFalse();
90+
});
91+
});
92+
93+
describe("isVideoUrl", () => {
94+
it("should return true for data:video/ data URLs", () => {
95+
expect(isVideoUrl("data:video/mp4;base64,abc123")).toBeTrue();
96+
expect(isVideoUrl("data:video/webm;base64,abc123")).toBeTrue();
97+
});
98+
99+
it("should return true for common video file extensions", () => {
100+
expect(isVideoUrl("https://example.com/clip.mp4")).toBeTrue();
101+
expect(isVideoUrl("https://example.com/clip.webm")).toBeTrue();
102+
expect(isVideoUrl("https://example.com/clip.ogg")).toBeTrue();
103+
});
104+
105+
it("should return true for fal.media CDN URLs", () => {
106+
expect(isVideoUrl("https://v3b.fal.media/files/abc123/output.mp4")).toBeTrue();
107+
});
108+
109+
it("should be case-insensitive for extensions", () => {
110+
expect(isVideoUrl("https://example.com/clip.MP4")).toBeTrue();
111+
expect(isVideoUrl("https://example.com/clip.WEBM")).toBeTrue();
112+
});
113+
114+
it("should return true for URLs with query strings", () => {
115+
expect(isVideoUrl("https://example.com/clip.mp4?t=5")).toBeTrue();
116+
});
117+
118+
it("should return false for image and audio URLs", () => {
119+
expect(isVideoUrl("data:image/png;base64,abc")).toBeFalse();
120+
expect(isVideoUrl("data:audio/mp3;base64,abc")).toBeFalse();
121+
expect(isVideoUrl("https://example.com/photo.jpg")).toBeFalse();
122+
});
123+
124+
it("should return false for plain text strings", () => {
125+
expect(isVideoUrl("hello world")).toBeFalse();
126+
expect(isVideoUrl("")).toBeFalse();
127+
});
128+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
export function isVideoUrl(value: string): boolean {
21+
if (typeof value !== "string") return false;
22+
return (
23+
value.match(/\.(mp4|webm|ogg)(\?.*)?$/i) !== null ||
24+
value.startsWith("data:video/") ||
25+
value.startsWith("https://v3b.fal.media/files/")
26+
);
27+
}
28+
29+
export function isAudioUrl(value: string): boolean {
30+
if (typeof value !== "string") return false;
31+
return value.match(/\.(mp3|wav|ogg|m4a|flac)(\?.*)?$/i) !== null || value.startsWith("data:audio/");
32+
}
33+
34+
export function isImageUrl(value: string): boolean {
35+
if (typeof value !== "string") return false;
36+
return value.match(/\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i) !== null || value.startsWith("data:image/");
37+
}

frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,56 @@
1818
-->
1919

2020
<div class="modal-body content-body">
21-
<ngx-json-viewer
22-
[expanded]="false"
23-
[json]="currentDisplayRowData"></ngx-json-viewer>
21+
<div class="modal-toolbar">
22+
<button
23+
nz-button
24+
nzType="default"
25+
type="button"
26+
(click)="copyText(prettyRowJson)">
27+
Copy Row JSON
28+
</button>
29+
</div>
30+
31+
<div class="row-detail-list">
32+
<div
33+
*ngFor="let entry of rowEntries; trackBy: trackByEntryKey"
34+
class="row-detail-item">
35+
<div class="row-detail-header">
36+
<div class="row-detail-key">{{ entry.key }}</div>
37+
<button
38+
nz-button
39+
nzType="link"
40+
type="button"
41+
(click)="copyText(entry.value)">
42+
Copy
43+
</button>
44+
</div>
45+
46+
<video
47+
*ngIf="entry.isVideo; else checkAudio"
48+
controls
49+
[src]="entry.mediaSrc"
50+
style="max-width: 100%; max-height: 480px; display: block; border: 1px solid #eee; border-radius: 4px"></video>
51+
52+
<ng-template #checkAudio>
53+
<audio
54+
*ngIf="entry.isAudio; else checkImage"
55+
controls
56+
[src]="entry.mediaSrc"
57+
style="width: 100%; max-width: 420px; display: block"></audio>
58+
</ng-template>
59+
60+
<ng-template #checkImage>
61+
<img
62+
*ngIf="entry.isImage; else textValue"
63+
[src]="entry.mediaSrc"
64+
alt="image result"
65+
style="max-width: 100%; max-height: 480px; display: block; border: 1px solid #eee; border-radius: 4px" />
66+
67+
<ng-template #textValue>
68+
<pre class="row-detail-pre row-detail-value">{{ entry.value }}</pre>
69+
</ng-template>
70+
</ng-template>
71+
</div>
72+
</div>
2473
</div>

frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@
1818
*/
1919

2020
import { Component, inject, OnChanges } from "@angular/core";
21+
import { CommonModule } from "@angular/common";
2122
import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal";
23+
import { NzButtonModule } from "ng-zorro-antd/button";
24+
import { NzIconModule } from "ng-zorro-antd/icon";
2225
import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service";
2326
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
2427
import { PanelResizeService } from "../../service/workflow-result/panel-resize/panel-resize.service";
2528
import { NgxJsonViewerModule } from "ngx-json-viewer";
29+
import { NotificationService } from "../../../common/service/notification/notification.service";
30+
import { isAudioUrl, isVideoUrl, isImageUrl } from "src/app/common/util/media-type.util";
31+
import { AppSettings } from "../../../common/app-setting";
2632

2733
/**
2834
*
@@ -42,29 +48,79 @@ import { NgxJsonViewerModule } from "ngx-json-viewer";
4248
selector: "texera-row-modal-content",
4349
templateUrl: "./result-panel-modal.component.html",
4450
styleUrls: ["./result-panel-model.component.scss"],
45-
imports: [NgxJsonViewerModule],
51+
imports: [CommonModule, NzButtonModule, NzIconModule, NgxJsonViewerModule],
4652
})
4753
export class RowModalComponent implements OnChanges {
54+
rowEntries: { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] =
55+
[];
4856
// Index of current displayed row in currentResult
49-
readonly operatorId: string = inject(NZ_MODAL_DATA).operatorId;
50-
rowIndex: number = inject(NZ_MODAL_DATA).rowIndex;
57+
private readonly modalData: { operatorId: string; rowIndex: number; rowData?: Record<string, unknown> } =
58+
inject(NZ_MODAL_DATA);
59+
readonly operatorId: string = this.modalData.operatorId;
60+
rowIndex: number = this.modalData.rowIndex;
5161
currentDisplayRowData: Record<string, unknown> = {};
5262

5363
constructor(
5464
public modal: NzModalRef<any, number>,
5565
private workflowResultService: WorkflowResultService,
56-
private resizeService: PanelResizeService
66+
private resizeService: PanelResizeService,
67+
private notificationService: NotificationService
5768
) {
69+
if (this.modalData.rowData) {
70+
this.currentDisplayRowData = this.modalData.rowData;
71+
this.rowEntries = this.buildRowEntries(this.currentDisplayRowData);
72+
}
5873
this.ngOnChanges();
5974
}
6075

76+
get prettyRowJson(): string {
77+
return JSON.stringify(this.currentDisplayRowData, null, 2);
78+
}
79+
80+
copyText(text: string): void {
81+
navigator.clipboard.writeText(text).then(
82+
() => this.notificationService.success("Copied to clipboard"),
83+
() => this.notificationService.error("Failed to copy")
84+
);
85+
}
86+
6187
ngOnChanges(): void {
6288
this.workflowResultService
6389
.getPaginatedResultService(this.operatorId)
6490
?.selectTuple(this.rowIndex, this.resizeService.pageSize)
6591
.pipe(untilDestroyed(this))
6692
.subscribe(res => {
67-
this.currentDisplayRowData = res.tuple;
93+
if (res?.tuple) {
94+
this.currentDisplayRowData = res.tuple;
95+
this.rowEntries = this.buildRowEntries(this.currentDisplayRowData);
96+
}
6897
});
6998
}
99+
100+
trackByEntryKey(_index: number, entry: { key: string }): string {
101+
return entry.key;
102+
}
103+
104+
private resolveMediaSrc(value: string): string {
105+
if (!value.startsWith("http://") && !value.startsWith("https://")) {
106+
return value;
107+
}
108+
return `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(value)}`;
109+
}
110+
111+
private buildRowEntries(
112+
rowData: Record<string, unknown>
113+
): { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] {
114+
return Object.entries(rowData).map(([key, val]) => {
115+
const value = typeof val === "string" ? val : JSON.stringify(val);
116+
return {
117+
key,
118+
value,
119+
mediaSrc: this.resolveMediaSrc(value),
120+
isVideo: typeof val === "string" && isVideoUrl(val),
121+
isImage: typeof val === "string" && isImageUrl(val),
122+
isAudio: typeof val === "string" && isAudioUrl(val),
123+
};
124+
});
125+
}
70126
}

frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,54 @@
2323
height: 100%;
2424
width: 100%;
2525
}
26+
27+
.modal-toolbar {
28+
display: flex;
29+
justify-content: flex-end;
30+
margin-bottom: 12px;
31+
}
32+
33+
.row-detail-list {
34+
display: flex;
35+
flex-direction: column;
36+
gap: 12px;
37+
}
38+
39+
.row-detail-item {
40+
border: 1px solid #d9d9d9;
41+
border-radius: 4px;
42+
padding: 12px;
43+
background: #fff;
44+
}
45+
46+
.row-detail-header {
47+
display: flex;
48+
align-items: center;
49+
justify-content: space-between;
50+
gap: 12px;
51+
margin-bottom: 8px;
52+
}
53+
54+
.row-detail-key {
55+
font-weight: 600;
56+
word-break: break-word;
57+
}
58+
59+
.row-detail-pre {
60+
margin: 0;
61+
white-space: pre-wrap;
62+
word-break: break-word;
63+
font-family: monospace;
64+
font-size: 12px;
65+
line-height: 1.5;
66+
}
67+
68+
.row-detail-value {
69+
max-height: none;
70+
overflow: visible;
71+
padding: 8px;
72+
border: 1px solid #d9d9d9;
73+
border-radius: 4px;
74+
background: #fafafa;
75+
user-select: text;
76+
}

0 commit comments

Comments
 (0)