Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions components/studio/StudioMediaBrowser.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Download, Plus, Search, Sparkles, Trash2 } from 'lucide-react';
import { useMemo, useState, type DragEvent } from 'react';
import { useWorkflowMediaUrl } from '../workflow/media';
import { loadWorkflowMediaBlob, useWorkflowMediaUrl } from '../workflow/media';
import { FolderTree, type FolderTreeProps } from './FolderTree';
import type { AssetFolder } from '../../types';
import { displayError } from '../../services/displayError';
import { downloadBlob, openRemoteMediaUrl } from '../../services/desktopNetwork';

export const STUDIO_MEDIA_DRAG_TYPE = 'application/x-flovart-studio-media';

Expand Down Expand Up @@ -54,18 +55,41 @@ function StudioMediaPreview({ item }: { item: StudioMediaItem }) {
}

function StudioMediaDownload({ item, isChinese }: { item: StudioMediaItem; isChinese: boolean }) {
const media = useWorkflowMediaUrl(undefined, item.href);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const onDownload = async () => {
if (busy) return;
setBusy(true);
setError('');
try {
const blob = await loadWorkflowMediaBlob(undefined, item.href);
downloadBlob(blob, item.name);
} catch (downloadError) {
const message = displayError(downloadError, isChinese ? '下载失败。' : 'Download failed.');
setError(message);
if (/^https:\/\//i.test(item.href)) {
try {
await openRemoteMediaUrl(item.href);
return;
} catch (openError) {
setError(displayError(openError, message));
}
}
} finally {
setBusy(false);
}
};
return (
<a
<button
type="button"
className="isl-icon-btn flex h-7 w-7 items-center justify-center"
href={media.url || '#'}
download={item.name}
aria-disabled={!media.url}
disabled={busy}
aria-label={`${isChinese ? '下载' : 'Download'} ${item.name}`}
onClick={event => { if (!media.url) event.preventDefault(); }}
title={error || (isChinese ? '下载' : 'Download')}
onClick={() => { void onDownload(); }}
>
<Download size={13} aria-hidden="true" />
</a>
</button>
);
}

Expand Down
16 changes: 7 additions & 9 deletions components/workflow/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getAssetById } from '../../utils/assetStorage';
import { loadRuntimeArtifactBlob } from '../../services/runtimeArtifacts';
import { loadBrowserImportArtifactBlob, parseBrowserImportHref } from '../../services/browserImportArtifacts';
import { LocalFolderError, parseLocalFolderHref, readLocalFolderFile } from '../../services/localFolderSource';
import { fetchRemoteMediaBlob, shouldUseDesktopNativeFetch } from '../../services/desktopNetwork';
import { workflowMediaStorage } from './storage';
import type { WorkflowArtifactRef, WorkflowNode, WorkflowNodeMetadata, WorkflowNodeType, WorkflowProject } from './types';

