feat(web): improve playground media resource prompts
This commit is contained in:
parent
cdf469eccf
commit
170fd8655c
@ -2,10 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import { ChevronDown, MessageSquarePlus, Send, Sparkles } from 'lucide-react';
|
||||
import { Badge, Button, Select, Textarea } from '../components/ui';
|
||||
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, taskIsPending } from '../api';
|
||||
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
|
||||
import type { PlaygroundMode } from '../types';
|
||||
import {
|
||||
PlaygroundPromptMentionInput,
|
||||
buildPlaygroundResourceToken,
|
||||
removeInvalidPlaygroundResourceTokens,
|
||||
replacePlaygroundResourceTokens,
|
||||
} from './playground-prompt-mention';
|
||||
@ -240,6 +241,8 @@ export function PlaygroundPage(props: {
|
||||
mode?: Exclude<PlaygroundMode, 'chat'>;
|
||||
prompt?: string;
|
||||
settings?: MediaGenerationSettings;
|
||||
uploads?: PlaygroundUpload[];
|
||||
videoMode?: VideoCreateMode;
|
||||
}) {
|
||||
const runMode = overrides?.mode ?? props.mode;
|
||||
if (runMode === 'chat') return;
|
||||
@ -266,7 +269,8 @@ export function PlaygroundPage(props: {
|
||||
}
|
||||
|
||||
const localId = newLocalId();
|
||||
const runUploads = overrides ? [] : mediaUploads;
|
||||
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
|
||||
const runVideoMode = overrides?.videoMode ?? videoMode;
|
||||
const runModelOption = modelOptions.find((item) => item.value === runModel);
|
||||
const modelLabel = runModelOption?.label ?? runModel;
|
||||
const run: MediaGenerationRun = {
|
||||
@ -278,6 +282,8 @@ export function PlaygroundPage(props: {
|
||||
prompt: trimmedPrompt,
|
||||
settings: runSettings,
|
||||
status: 'submitting',
|
||||
uploads: runUploads,
|
||||
videoMode: runMode === 'video' ? runVideoMode : undefined,
|
||||
};
|
||||
|
||||
setMediaRuns((current) => [...current, run]);
|
||||
@ -288,7 +294,7 @@ export function PlaygroundPage(props: {
|
||||
if (runMode === 'video') {
|
||||
response = await createVideoGenerationTask(credential, {
|
||||
model: runModel,
|
||||
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, runUploads, videoMode),
|
||||
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, runUploads, runVideoMode),
|
||||
...mediaRequestPayload(runSettings, 'video'),
|
||||
});
|
||||
} else {
|
||||
@ -353,8 +359,15 @@ export function PlaygroundPage(props: {
|
||||
}
|
||||
|
||||
function editMediaRun(run: MediaGenerationRun) {
|
||||
setPrompt(run.prompt);
|
||||
const runUploads = editableMediaRunUploads(run);
|
||||
const editablePrompt = editableMediaRunPrompt(run, runUploads);
|
||||
setPrompt(editablePrompt);
|
||||
setMediaSettings(run.settings);
|
||||
setMediaUploads(runUploads);
|
||||
setImageHasReference(run.mode === 'image' && runUploads.some((item) => item.kind === 'image'));
|
||||
if (run.mode === 'video') {
|
||||
setVideoMode(run.videoMode ?? inferVideoModeFromUploads(runUploads));
|
||||
}
|
||||
selectMediaRunModel(run);
|
||||
if (props.mode !== run.mode) {
|
||||
props.onModeChange(run.mode);
|
||||
@ -363,15 +376,23 @@ export function PlaygroundPage(props: {
|
||||
}
|
||||
|
||||
function rerunMediaRun(run: MediaGenerationRun) {
|
||||
setPrompt(run.prompt);
|
||||
const runUploads = editableMediaRunUploads(run);
|
||||
const editablePrompt = editableMediaRunPrompt(run, runUploads);
|
||||
const runVideoMode = run.videoMode ?? inferVideoModeFromUploads(runUploads);
|
||||
setPrompt(editablePrompt);
|
||||
setMediaSettings(run.settings);
|
||||
setMediaUploads(runUploads);
|
||||
setImageHasReference(run.mode === 'image' && runUploads.some((item) => item.kind === 'image'));
|
||||
if (run.mode === 'video') {
|
||||
setVideoMode(runVideoMode);
|
||||
}
|
||||
const runModel = selectMediaRunModel(run);
|
||||
if (props.mode !== run.mode) {
|
||||
props.onModeChange(run.mode);
|
||||
setMediaMessage('已切换到对应模式并带入模型和参数,请确认后再次生成。');
|
||||
return;
|
||||
}
|
||||
void submitMediaTask({ mode: run.mode, model: runModel, prompt: run.prompt, settings: run.settings });
|
||||
void submitMediaTask({ mode: run.mode, model: runModel, prompt: editablePrompt, settings: run.settings, uploads: runUploads, videoMode: runVideoMode });
|
||||
}
|
||||
|
||||
const mediaComposer = props.mode === 'chat' ? null : (
|
||||
@ -676,8 +697,8 @@ function Composer(props: {
|
||||
}
|
||||
|
||||
function mediaPromptPlaceholder(mode: PlaygroundMode) {
|
||||
if (mode === 'image') return '输入画面描述,可用 @ 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
|
||||
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 引用图片、视频或音频资源...';
|
||||
if (mode === 'image') return '输入画面描述,可用 @ 或 @资产 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
|
||||
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 或 @资产 引用图片、视频或音频资源...';
|
||||
return placeholderByMode.chat;
|
||||
}
|
||||
|
||||
@ -771,6 +792,211 @@ function updateMediaRun(runs: MediaGenerationRun[], localId: string, patch: Part
|
||||
return runs.map((run) => run.localId === localId ? { ...run, ...patch } : run);
|
||||
}
|
||||
|
||||
const playgroundResourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
|
||||
|
||||
function editableMediaRunUploads(run: MediaGenerationRun): PlaygroundUpload[] {
|
||||
const storedUploads = sanitizeMediaRunUploads(run.uploads ?? []);
|
||||
if (storedUploads.length) return storedUploads;
|
||||
return mediaRunUploadsFromTaskRequest(run.task?.request, run.mode);
|
||||
}
|
||||
|
||||
function editableMediaRunPrompt(run: MediaGenerationRun, uploads: PlaygroundUpload[]) {
|
||||
return restorePromptResourceTokens(run.prompt, uploads, run.mode);
|
||||
}
|
||||
|
||||
function restorePromptResourceTokens(raw: string, uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||
if (!uploads.length) return raw;
|
||||
if (raw.includes('<<<playground-resource:')) {
|
||||
return restoreSerializedPromptResourceTokens(raw, uploads);
|
||||
}
|
||||
return restorePromptLabelsToResourceTokens(raw, uploads, mode);
|
||||
}
|
||||
|
||||
function restoreSerializedPromptResourceTokens(raw: string, uploads: PlaygroundUpload[]) {
|
||||
const byId = new Map(uploads.map((item) => [item.id, item]));
|
||||
const usedIds = new Set<string>();
|
||||
let fallbackIndex = 0;
|
||||
return raw.replace(playgroundResourceTokenPattern, (full, id: string) => {
|
||||
let item = byId.get(id);
|
||||
if (item) {
|
||||
usedIds.add(item.id);
|
||||
} else {
|
||||
while (fallbackIndex < uploads.length && usedIds.has(uploads[fallbackIndex]!.id)) {
|
||||
fallbackIndex += 1;
|
||||
}
|
||||
item = uploads[fallbackIndex];
|
||||
fallbackIndex += 1;
|
||||
if (item) usedIds.add(item.id);
|
||||
}
|
||||
return item ? buildPlaygroundResourceToken(item.id) : full;
|
||||
});
|
||||
}
|
||||
|
||||
function restorePromptLabelsToResourceTokens(raw: string, uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||
return uploads.reduce((text, item) => {
|
||||
const index = uploadKindIndex(item, uploads);
|
||||
return promptResourceRestorePatterns(item.kind, index, mode).reduce(
|
||||
(current, pattern) => current.replace(pattern, buildPlaygroundResourceToken(item.id)),
|
||||
text,
|
||||
);
|
||||
}, raw);
|
||||
}
|
||||
|
||||
function promptResourceRestorePatterns(kind: PlaygroundUpload['kind'], index: number, mode: Exclude<PlaygroundMode, 'chat'>) {
|
||||
const englishKind = mode === 'image' ? 'image' : uploadKindEnglish(kind);
|
||||
const patterns = [
|
||||
new RegExp(`\\b${englishKind}\\s+${index}\\b`, 'g'),
|
||||
];
|
||||
if (kind === 'image') {
|
||||
patterns.push(new RegExp(`@(?:图像|图片)\\s*${index}(?!\\d)`, 'g'));
|
||||
} else {
|
||||
patterns.push(new RegExp(`@${uploadKindTitle(kind)}\\s*${index}(?!\\d)`, 'g'));
|
||||
}
|
||||
patterns.push(new RegExp(`@(?:素材|资产)\\s*${index}(?!\\d)`, 'g'));
|
||||
return patterns;
|
||||
}
|
||||
|
||||
function mediaRunUploadsFromTaskRequest(request: unknown, mode: Exclude<PlaygroundMode, 'chat'>): PlaygroundUpload[] {
|
||||
const record = recordFromUnknown(request);
|
||||
if (!record) return [];
|
||||
const uploads: PlaygroundUpload[] = [];
|
||||
if (Array.isArray(record.content)) {
|
||||
record.content.forEach((item) => {
|
||||
const part = recordFromUnknown(item);
|
||||
if (!part) return;
|
||||
appendUploadFromContentPart(uploads, part);
|
||||
});
|
||||
}
|
||||
if (mode === 'image') {
|
||||
appendImageUploadsFromRequestValue(uploads, record.image);
|
||||
appendImageUploadsFromRequestValue(uploads, record.images);
|
||||
appendImageUploadsFromRequestValue(uploads, record.input_image);
|
||||
appendImageUploadsFromRequestValue(uploads, record.input_images);
|
||||
}
|
||||
return dedupeMediaRunUploads(uploads);
|
||||
}
|
||||
|
||||
function appendUploadFromContentPart(uploads: PlaygroundUpload[], part: Record<string, unknown>) {
|
||||
const role = part.role === 'first_frame' || part.role === 'last_frame' ? part.role : undefined;
|
||||
const type = stringFromUnknown(part.type);
|
||||
if (type === 'image_url') {
|
||||
appendMediaRunUploadUrl(uploads, 'image', firstString(nestedString(part.image_url, 'url'), part.url), role);
|
||||
return;
|
||||
}
|
||||
if (type === 'video_url') {
|
||||
appendMediaRunUploadUrl(uploads, 'video', firstString(nestedString(part.video_url, 'url'), part.url), role);
|
||||
return;
|
||||
}
|
||||
if (type === 'audio_url') {
|
||||
appendMediaRunUploadUrl(uploads, 'audio', firstString(nestedString(part.audio_url, 'url'), part.url), role);
|
||||
}
|
||||
}
|
||||
|
||||
function appendImageUploadsFromRequestValue(uploads: PlaygroundUpload[], value: unknown) {
|
||||
if (!value) return;
|
||||
if (typeof value === 'string') {
|
||||
appendMediaRunUploadUrl(uploads, 'image', value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => appendImageUploadsFromRequestValue(uploads, item));
|
||||
return;
|
||||
}
|
||||
const record = recordFromUnknown(value);
|
||||
if (!record) return;
|
||||
appendMediaRunUploadUrl(uploads, 'image', firstString(record.url, nestedString(record.image_url, 'url'), record.image_url, record.imageUrl, record.path));
|
||||
}
|
||||
|
||||
function appendMediaRunUploadUrl(uploads: PlaygroundUpload[], kind: PlaygroundUpload['kind'], value: unknown, role?: PlaygroundUpload['role']) {
|
||||
const rawUrl = stringFromUnknown(value);
|
||||
if (!rawUrl) return;
|
||||
const url = resolveApiAssetUrl(rawUrl);
|
||||
uploads.push({
|
||||
contentType: '',
|
||||
id: `${kind}-${uploads.length}-${url}`,
|
||||
kind,
|
||||
name: `${uploadKindTitle(kind)} ${uploads.filter((item) => item.kind === kind).length + 1}`,
|
||||
raw: {},
|
||||
role,
|
||||
size: 0,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeMediaRunUploads(uploads: PlaygroundUpload[]) {
|
||||
const seen = new Set<string>();
|
||||
return uploads.filter((item) => {
|
||||
const key = `${item.kind}:${item.url}:${item.role ?? ''}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeMediaRunUploads(uploads: unknown[]): PlaygroundUpload[] {
|
||||
return uploads
|
||||
.map((item, index) => sanitizeMediaRunUpload(item, index))
|
||||
.filter((item): item is PlaygroundUpload => Boolean(item));
|
||||
}
|
||||
|
||||
function sanitizeMediaRunUpload(value: unknown, index: number): PlaygroundUpload | undefined {
|
||||
const record = recordFromUnknown(value);
|
||||
if (!record) return undefined;
|
||||
const url = stringFromUnknown(record.url);
|
||||
const kind = uploadKindFromUnknown(record.kind);
|
||||
if (!url || !kind) return undefined;
|
||||
const size = numericFromUnknown(record.size);
|
||||
return {
|
||||
contentType: stringFromUnknown(record.contentType),
|
||||
id: stringFromUnknown(record.id) || `${kind}-${index}-${url}`,
|
||||
kind,
|
||||
name: stringFromUnknown(record.name) || `${uploadKindTitle(kind)} ${index + 1}`,
|
||||
raw: recordFromUnknown(record.raw) ?? {},
|
||||
role: record.role === 'first_frame' || record.role === 'last_frame' ? record.role : undefined,
|
||||
size: size && size > 0 ? Math.round(size) : 0,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
function numericFromUnknown(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function uploadKindFromUnknown(value: unknown): PlaygroundUpload['kind'] | undefined {
|
||||
if (value === 'audio' || value === 'file' || value === 'image' || value === 'video') return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function uploadKindTitle(kind: PlaygroundUpload['kind']) {
|
||||
if (kind === 'image') return '图像';
|
||||
if (kind === 'video') return '视频';
|
||||
if (kind === 'audio') return '音频';
|
||||
return '文件';
|
||||
}
|
||||
|
||||
function uploadKindEnglish(kind: PlaygroundUpload['kind']) {
|
||||
if (kind === 'image') return 'image';
|
||||
if (kind === 'video') return 'video';
|
||||
if (kind === 'audio') return 'audio';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function uploadKindIndex(item: PlaygroundUpload, uploads: PlaygroundUpload[]) {
|
||||
const sameKind = uploads.filter((upload) => upload.kind === item.kind);
|
||||
return Math.max(1, sameKind.findIndex((upload) => upload.id === item.id) + 1);
|
||||
}
|
||||
|
||||
function inferVideoModeFromUploads(uploads: PlaygroundUpload[]): VideoCreateMode {
|
||||
if (uploads.some((item) => item.role === 'first_frame' || item.role === 'last_frame')) return 'first_last_frame';
|
||||
if (uploads.length > 0) return 'omni_reference';
|
||||
return 'text_to_video';
|
||||
}
|
||||
|
||||
function readStoredMediaRuns(): MediaGenerationRun[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
@ -796,7 +1022,9 @@ function writeStoredMediaRuns(runs: MediaGenerationRun[]) {
|
||||
window.localStorage.removeItem(MEDIA_RUNS_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
const storedRuns = sortMediaRunsByCreatedAt(runs).slice(-MEDIA_RUNS_STORAGE_LIMIT);
|
||||
const storedRuns = sortMediaRunsByCreatedAt(runs)
|
||||
.slice(-MEDIA_RUNS_STORAGE_LIMIT)
|
||||
.map((run) => ({ ...run, uploads: sanitizeMediaRunUploads(run.uploads ?? []) }));
|
||||
window.localStorage.setItem(MEDIA_RUNS_STORAGE_KEY, JSON.stringify({
|
||||
runs: storedRuns,
|
||||
version: 1,
|
||||
@ -823,6 +1051,7 @@ function mediaRunFromStorage(value: unknown, index: number): MediaGenerationRun
|
||||
status = 'failed';
|
||||
error = error || '任务提交已中断,请重新生成。';
|
||||
}
|
||||
const uploads = sanitizeMediaRunUploads(Array.isArray(record.uploads) ? record.uploads : []);
|
||||
return {
|
||||
createdAt,
|
||||
error,
|
||||
@ -834,6 +1063,8 @@ function mediaRunFromStorage(value: unknown, index: number): MediaGenerationRun
|
||||
settings: mediaSettingsFromStorage(record.settings),
|
||||
status,
|
||||
task,
|
||||
uploads,
|
||||
videoMode: mode === 'video' ? videoModeFromStorage(record.videoMode, uploads) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -858,6 +1089,11 @@ function mediaSettingsFromStorage(value: unknown): MediaGenerationSettings {
|
||||
};
|
||||
}
|
||||
|
||||
function videoModeFromStorage(value: unknown, uploads: PlaygroundUpload[]): VideoCreateMode {
|
||||
if (value === 'text_to_video' || value === 'first_last_frame' || value === 'omni_reference') return value;
|
||||
return inferVideoModeFromUploads(uploads);
|
||||
}
|
||||
|
||||
function countPresetFromStorage(value: unknown, fallback: MediaGenerationSettings['countPreset']) {
|
||||
if (value === 'custom') return value;
|
||||
const numeric = Number(value);
|
||||
@ -880,6 +1116,11 @@ function stringFromUnknown(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function nestedString(value: unknown, key: string) {
|
||||
const record = recordFromUnknown(value);
|
||||
return record ? stringFromUnknown(record[key]) : '';
|
||||
}
|
||||
|
||||
function dateStringFromUnknown(value: unknown) {
|
||||
const raw = stringFromUnknown(value);
|
||||
if (!raw) return undefined;
|
||||
|
||||
@ -5,16 +5,19 @@ import Slider from 'antd/es/slider';
|
||||
import {
|
||||
Download,
|
||||
Edit3,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Images,
|
||||
Link2,
|
||||
LoaderCircle,
|
||||
Music2,
|
||||
Sparkles,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
import { resolveApiAssetUrl } from '../api';
|
||||
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '../components/ui';
|
||||
import type { PlaygroundMode } from '../types';
|
||||
import type { PlaygroundUpload, PlaygroundUploadKind, PlaygroundVideoCreateMode } from './playground-upload';
|
||||
|
||||
export type MediaOutputMode = 'single' | 'group';
|
||||
export type MediaCountPreset = 1 | 2 | 3 | 4 | 'custom';
|
||||
@ -46,6 +49,8 @@ export interface MediaGenerationRun {
|
||||
settings: MediaGenerationSettings;
|
||||
status: GatewayTask['status'] | 'submitting';
|
||||
task?: GatewayTask;
|
||||
uploads?: PlaygroundUpload[];
|
||||
videoMode?: PlaygroundVideoCreateMode;
|
||||
}
|
||||
|
||||
export function gatewayTaskErrorText(task: GatewayTask | undefined, fallback = '任务失败') {
|
||||
@ -513,20 +518,40 @@ function MediaTaskCard(props: {
|
||||
const isPending = props.run.status === 'submitting' || props.run.status === 'queued' || props.run.status === 'running';
|
||||
const backdropItem = expectedCount === 1 && items[0]?.type === 'image' ? items[0] : undefined;
|
||||
const errorText = mediaRunErrorText(props.run);
|
||||
const references = mediaReferenceItems(props.run);
|
||||
const promptParts = promptDisplayParts(props.run.prompt, references);
|
||||
const taskMeta = mediaTaskMetaText(props.run);
|
||||
|
||||
return (
|
||||
<article className="mediaTaskItem" data-status={props.run.status}>
|
||||
<header className="mediaTaskHeader">
|
||||
<div>
|
||||
<p>
|
||||
<span>{props.run.prompt}</span>
|
||||
<small>{props.run.mode === 'video' ? '视频' : '图片'} {props.run.modelLabel} {props.run.settings.aspectRatio} {props.run.settings.resolution}</small>
|
||||
</p>
|
||||
<time dateTime={props.run.createdAt}>{formatRunTime(props.run.createdAt)}</time>
|
||||
<div className="mediaTaskHeaderMain">
|
||||
{references.length > 0 && <MediaTaskReferenceStack references={references} />}
|
||||
<div className="mediaTaskHeaderContent">
|
||||
<div className="mediaTaskMetaLine">
|
||||
<Sparkles size={16} />
|
||||
<time dateTime={props.run.createdAt}>{formatRunDateTime(props.run.createdAt)}</time>
|
||||
<span aria-hidden="true">|</span>
|
||||
<strong>{props.run.modelLabel}</strong>
|
||||
<span>{props.run.mode === 'video' ? '视频生成' : references.length ? '图像编辑' : '图像生成'}</span>
|
||||
<span aria-hidden="true">|</span>
|
||||
<span>{taskMeta}</span>
|
||||
</div>
|
||||
<div className="mediaTaskPromptLine">
|
||||
<span className="mediaTaskPromptText">{promptParts}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="mediaTaskStatus" data-status={props.run.status}>{status}</span>
|
||||
</header>
|
||||
|
||||
{errorText && (
|
||||
<div className="mediaTaskError">
|
||||
<strong>错误详情</strong>
|
||||
<span>{errorText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mediaPreviewStage" data-count={expectedCount}>
|
||||
{backdropItem && <img aria-hidden="true" className="mediaPreviewBackdrop" src={backdropItem.src} alt="" />}
|
||||
<div className="mediaGrid" data-count={expectedCount} style={style}>
|
||||
@ -543,12 +568,6 @@ function MediaTaskCard(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorText && (
|
||||
<div className="mediaTaskError">
|
||||
<strong>错误详情</strong>
|
||||
<span>{errorText}</span>
|
||||
</div>
|
||||
)}
|
||||
<footer className="mediaTaskActions">
|
||||
{items[0] ? (
|
||||
<Button asChild size="sm" variant="secondary">
|
||||
@ -580,6 +599,57 @@ function MediaTaskCard(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTaskReferenceStack(props: { references: PlaygroundUpload[] }) {
|
||||
const visibleReferences = props.references.slice(0, 8);
|
||||
const overflowCount = props.references.length - visibleReferences.length;
|
||||
return (
|
||||
<span
|
||||
className="mediaTaskReferenceStack"
|
||||
style={{ '--reference-count': Math.max(1, visibleReferences.length) } as CSSProperties}
|
||||
title={props.references.map((item) => item.name).join('\n')}
|
||||
>
|
||||
{visibleReferences.map((item, index) => (
|
||||
<span
|
||||
className="mediaTaskReferenceCard"
|
||||
data-kind={item.kind}
|
||||
key={`${item.id}-${index}`}
|
||||
style={taskReferenceCardStyle(index)}
|
||||
>
|
||||
<MediaTaskReferencePreview item={item} />
|
||||
<small>{referenceKindLabel(item.kind)}</small>
|
||||
</span>
|
||||
))}
|
||||
{overflowCount > 0 && (
|
||||
<span className="mediaTaskReferenceOverflow">+{overflowCount}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTaskReferencePreview(props: { item: PlaygroundUpload }) {
|
||||
if (props.item.kind === 'image') {
|
||||
return <img src={props.item.url} alt="" draggable={false} />;
|
||||
}
|
||||
if (props.item.kind === 'video') {
|
||||
return <video src={props.item.url} muted playsInline preload="metadata" />;
|
||||
}
|
||||
if (props.item.kind === 'audio') {
|
||||
return <Music2 size={16} />;
|
||||
}
|
||||
return <FileText size={16} />;
|
||||
}
|
||||
|
||||
function PromptResourceTag(props: { item: PlaygroundUpload; references: PlaygroundUpload[] }) {
|
||||
return (
|
||||
<span className="mediaPromptResourceTag" contentEditable={false}>
|
||||
<span className="mediaPromptResourceThumb">
|
||||
<MediaTaskReferencePreview item={props.item} />
|
||||
</span>
|
||||
<span>{mediaReferenceMentionLabel(props.item, props.references)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTile(props: {
|
||||
expectedCount: number;
|
||||
index: number;
|
||||
@ -628,6 +698,193 @@ function mediaRunErrorText(run: MediaGenerationRun) {
|
||||
return gatewayTaskErrorText(run.task, '') || run.error || '';
|
||||
}
|
||||
|
||||
const mediaResourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
|
||||
const taskReferenceTiltValues = [-10, 8, -5, 9, -7, 5, -8, 6];
|
||||
const taskReferenceYValues = [0, 3, -1, 2, -2, 4, 1, -3];
|
||||
|
||||
function promptDisplayParts(raw: string, references: PlaygroundUpload[]): ReactNode[] {
|
||||
if (!raw.includes('<<<playground-resource:')) return [raw];
|
||||
const parts: ReactNode[] = [];
|
||||
const byId = new Map(references.map((item) => [item.id, item]));
|
||||
const usedIds = new Set<string>();
|
||||
let fallbackIndex = 0;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(mediaResourceTokenPattern);
|
||||
while ((match = re.exec(raw)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(raw.slice(lastIndex, match.index));
|
||||
}
|
||||
const id = match[1] ?? '';
|
||||
let item = byId.get(id);
|
||||
if (item) {
|
||||
usedIds.add(item.id);
|
||||
} else {
|
||||
while (fallbackIndex < references.length && usedIds.has(references[fallbackIndex]!.id)) {
|
||||
fallbackIndex += 1;
|
||||
}
|
||||
item = references[fallbackIndex];
|
||||
fallbackIndex += 1;
|
||||
if (item) usedIds.add(item.id);
|
||||
}
|
||||
if (item) {
|
||||
parts.push(<PromptResourceTag item={item} references={references} key={`${match.index}-${item.id}`} />);
|
||||
} else {
|
||||
parts.push('@资产');
|
||||
}
|
||||
lastIndex = match.index + (match[0]?.length ?? 0);
|
||||
}
|
||||
if (lastIndex < raw.length) {
|
||||
parts.push(raw.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function taskReferenceCardStyle(index: number) {
|
||||
const valueIndex = index % taskReferenceTiltValues.length;
|
||||
return {
|
||||
'--reference-index': index,
|
||||
'--reference-tilt': `${taskReferenceTiltValues[valueIndex]}deg`,
|
||||
'--reference-y': `${taskReferenceYValues[valueIndex]}px`,
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function mediaReferenceMentionLabel(item: PlaygroundUpload, references: PlaygroundUpload[]) {
|
||||
return `@${promptReferenceKindLabel(item.kind)}${referenceKindIndex(item, references)}`;
|
||||
}
|
||||
|
||||
function referenceKindIndex(item: PlaygroundUpload, references: PlaygroundUpload[]) {
|
||||
const sameKind = references.filter((reference) => reference.kind === item.kind);
|
||||
return Math.max(1, sameKind.findIndex((reference) => reference.id === item.id) + 1);
|
||||
}
|
||||
|
||||
function mediaReferenceItems(run: MediaGenerationRun): PlaygroundUpload[] {
|
||||
const uploads = normalizeReferenceUploads(run.uploads);
|
||||
if (uploads.length) return uploads;
|
||||
return referencesFromTaskRequest(run.task?.request, run.mode);
|
||||
}
|
||||
|
||||
function normalizeReferenceUploads(value: unknown): PlaygroundUpload[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item, index) => normalizeReferenceUpload(item, index))
|
||||
.filter((item): item is PlaygroundUpload => Boolean(item));
|
||||
}
|
||||
|
||||
function normalizeReferenceUpload(value: unknown, index: number): PlaygroundUpload | undefined {
|
||||
const record = recordFromUnknown(value);
|
||||
if (!record) return undefined;
|
||||
const url = stringFromUnknown(record.url);
|
||||
const kind = referenceKindFromUnknown(record.kind, url);
|
||||
if (!url || !kind) return undefined;
|
||||
const size = numberFromUnknown(record.size);
|
||||
return {
|
||||
contentType: stringFromUnknown(record.contentType),
|
||||
id: stringFromUnknown(record.id) || `${kind}-${index}-${url}`,
|
||||
kind,
|
||||
name: stringFromUnknown(record.name) || `${referenceKindLabel(kind)} ${index + 1}`,
|
||||
raw: recordFromUnknown(record.raw) ?? {},
|
||||
role: record.role === 'first_frame' || record.role === 'last_frame' ? record.role : undefined,
|
||||
size: size && size > 0 ? Math.round(size) : 0,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
function referencesFromTaskRequest(request: unknown, mode: Exclude<PlaygroundMode, 'chat'>): PlaygroundUpload[] {
|
||||
const record = recordFromUnknown(request);
|
||||
if (!record) return [];
|
||||
const references: PlaygroundUpload[] = [];
|
||||
if (Array.isArray(record.content)) {
|
||||
record.content.forEach((item) => {
|
||||
const content = recordFromUnknown(item);
|
||||
if (!content) return;
|
||||
appendReferenceFromContentPart(references, content);
|
||||
});
|
||||
}
|
||||
if (mode === 'image') {
|
||||
appendImageReferencesFromValue(references, record.image);
|
||||
appendImageReferencesFromValue(references, record.images);
|
||||
appendImageReferencesFromValue(references, record.input_image);
|
||||
appendImageReferencesFromValue(references, record.input_images);
|
||||
}
|
||||
return dedupeReferenceUploads(references);
|
||||
}
|
||||
|
||||
function appendReferenceFromContentPart(references: PlaygroundUpload[], part: Record<string, unknown>) {
|
||||
const type = stringFromUnknown(part.type);
|
||||
if (type === 'image_url') {
|
||||
appendReferenceUrl(references, 'image', firstString(nestedString(part.image_url, 'url'), part.url));
|
||||
return;
|
||||
}
|
||||
if (type === 'video_url') {
|
||||
appendReferenceUrl(references, 'video', firstString(nestedString(part.video_url, 'url'), part.url));
|
||||
return;
|
||||
}
|
||||
if (type === 'audio_url') {
|
||||
appendReferenceUrl(references, 'audio', firstString(nestedString(part.audio_url, 'url'), part.url));
|
||||
}
|
||||
}
|
||||
|
||||
function appendImageReferencesFromValue(references: PlaygroundUpload[], value: unknown) {
|
||||
if (!value) return;
|
||||
if (typeof value === 'string') {
|
||||
appendReferenceUrl(references, 'image', value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => appendImageReferencesFromValue(references, item));
|
||||
return;
|
||||
}
|
||||
const record = recordFromUnknown(value);
|
||||
if (!record) return;
|
||||
appendReferenceUrl(references, 'image', firstString(record.url, nestedString(record.image_url, 'url'), record.image_url, record.imageUrl, record.path));
|
||||
}
|
||||
|
||||
function appendReferenceUrl(references: PlaygroundUpload[], kind: PlaygroundUploadKind, rawUrl: unknown) {
|
||||
const url = stringFromUnknown(rawUrl);
|
||||
if (!url) return;
|
||||
const resolvedUrl = resolveApiAssetUrl(url);
|
||||
references.push({
|
||||
contentType: '',
|
||||
id: `${kind}-${references.length}-${resolvedUrl}`,
|
||||
kind,
|
||||
name: `${referenceKindLabel(kind)} ${references.filter((item) => item.kind === kind).length + 1}`,
|
||||
raw: {},
|
||||
size: 0,
|
||||
url: resolvedUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeReferenceUploads(references: PlaygroundUpload[]) {
|
||||
const seen = new Set<string>();
|
||||
return references.filter((item) => {
|
||||
const key = `${item.kind}:${item.url}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function referenceKindFromUnknown(value: unknown, url: string): PlaygroundUploadKind | undefined {
|
||||
if (value === 'image' || value === 'video' || value === 'audio' || value === 'file') return value;
|
||||
if (/\.(png|jpe?g|webp|gif|bmp|avif|svg)(\?|#|$)/i.test(url)) return 'image';
|
||||
if (/\.(mp4|mov|webm|m4v|avi|mkv)(\?|#|$)/i.test(url)) return 'video';
|
||||
if (/\.(mp3|m4a|wav|aac|flac|ogg|opus)(\?|#|$)/i.test(url)) return 'audio';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function referenceKindLabel(kind: PlaygroundUploadKind) {
|
||||
if (kind === 'image') return '图像';
|
||||
if (kind === 'video') return '视频';
|
||||
if (kind === 'audio') return '音频';
|
||||
return '文件';
|
||||
}
|
||||
|
||||
function promptReferenceKindLabel(kind: PlaygroundUploadKind) {
|
||||
if (kind === 'image') return '图片';
|
||||
return referenceKindLabel(kind);
|
||||
}
|
||||
|
||||
function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode, 'chat'>): MediaResultItem | undefined {
|
||||
const record = recordFromUnknown(entry);
|
||||
if (!record) return undefined;
|
||||
@ -660,8 +917,23 @@ function mediaStatusText(run: MediaGenerationRun) {
|
||||
return run.status;
|
||||
}
|
||||
|
||||
function formatRunTime(value: string) {
|
||||
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
||||
function formatRunDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
const pad = (item: number) => String(item).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function mediaTaskMetaText(run: MediaGenerationRun) {
|
||||
const items = [run.settings.aspectRatio, run.settings.resolution];
|
||||
if (run.mode === 'video') {
|
||||
items.push(`${run.settings.durationSeconds}s`);
|
||||
if (run.settings.outputAudio) items.push('有声音');
|
||||
} else {
|
||||
const count = mediaOutputCount(run.settings);
|
||||
if (count > 1) items.push(`${count}张`);
|
||||
}
|
||||
return items.filter(Boolean).join(' | ');
|
||||
}
|
||||
|
||||
function cssAspectRatio(settings: MediaGenerationSettings) {
|
||||
|
||||
@ -39,6 +39,28 @@ export function replacePlaygroundResourceTokens(
|
||||
});
|
||||
}
|
||||
|
||||
export function replacePlaygroundResourceTokensForDisplay(raw: string, uploads: PlaygroundMentionUpload[]) {
|
||||
if (!raw.includes(resourceTokenPrefix)) return raw;
|
||||
const byId = new Map(uploads.map((item) => [item.id, item]));
|
||||
const usedIds = new Set<string>();
|
||||
let fallbackIndex = 0;
|
||||
return raw.replace(resourceTokenPattern, (full, id: string) => {
|
||||
let item = byId.get(id);
|
||||
if (item) {
|
||||
usedIds.add(item.id);
|
||||
} else {
|
||||
while (fallbackIndex < uploads.length && usedIds.has(uploads[fallbackIndex]!.id)) {
|
||||
fallbackIndex += 1;
|
||||
}
|
||||
item = uploads[fallbackIndex];
|
||||
fallbackIndex += 1;
|
||||
if (item) usedIds.add(item.id);
|
||||
}
|
||||
if (!item) return '@资产';
|
||||
return mentionDisplayLabel(item, uploads);
|
||||
});
|
||||
}
|
||||
|
||||
export function PlaygroundPromptMentionInput(props: {
|
||||
disabled?: boolean;
|
||||
placeholder: string;
|
||||
@ -68,7 +90,7 @@ export function PlaygroundPromptMentionInput(props: {
|
||||
const mentionItems = useMemo(() => props.uploads.map((item, index) => ({
|
||||
item,
|
||||
label: mentionDisplayLabel(item, props.uploads),
|
||||
searchText: `${item.name} ${mentionDisplayLabel(item, props.uploads)} ${uploadKindChinese(item.kind)} ${index + 1}`.toLowerCase(),
|
||||
searchText: `${item.name} ${mentionDisplayLabel(item, props.uploads)} ${uploadKindChinese(item.kind)} 资产 素材 resource asset ${index + 1}`.toLowerCase(),
|
||||
token: buildPlaygroundResourceToken(item.id),
|
||||
})), [props.uploads]);
|
||||
|
||||
@ -85,22 +107,26 @@ export function PlaygroundPromptMentionInput(props: {
|
||||
}
|
||||
setText(props.value);
|
||||
setHasEditableContent(promptTextHasContent(props.value));
|
||||
if (!focused) {
|
||||
if (!isComposingRef.current) {
|
||||
requestAnimationFrame(() => renderToEditable(props.value));
|
||||
}
|
||||
}, [focused, props.value, text]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleaned = removeInvalidPlaygroundResourceTokens(text, props.uploads);
|
||||
if (cleaned !== text) {
|
||||
const cleaned = removeInvalidPlaygroundResourceTokens(props.value, props.uploads);
|
||||
if (cleaned !== props.value) {
|
||||
setText(cleaned);
|
||||
setHasEditableContent(promptTextHasContent(cleaned));
|
||||
props.onChange(cleaned);
|
||||
requestAnimationFrame(() => renderToEditable(cleaned));
|
||||
return;
|
||||
}
|
||||
if (props.value !== text) {
|
||||
setText(props.value);
|
||||
setHasEditableContent(promptTextHasContent(props.value));
|
||||
}
|
||||
if (!focused) {
|
||||
requestAnimationFrame(() => renderToEditable(text));
|
||||
requestAnimationFrame(() => renderToEditable(props.value));
|
||||
}
|
||||
}, [uploadSignature]);
|
||||
|
||||
|
||||
@ -1831,37 +1831,220 @@
|
||||
|
||||
.mediaTaskHeader {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mediaTaskHeader > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
.mediaTaskHeaderMain {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mediaTaskHeader p {
|
||||
.mediaTaskHeaderContent {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mediaTaskMetaLine {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #7a8794;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.mediaTaskHeader p span {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
.mediaTaskMetaLine svg {
|
||||
flex: 0 0 auto;
|
||||
color: #607080;
|
||||
}
|
||||
|
||||
.mediaTaskMetaLine time,
|
||||
.mediaTaskMetaLine strong,
|
||||
.mediaTaskMetaLine span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mediaTaskMetaLine strong {
|
||||
color: var(--text-normal);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.mediaTaskHeader small,
|
||||
.mediaTaskPromptLine {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mediaTaskPromptText {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1.65;
|
||||
max-height: 4.95em;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mediaTaskPromptLine:hover {
|
||||
z-index: 13;
|
||||
}
|
||||
|
||||
.mediaTaskPromptLine:hover .mediaTaskPromptText {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mediaPromptResourceTag {
|
||||
display: inline-flex;
|
||||
max-width: min(220px, 86vw);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 5px 3px;
|
||||
padding: 2px 8px 2px 4px;
|
||||
border-radius: 7px;
|
||||
background: #438ce9;
|
||||
color: #fff;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1.25;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.mediaPromptResourceThumb {
|
||||
display: inline-grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.mediaPromptResourceThumb img,
|
||||
.mediaPromptResourceThumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mediaPromptResourceThumb svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.mediaTaskHeader time {
|
||||
color: #7a8794;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.mediaTaskReferenceStack {
|
||||
--task-reference-width: 54px;
|
||||
--task-reference-height: 72px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
width: var(--task-reference-width);
|
||||
height: var(--task-reference-height);
|
||||
margin: 2px 2px 2px 0;
|
||||
vertical-align: middle;
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.mediaTaskReferenceStack:hover {
|
||||
z-index: 14;
|
||||
width: min(calc(var(--reference-count) * 58px), calc(100vw - 96px));
|
||||
}
|
||||
|
||||
.mediaTaskReferenceCard {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(var(--reference-y, 0px));
|
||||
display: grid;
|
||||
width: var(--task-reference-width);
|
||||
height: var(--task-reference-height);
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.86);
|
||||
border-radius: 7px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--primary);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.2);
|
||||
transform: rotate(var(--reference-tilt, -8deg));
|
||||
transition: left 180ms ease, top 180ms ease, transform 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.mediaTaskReferenceStack:hover .mediaTaskReferenceCard {
|
||||
left: calc(var(--reference-index) * 58px);
|
||||
top: 0;
|
||||
transform: rotate(var(--reference-tilt, -5deg));
|
||||
}
|
||||
|
||||
.mediaTaskReferenceCard:hover {
|
||||
z-index: 6;
|
||||
transform: translateY(-5px) scale(1.04) rotate(0deg) !important;
|
||||
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.mediaTaskReferenceCard img,
|
||||
.mediaTaskReferenceCard video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mediaTaskReferenceCard svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.mediaTaskReferenceCard small {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(24, 24, 27, 0.68);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.mediaTaskReferenceCard[data-kind="audio"],
|
||||
.mediaTaskReferenceCard[data-kind="file"] {
|
||||
background: linear-gradient(135deg, #f7fbff, #e7edf5);
|
||||
}
|
||||
|
||||
.mediaTaskReferenceOverflow {
|
||||
position: absolute;
|
||||
right: -9px;
|
||||
bottom: -7px;
|
||||
z-index: 8;
|
||||
display: inline-grid;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
place-items: center;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--text-strong);
|
||||
color: var(--surface);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.mediaTaskStatus {
|
||||
flex: 0 0 auto;
|
||||
min-height: 28px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user