Expand Down Expand Up @@ -332,9 +333,7 @@ function decodeDataUrl(dataUrl: string): Blob {
export async function workflowDataUrlToBlob(dataUrl: string): Promise<Blob> {
const trimmed = dataUrl.trim();
if (/^data:/i.test(trimmed)) return decodeDataUrl(trimmed);
const response = await fetch(dataUrl);
if (!response.ok) throw new Error('无法读取图片结果');
return response.blob();
return fetchRemoteMediaBlob(dataUrl);
}

export function isFetchableMediaHref(href: string) {
Expand Down Expand Up @@ -369,9 +368,7 @@ export async function loadFallbackMediaBlob(href: string): Promise<Blob> {
if (!isFetchableMediaHref(href)) {
throw new Error('无法读取本地媒体引用,请重新导入素材');
}
const response = await fetch(href);
if (!response.ok) throw new Error('无法读取图片文件');
return response.blob();
return fetchRemoteMediaBlob(href);
}

export async function loadWorkflowMediaBlob(storageKey?: string, href?: string, artifactRef?: WorkflowArtifactRef): Promise<Blob> {
Expand Down Expand Up @@ -419,7 +416,8 @@ function inspectImageElement(url: string): Promise<HTMLImageElement> {
export function useWorkflowMediaUrl(storageKey?: string, fallbackHref?: string, artifactRef?: WorkflowArtifactRef) {
const artifactKey = artifactRef?.taskId || '';
const mediaKey = storageKey || artifactKey || fallbackHref || '';
const immediateUrl = storageKey ? null : (fallbackHref && !isFetchableMediaHref(fallbackHref) ? null : fallbackHref || null);
const nativeRemote = shouldUseDesktopNativeFetch(fallbackHref);
const immediateUrl = storageKey || nativeRemote ? null : (fallbackHref && !isFetchableMediaHref(fallbackHref) ? null : fallbackHref || null);
const [state, setState] = useState<{ key: string; url: string | null; error: string | null }>({
key: mediaKey,
url: immediateUrl,
Expand All @@ -430,7 +428,7 @@ export function useWorkflowMediaUrl(storageKey?: string, fallbackHref?: string,
let active = true;
let objectUrl: string | null = null;
setState({ key: mediaKey, url: immediateUrl, error: null });
if (!storageKey && !artifactRef && (!fallbackHref || isFetchableMediaHref(fallbackHref))) return () => undefined;
if (!storageKey && !artifactRef && (!fallbackHref || (isFetchableMediaHref(fallbackHref) && !nativeRemote))) return () => undefined;
void loadWorkflowMediaBlob(storageKey, fallbackHref, artifactRef).then(blob => {
if (!active) return;
if (!blob) {
Expand All @@ -453,7 +451,7 @@ export function useWorkflowMediaUrl(storageKey?: string, fallbackHref?: string,
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [artifactKey, artifactRef?.mimeType, fallbackHref, mediaKey, storageKey]);
}, [artifactKey, artifactRef?.mimeType, fallbackHref, mediaKey, nativeRemote, storageKey]);

return state.key === mediaKey ? state : { url: immediateUrl, error: null };
}
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"react-konva": "^19.2.4",
"react-router": "^7.18.2",
"tippy.js": "^6.3.7",
"undici": "^6.28.0",
"undici": "^6.28.1",
"uuid": "^14.0.1",
"yaml": "^2.9.0",
"zod": "^4.0.17",
Expand Down
77 changes: 77 additions & 0 deletions services/desktopNetwork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { invoke, isTauri } from '@tauri-apps/api/core';

const REMOTE_MEDIA_MIME_HEADER_BYTES = 4;

function decodeNativeMediaPayload(payload: ArrayBuffer | Uint8Array, fallbackMimeType?: string): Blob {
const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
if (bytes.byteLength < REMOTE_MEDIA_MIME_HEADER_BYTES) {
throw new Error('桌面媒体响应无效');
}

const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const mimeLength = view.getUint32(0, true);
const bodyOffset = REMOTE_MEDIA_MIME_HEADER_BYTES + mimeLength;
if (bodyOffset > bytes.byteLength) {
throw new Error('桌面媒体响应无效');
}

const mimeBytes = bytes.subarray(REMOTE_MEDIA_MIME_HEADER_BYTES, bodyOffset);
const mimeType = new TextDecoder().decode(mimeBytes).trim()
|| fallbackMimeType
|| 'application/octet-stream';
return new Blob([bytes.subarray(bodyOffset)], { type: mimeType });
}

export function shouldUseDesktopNativeFetch(url: string | undefined | null): boolean {
return Boolean(url && /^https:\/\//i.test(url) && typeof window !== 'undefined' && isTauri());
}

export async function fetchRemoteMediaBlob(
url: string,
options: { signal?: AbortSignal; fallbackMimeType?: string } = {},
): Promise<Blob> {
if (options.signal?.aborted) {
throw options.signal.reason || new DOMException('Media fetch aborted', 'AbortError');
}

if (shouldUseDesktopNativeFetch(url)) {
const pending = invoke<ArrayBuffer>('desktop_fetch_remote_media', { url })
.then(payload => decodeNativeMediaPayload(payload, options.fallbackMimeType));

if (!options.signal) return pending;

return await Promise.race([
pending,
new Promise<never>((_, reject) => {
options.signal?.addEventListener('abort', () => {
reject(options.signal?.reason || new DOMException('Media fetch aborted', 'AbortError'));
}, { once: true });
}),
]);
}

const response = await fetch(url, { signal: options.signal });
if (!response.ok) throw new Error(`无法下载远程媒体 (HTTP ${response.status})`);
return response.blob();
}

export async function openRemoteMediaUrl(url: string): Promise<void> {
if (typeof window !== 'undefined' && isTauri() && /^https:\/\//i.test(url)) {
await invoke('desktop_open_remote_url', { url });
return;
}

const opened = window.open(url, '_blank', 'noopener,noreferrer');
if (!opened) throw new Error('无法打开远程媒体链接');
}

export function downloadBlob(blob: Blob, filename: string): void {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}
9 changes: 4 additions & 5 deletions services/workflowGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { validateWorkflowOperationOutputs } from '../components/workflow/operati
import type { ProviderMaterializedReference } from './providerGenerationAdapter';
import { resolveProviderGenerationExtension } from './userScriptProviderAdapter';
import { displayError } from './displayError';
import { fetchRemoteMediaBlob } from './desktopNetwork';

export interface WorkflowHistoryPayload {
name?: string;
Expand Down Expand Up @@ -148,14 +149,12 @@ async function mediaResult(
try {
const blob = /^data:/i.test(result.mediaUrl)
? await workflowDataUrlToBlob(result.mediaUrl)
: await (runtime.fetchMedia || (href => fetch(href, {
// 产物下载同时受外层取消(停止生成/新 run 抢占)与 120s 超时约束,避免无限挂起。
: await (runtime.fetchMedia || (href => fetchRemoteMediaBlob(href, {
// 桌面端通过 Rust IPC 下载远程产物,绕过 WebView CORS;Web 端仍使用浏览器 fetch。
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(120_000)])
: AbortSignal.timeout(120_000),
}).then(response => {
if (!response.ok) throw new Error('无法下载生成结果');
return response.blob();
fallbackMimeType: result.mimeType,
})))(result.mediaUrl);
const extension = mode === 'video' ? 'mp4' : 'png';
const file = typeof File === 'undefined' ? Object.assign(blob, { name: `workflow-result.${extension}`, lastModified: Date.now() }) as File : new File([blob], `workflow-result.${extension}`, { type: result.mimeType || blob.type, lastModified: Date.now() });
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod deeplink;
pub mod errors;
pub mod keyring;
pub mod managed_agent;
pub mod remote_media;
pub mod runtime;
pub mod state;

Expand Down Expand Up @@ -149,6 +150,8 @@ pub fn run() {
runtime::browser_import_mark_consumed,
runtime::browser_import_artifact_read,
managed_agent::managed_agent_connection,
remote_media::desktop_fetch_remote_media,
remote_media::desktop_open_remote_url,
])
.build(tauri::generate_context!())
.expect("error while building Iris");
Expand Down
Loading
Loading