feat(*): 短剧创作工作台 - 完善章节分镜、资产管理、技能运行与生成任务恢复

This commit is contained in:
ddCat committed 2026-08-29 23:34:55 +08:00
1 parent 1293ba836e
commit 40fd0c54a4
113 files changed
+7016 -723

No files matched your search

@@ -34,7 +34,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
category: asset.category || "other",
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
asset,
searchText: asset.tags.join(" "),
searchText: (asset.tags || []).join(" "),
})),
...externalAssetSources.items,
], [externalAssetSources.items, insertableAssets]);
@@ -34,15 +34,15 @@ import { canvasAgentPostconditionMessage, previewCanvasAgentOps, summarizeCanvas
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { canvasAgentPromptCacheKey } from "@/lib/openai-prompt-cache";
import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context";
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
import { buildCanvasWorkflowOps, looksLikeWorkflowRequest, type CanvasWorkflowInput } from "@/lib/canvas/canvas-agent-workflow";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { buildSkillMentionReferences, SKILL_RUNTIME_AGENT_GUIDANCE, skillRuntime } from "@/services/skill-runtime";
export const CANVAS_AGENT_PANEL_MOTION_MS = 500;
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
const ONLINE_AGENT_MAX_STEPS = 4;
const ONLINE_AGENT_MAX_STEPS = 8;
const ONLINE_AGENT_PROMPT =
"你是影策网页内置在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。技能不是被拼进用户消息的提示词:用户提及技能时,先用 canvas_get_skill 按 id 或名称加载该技能,再按工具返回的技能契约执行。";
`你是影策网页内置在线画布助手。首轮必须先调用 canvas_get_context;涉及已有节点时用 canvas_find_nodes 获取真实 id,涉及媒体参考时用 canvas_get_resources。流水线、工作流、管线、节点图或用户要求连线时,必须使用 canvas_create_workflow:把需求拆成有语义的节点类型、真实内容/提示词、边和布局,禁止把业务阶段退化成几个空文本卡片;工具会自动分配 id、布局并建立连线。复杂写操作先 canvas_validate_ops,再执行 canvas_apply_ops。任何写入后都必须检查工具返回的真实节点类型、connectionCount、overlapWarnings 和 verification;没有真实连线时绝不能说已连线,没有生成资源时绝不能说已完成。不要输出 JSON ops、不要猜 id、不要把未就绪资源当作可用素材、不要编造执行结果。需要用户选择时,给出可点击的短选项,不要只让用户输入 1、2、3。${SKILL_RUNTIME_AGENT_GUIDANCE}`;
const JSON_RECORD_SCHEMA = { type: "object", additionalProperties: true };
const POSITION_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], additionalProperties: false };
const VIEWPORT_SCHEMA = { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, k: { type: "number" } }, required: ["x", "y", "k"], additionalProperties: false };
@@ -91,7 +91,7 @@ const CANVAS_OP_SCHEMA = {
required: ["type"],
additionalProperties: false,
};
const ONLINE_READ_TOOLS = new Set(["canvas_list_skills", "canvas_get_skill", "canvas_get_state", "canvas_get_context", "canvas_find_nodes", "canvas_get_node", "canvas_get_connection", "canvas_get_generation_tasks", "canvas_get_resources", "canvas_validate_ops", "canvas_get_selection", "canvas_export_snapshot"]);
const ONLINE_READ_TOOLS = new Set([...skillRuntime.agentToolNames("onlineAgent"), "canvas_get_state", "canvas_get_context", "canvas_find_nodes", "canvas_get_node", "canvas_get_connection", "canvas_get_generation_tasks", "canvas_get_resources", "canvas_validate_ops", "canvas_get_selection", "canvas_export_snapshot"]);
function toolDefinition(name: string, description: string, properties: Record<string, unknown>, required: string[] = [], strict = false): ResponseFunctionTool {
return { type: "function", function: { name, description, parameters: { type: "object", properties, required, additionalProperties: false }, strict } };
@@ -116,8 +116,7 @@ function generationToolDefinition(name: string, description: string, mode?: "tex
}
const ONLINE_AGENT_TOOLS: ResponseFunctionTool[] = [
toolDefinition("canvas_list_skills", "列出当前用户已加入、可按需加载的画布技能;只返回元数据,不返回完整指令。", {}),
toolDefinition("canvas_get_skill", "按 skillId 或技能名称按需加载一个画布技能的完整契约。技能正文通过工具结果提供,不会自动注入每条用户消息。", { skillId: { type: "string" }, name: { type: "string" } }),
...skillRuntime.agentTools("onlineAgent"),
toolDefinition("canvas_get_state", "读取当前网页画布的节点、连线、选区和视口。", {}),
toolDefinition("canvas_get_context", "读取语义化画布上下文、真实节点 id、连接关系、资源就绪状态和状态哈希。", {}),
toolDefinition("canvas_find_nodes", "按标题、内容、提示词、类型、状态或资产检索真实节点。", { query: { type: "string" }, ids: { type: "array", items: { type: "string" } }, types: { type: "array", items: { type: "string" } }, statuses: { type: "array", items: { type: "string" } }, resourceOnly: { type: "boolean" }, limit: { type: "number" } }),
@@ -779,21 +778,8 @@ export function CanvasAssistantPanel({
if (expectedRevision !== undefined && expectedRevision !== (current.revision ?? 0)) return { ok: false, message: "画布 revision 已变化,请重新读取 canvas_get_context 后再执行写操作。" };
const expectedStateHash = typeof args.expectedStateHash === "string" ? args.expectedStateHash : "";
if (expectedStateHash && expectedStateHash !== buildCanvasAgentContext(current).stateHash) return { ok: false, message: "画布状态已变化,请重新读取 canvas_get_context 后再执行写操作。" };
if (name === "canvas_list_skills") {
const data = composerSkills.filter((skill) => skill.is_added).map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, tag: skill.tag }));
return { ok: true, message: data.length ? "已列出当前可用技能。" : "当前没有已加入技能。", data };
}
if (name === "canvas_get_skill") {
const skillId = typeof args.skillId === "string" ? args.skillId : "";
const nameQuery = typeof args.name === "string" ? args.name.trim().toLocaleLowerCase() : "";
const skill = composerSkills.find((item) => item.is_added && (item.skill_id === skillId || item.skill_name.toLocaleLowerCase() === nameQuery));
if (!skill) return { ok: false, message: "未找到已加入的技能,请先调用 canvas_list_skills。" };
return {
ok: true,
message: `已按需加载技能「${skill.skill_name}」。`,
data: { skillId: skill.skill_id, name: skill.skill_name, description: skill.description, instruction: skill.instruction || skill.description, version: skill.update_time },
};
}
const skillToolResult = await skillRuntime.executeAgentTool("onlineAgent", name, args, composerSkills);
if (skillToolResult) return skillToolResult;
if (name === "canvas_get_state") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) };
if (name === "canvas_get_context") return { ok: true, message: "已读取语义化画布上下文。", data: buildCanvasAgentContext(current) };
if (name === "canvas_find_nodes") return { ok: true, message: "已按条件检索真实节点。", data: findCanvasAgentNodes(current, args as Parameters<typeof findCanvasAgentNodes>[1]) };
@@ -1701,7 +1687,10 @@ function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgent
function toolCallLabel(name: string) {
if (name === "canvas_list_skills") return "列出技能";
if (name === "canvas_get_skill") return "加载技能";
if (name === "canvas_get_skill") return "读取技能入口";
if (name === "canvas_list_skill_files") return "列出技能文件";
if (name === "canvas_read_skill_file") return "读取技能文件";
if (name === "canvas_search_skill_files") return "搜索技能文件";
if (name === "canvas_apply_ops") return "画布操作";
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_context") return "读取上下文";
@@ -1913,7 +1902,7 @@ async function buildToolAgentMessages(snapshot: CanvasAgentSnapshot, history: Ca
const skillCatalog = skills
.filter((skill) => skill.is_added)
.slice(0, 40)
.map((skill) => `- ${skill.skill_name}(${skill.skill_id}):${skill.description}`)
.map((skill) => `- ${skill.skill_name}(${skill.skill_id},v${skill.version || "1"},${skill.file_count || 1} 文件):${skill.description}`)
.join("\n");
const systemContent = [ONLINE_AGENT_PROMPT, skillCatalog ? `当前可按需加载的技能(仅元数据):\n${skillCatalog}` : ""].filter(Boolean).join("\n\n");
return [
@@ -116,9 +116,9 @@ export function CanvasNodeContextMenu({
}, [categoryOpen, onClose]);
useEffect(() => {
setAddOpen(false);
setAddOpen(menu.type === "canvas" && Boolean(menu.createOpen));
setCategoryOpen(false);
}, [menu.type, menu.x, menu.y]);
}, [menu]);
const runAction = (action: () => void) => {
action();
@@ -26,8 +26,8 @@ import { canvasAgentPostconditionMessage, hashCanvasAgentSnapshot, previewCanvas
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
import { buildLocalAgentSetupCommands, detectLocalAgentSetupPlatform, type LocalAgentSetupPlatform } from "@/lib/canvas/local-agent-setup";
import { resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { skillRuntime } from "@/services/skill-runtime";
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
import { AgentChatComposer, AgentChatMessage, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
@@ -36,6 +36,7 @@ import { AgentChatEmptyState } from "./canvas-agent-panel-chrome";
const PANEL_MOTION_SECONDS = 0.5;
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const MAX_AGENT_TURN_PAYLOAD_BYTES = 56 * 1024 * 1024;
type AgentEventPayload = {
agent?: string;
type?: string;
@@ -57,7 +58,7 @@ type AgentTurnPayload = {
canvasId: string;
threadId?: string;
attachments: Array<Pick<AgentAttachment, "name" | "type" | "dataUrl">>;
skills: Array<{ skillId: string; name: string; description: string; instruction: string }>;
skills: Array<{ skillId: string; name: string; description: string; version: string; files: Array<{ path: string; mimeType: string; contentBase64: string }> }>;
};
export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
@@ -386,20 +387,31 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
const sendPrompt = async (overrideText?: string) => {
const text = (overrideText ?? prompt).trim();
const files = attachments;
const mentionedSkills = resolveSkillMentions(text, composerSkills);
const requestPrompt = promptWithAttachments(text, files);
if (!connected || !requestPrompt || sending || waiting) return;
if (!connected || !promptWithAttachments(text, files) || sending || waiting) return;
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
return;
}
let skillExecution: Awaited<ReturnType<typeof skillRuntime.prepare<"localAgent">>>;
try {
skillExecution = await skillRuntime.prepare({ profile: "localAgent", prompt: text, skills: composerSkills });
} catch (error) {
addMessage({ role: "error", title: "技能加载失败", text: error instanceof Error ? error.message : "无法读取技能包" });
return;
}
const requestPrompt = promptWithAttachments(skillExecution.prompt, files);
const skillBundles: AgentTurnPayload["skills"] = skillExecution.skills;
if (attachmentPayloadBytes(files) + skillBundlePayloadBytes(skillBundles) > MAX_AGENT_TURN_PAYLOAD_BYTES) {
addMessage({ role: "error", title: "本轮内容过大", text: "图片与技能包合计超过本地 Agent 单轮限制,请减少附件或只选择一个技能。" });
return;
}
await submitTurn({
text,
prompt: requestPrompt,
canvasId: snapshotRef.current.projectId,
threadId: useCanvasAgentStore.getState().activeThreadId || undefined,
attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })),
skills: mentionedSkills.map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, instruction: skill.instruction || skill.description })),
skills: skillBundles,
}, true);
};
@@ -1385,6 +1397,10 @@ function attachmentPayloadBytes(attachments: AgentAttachment[]) {
return attachments.reduce((total, item) => total + item.dataUrl.length, 0);
}
function skillBundlePayloadBytes(skills: AgentTurnPayload["skills"]) {
return skills.reduce((total, skill) => total + skill.files.reduce((fileTotal, file) => fileTotal + file.contentBase64.length, 0), 0);
}
function formatBytes(bytes: number) {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(bytes / 1024)}KB`;
}
@@ -11,6 +11,7 @@ import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-refer
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { audioFileExtension } from "@/lib/character-voice-formats";
import type { ModelReferenceLimits } from "@/lib/model-selection";
import type { Asset } from "@/stores/use-asset-store";
@@ -433,7 +434,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
if (!context.characterReferences.length) return { ...context, referenceImages };
if (!domainProjectId) throw new Error("角色引用未关联短剧项目,无法解析角色版本");
const { getProjectCharacter } = await import("@/services/api/projects");
const { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
const { getResource, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
const details = await Promise.all(context.characterReferences.map((reference) => getProjectCharacter(domainProjectId, reference.assetId)));
const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length);
const selected = details.flatMap((detail) => {
@@ -491,13 +492,17 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
}
const maxAudios = referenceLimits?.maxAudios ?? 3;
if (context.referenceAudios.length + voiceSamples.length > maxAudios) throw new Error(`当前模型参考音频容量不足:已连接 ${context.referenceAudios.length} 个音频,角色声音样本还需要 ${voiceSamples.length} 个名额`);
const characterVoiceAudios = voiceSamples.map((voice) => ({
id: `character-voice-${voice.assetId}`,
name: `${voice.characterName}-声音样本.mp3`,
type: "audio/mpeg",
url: resourceFileUrl(voice.sampleResourceId!),
storageKey: resourceStorageKey(voice.sampleResourceId!),
} satisfies ReferenceAudio));
const characterVoiceAudios = await Promise.all(voiceSamples.map(async (voice) => {
const resource = await getResource(voice.sampleResourceId!);
const extension = audioFileExtension(resource.mimeType, resource.objectKey);
return {
id: `character-voice-${voice.assetId}`,
name: `${voice.characterName}-声音样本.${extension}`,
type: resource.mimeType || "audio/mpeg",
url: resourceFileUrl(voice.sampleResourceId!),
storageKey: resourceStorageKey(voice.sampleResourceId!),
} satisfies ReferenceAudio;
}));
const referenceAudios = [...context.referenceAudios, ...characterVoiceAudios];
const voiceBlocks = mode === "video" ? resolvedCharacterVoices.map(compileResolvedVoicePrompt) : [];
return {
@@ -49,7 +49,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags.join(" ") || "", project?.previewText || ""].join(" "),
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
};
}), [items]);
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
@@ -1,7 +1,7 @@
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, Sparkles, UserRound, Video } from "lucide-react";
import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, UserRound, Video, Workflow } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -234,8 +234,8 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
assetReferences={assetReferences}
filteredReferences={candidates}
query={mention.query}
cursorOffset={mention.end}
activeReferenceId={activeIndex >= 0 ? candidates[Math.min(activeIndex, candidates.length - 1)]?.id : undefined}
theme={theme}
preferredWidth={mentionMenuWidth}
onQueryChange={(query) => setMention((current) => current ? { ...current, query } : current)}
onClose={closeMention}
@@ -507,14 +507,14 @@ const ASSET_CATEGORY_LABELS: Record<AssetCategory, string> = {
other: "其他",
};
function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, activeReferenceId, theme, preferredWidth, onQueryChange, onClose, onSelect }: {
function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, cursorOffset, activeReferenceId, preferredWidth, onQueryChange, onClose, onSelect }: {
anchor: HTMLElement;
connectedReferences: CanvasResourceReference[];
assetReferences: CanvasResourceReference[];
filteredReferences: CanvasResourceReference[];
query: string;
cursorOffset: number;
activeReferenceId?: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
preferredWidth: number;
onQueryChange: (query: string) => void;
onClose: () => void;
@@ -523,14 +523,26 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
const menuRef = useRef<HTMLDivElement | null>(null);
const selectedRef = useRef(false);
const [category, setCategory] = useState<AssetCategory | null>(null);
const rect = anchor.getBoundingClientRect();
const boundary = anchor.closest(".ant-modal-container")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
const menuWidth = Math.min(preferredWidth, Math.max(0, boundary.right - boundary.left - 16));
const maxMenuHeight = Math.min(320, Math.max(0, boundary.bottom - boundary.top - 16));
const gap = 6;
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
const [position, setPosition] = useState(() => mentionMenuPosition(anchor, cursorOffset, preferredWidth));
useLayoutEffect(() => {
let frame = 0;
const updatePosition = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => setPosition(mentionMenuPosition(anchor, cursorOffset, preferredWidth)));
};
updatePosition();
const observer = new ResizeObserver(updatePosition);
observer.observe(anchor);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [anchor, cursorOffset, preferredWidth]);
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
event.stopPropagation();
@@ -566,7 +578,8 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
ref={menuRef}
data-canvas-resource-mention-menu="true"
className="canvas-resource-mention-menu fixed z-[var(--z-tooltip)]"
style={{ left, top, width: menuWidth, maxHeight: maxMenuHeight, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
data-placement={position.showAbove ? "top" : "bottom"}
style={{ left: position.left, top: position.top, width: position.width, maxHeight: position.maxHeight, transform: position.showAbove ? "translateY(-100%)" : undefined }}
onPointerDown={stopCanvasInteraction}
onMouseDown={stopCanvasInteraction}
onClick={(event) => event.stopPropagation()}
@@ -575,7 +588,7 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
<Search aria-hidden />
<input
value={query}
placeholder="搜索素材..."
placeholder="搜索节点、素材或技能"
aria-label="搜索引用素材"
onChange={(event) => onQueryChange(event.target.value)}
onPointerDown={(event) => event.stopPropagation()}
@@ -595,7 +608,7 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
</div>
<div className="canvas-resource-mention-scroll thin-scrollbar">
{query ? (
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
) : category ? (
<>
<button type="button" className="canvas-resource-mention-back" onClick={() => setCategory(null)}>
@@ -603,25 +616,25 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
<span>{ASSET_CATEGORY_LABELS[category]}</span>
<small>{visibleReferences.length}</small>
</button>
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
</>
) : (
<>
{connectedNodes.length ? (
<section className="canvas-resource-mention-section">
<h4>已连接节点</h4>
<MentionReferenceList references={connectedNodes} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
<h4><span>已连接节点</span><small>{connectedNodes.length}</small></h4>
<MentionReferenceList references={connectedNodes} activeReferenceId={activeReferenceId} onSelect={selectReference} />
</section>
) : null}
{skillReferences.length ? (
<section className="canvas-resource-mention-section">
<h4>技能库</h4>
<MentionReferenceList references={skillReferences} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
<h4><span>技能库</span><small>{skillReferences.length}</small></h4>
<MentionReferenceList references={skillReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
</section>
) : null}
{categoryItems.length ? (
<section className="canvas-resource-mention-section">
<h4>素材库</h4>
<h4><span>素材库</span><small>{assetReferences.length}</small></h4>
{categoryItems.map((item) => (
<button key={item.value} type="button" className="canvas-resource-mention-folder" onClick={() => setCategory(item.value)}>
<Folder aria-hidden />
@@ -640,14 +653,14 @@ function MentionMenu({ anchor, connectedReferences, assetReferences, filteredRef
);
}
function MentionReferenceList({ references, activeReferenceId, theme, onSelect }: { references: CanvasResourceReference[]; activeReferenceId?: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
if (!references.length) return <div className="canvas-resource-mention-empty">没有匹配的素材</div>;
function MentionReferenceList({ references, activeReferenceId, onSelect }: { references: CanvasResourceReference[]; activeReferenceId?: string; onSelect: (reference: CanvasResourceReference) => void }) {
if (!references.length) return <div className="canvas-resource-mention-empty">没有匹配的引用</div>;
return references.map((reference) => (
<button
key={reference.id}
type="button"
className="canvas-resource-mention-item"
style={{ background: reference.id === activeReferenceId ? theme.toolbar.activeBg : "transparent", color: reference.id === activeReferenceId ? theme.toolbar.activeText : theme.node.text }}
className={`canvas-resource-mention-item ${reference.kind === "skill" ? "is-skill" : ""} ${reference.id === activeReferenceId ? "is-active" : ""}`}
aria-selected={reference.id === activeReferenceId}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -660,29 +673,31 @@ function MentionReferenceList({ references, activeReferenceId, theme, onSelect }
}}
>
<ReferencePreview reference={reference} />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium" title={reference.label}>{reference.label}</span>
{reference.kind !== "skill" && reference.text && reference.text !== reference.title ? <span className="block truncate opacity-55">{reference.text}</span> : null}
<span className="canvas-resource-mention-copy">
<span className="canvas-resource-mention-title-row"><strong title={reference.label}>{reference.label}</strong>{reference.kind === "skill" ? <em>技能</em> : null}</span>
{reference.kind === "skill" ? (
<span className="canvas-resource-mention-meta"><span>{reference.skill?.description || reference.text || "工作流技能"}</span><small>{reference.skill?.version ? `v${reference.skill.version}` : ""}{reference.skill?.file_count ? ` · ${reference.skill.file_count} 文件` : ""}</small></span>
) : reference.text && reference.text !== reference.title ? <span className="canvas-resource-mention-meta"><span>{reference.text}</span></span> : null}
</span>
</button>
));
}
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-7 rounded-sm object-cover" />;
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-7 rounded-sm bg-black object-cover" muted preload="metadata" />;
if (reference.kind === "character" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-7 rounded-sm bg-black/5 object-contain" />;
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-image" />;
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="canvas-resource-mention-preview is-video" muted preload="metadata" />;
if (reference.kind === "character" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="canvas-resource-mention-preview is-character" />;
if (reference.kind === "skill") {
return (
<span className="grid size-7 shrink-0 place-items-center rounded-sm bg-cyan-500/10 text-cyan-600 dark:text-cyan-200">
<Sparkles className="size-3.5" />
<span className="canvas-resource-mention-preview is-skill">
<Workflow aria-hidden />
</span>
);
}
const Icon = reference.sourceType === CanvasNodeType.Drawing ? Pencil : reference.kind === "character" ? UserRound : reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
return (
<span className="grid size-7 shrink-0 place-items-center rounded-sm bg-black/10">
<Icon className="size-3.5" />
<span className="canvas-resource-mention-preview is-fallback">
<Icon aria-hidden />
</span>
);
}
@@ -823,6 +838,105 @@ function isMentionElement(node: Node): node is HTMLElement {
return node instanceof HTMLElement && Boolean(node.dataset.mentionToken);
}
type MentionAnchorRect = Pick<DOMRect, "left" | "right" | "top" | "bottom" | "width" | "height">;
function mentionMenuPosition(anchor: HTMLElement, cursorOffset: number, preferredWidth: number) {
const caret = mentionCaretRect(anchor, cursorOffset);
const boundary = anchor.closest(".ant-modal-container")?.getBoundingClientRect() || { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
const inset = 8;
const gap = 8;
const availableWidth = Math.max(0, boundary.right - boundary.left - inset * 2);
const width = Math.min(preferredWidth, availableWidth);
const left = clamp(caret.left, boundary.left + inset, boundary.right - width - inset);
const spaceAbove = Math.max(0, caret.top - boundary.top - gap - inset);
const spaceBelow = Math.max(0, boundary.bottom - caret.bottom - gap - inset);
const showAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
const maxHeight = Math.min(320, showAbove ? spaceAbove : spaceBelow);
const top = showAbove ? caret.top - gap : caret.bottom + gap;
return { left, top, width, maxHeight, showAbove };
}
function mentionCaretRect(anchor: HTMLElement, cursorOffset: number): MentionAnchorRect {
if (anchor instanceof HTMLTextAreaElement) return textareaCaretRect(anchor, cursorOffset);
const point = pointForOffset(anchor, cursorOffset);
const range = document.createRange();
range.setStart(point.node, point.offset);
range.collapse(true);
const rangeRect = range.getClientRects()[0] || range.getBoundingClientRect();
if (rangeRect && (rangeRect.height || rangeRect.width)) return rangeRect;
return fallbackCaretRect(anchor);
}
function textareaCaretRect(textarea: HTMLTextAreaElement, cursorOffset: number): MentionAnchorRect {
const rect = textarea.getBoundingClientRect();
const computed = window.getComputedStyle(textarea);
const mirror = document.createElement("div");
const marker = document.createElement("span");
const copiedProperties = [
"boxSizing",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"fontFamily",
"fontSize",
"fontStyle",
"fontVariant",
"fontWeight",
"fontStretch",
"lineHeight",
"letterSpacing",
"textAlign",
"textIndent",
"textTransform",
"wordSpacing",
"tabSize",
"wordBreak",
"overflowWrap",
] as const;
copiedProperties.forEach((property) => {
mirror.style[property] = computed[property];
});
Object.assign(mirror.style, {
position: "fixed",
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
height: "auto",
minHeight: "0",
maxHeight: "none",
overflow: "hidden",
visibility: "hidden",
pointerEvents: "none",
whiteSpace: "pre-wrap",
zIndex: "-1",
});
mirror.append(document.createTextNode(textarea.value.slice(0, Math.max(0, cursorOffset))));
marker.textContent = "\u200b";
mirror.append(marker);
document.body.append(mirror);
const markerRect = marker.getBoundingClientRect();
mirror.remove();
const lineHeight = Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.4 || 20;
const top = clamp(markerRect.top - textarea.scrollTop, rect.top, rect.bottom - lineHeight);
const left = clamp(markerRect.left - textarea.scrollLeft, rect.left, rect.right);
return { left, right: left, top, bottom: top + lineHeight, width: 0, height: lineHeight };
}
function fallbackCaretRect(anchor: HTMLElement): MentionAnchorRect {
const rect = anchor.getBoundingClientRect();
const computed = window.getComputedStyle(anchor);
const lineHeight = Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.4 || 20;
const left = rect.left + Number.parseFloat(computed.paddingLeft || "0");
const top = rect.top + Number.parseFloat(computed.paddingTop || "0");
return { left, right: left, top, bottom: top + lineHeight, width: 0, height: lineHeight };
}
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
+28 -25
View File
@@ -6,6 +6,7 @@ import { WorkspaceCommandPalette } from "@/components/layout/workspace-command-p
import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
import { readWorkspaceSidebarCollapsed, writeWorkspaceSidebarCollapsed } from "@/components/layout/workspace-sidebar-state";
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
import { WorkspaceTopBarExtensionProvider } from "@/components/layout/workspace-top-bar-extension";
import { cn } from "@/lib/utils";
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
@@ -69,35 +70,37 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
return (
<>
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full flex-col overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
{!hideChrome && mobileSidebarExpanded ? <button type="button" className="app-workspace-sidebar-scrim lg:hidden" aria-label="收起侧栏" onClick={() => setMobileSidebarExpanded(false)} /> : null}
<WorkspaceTopBarExtensionProvider>
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full flex-col overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
{!hideChrome && mobileSidebarExpanded ? <button type="button" className="app-workspace-sidebar-scrim lg:hidden" aria-label="收起侧栏" onClick={() => setMobileSidebarExpanded(false)} /> : null}
<div className="app-workspace-main-row flex min-h-0 min-w-0 flex-1 overflow-hidden">
{!hideChrome ? (
<aside
className={cn(
"app-workspace-sidebar flex h-full shrink-0 flex-col overflow-hidden",
mobileSidebarExpanded && "is-mobile-expanded",
desktopSidebarCollapsed && "is-collapsed",
)}
>
<WorkspaceSidebarNav
collapsed={desktopSidebarCollapsed}
onNavigate={handleNavClick}
onOpenSearch={() => setPaletteOpen(true)}
onExpand={expandDesktopSidebar}
/>
</aside>
) : null}
<div className="app-workspace-main-row flex min-h-0 min-w-0 flex-1 overflow-hidden">
{!hideChrome ? (
<aside
className={cn(
"app-workspace-sidebar flex h-full shrink-0 flex-col overflow-hidden",
mobileSidebarExpanded && "is-mobile-expanded",
desktopSidebarCollapsed && "is-collapsed",
)}
>
<WorkspaceSidebarNav
collapsed={desktopSidebarCollapsed}
onNavigate={handleNavClick}
onOpenSearch={() => setPaletteOpen(true)}
onExpand={expandDesktopSidebar}
/>
</aside>
) : null}
<div className="app-workspace-stage relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{showGlobalTopBar ? <WorkspaceTopBar sidebarOpen={isMobileViewport() ? mobileSidebarExpanded : !desktopSidebarCollapsed} onToggleSidebar={toggleSidebar} /> : null}
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">{children}</div>
<div className="app-workspace-stage relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{showGlobalTopBar ? <WorkspaceTopBar sidebarOpen={isMobileViewport() ? mobileSidebarExpanded : !desktopSidebarCollapsed} onToggleSidebar={toggleSidebar} /> : null}
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">{children}</div>
</div>
</div>
</div>
<WorkspaceCommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
<WorkspaceCommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
</WorkspaceTopBarExtensionProvider>
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />
</>
);
@@ -0,0 +1,44 @@
import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useState, type ReactNode } from "react";
type WorkspaceTopBarRegistration = {
id: string;
content: ReactNode;
};
type WorkspaceTopBarRegistrar = {
register: (registration: WorkspaceTopBarRegistration) => void;
unregister: (id: string) => void;
};
const WorkspaceTopBarRegistrarContext = createContext<WorkspaceTopBarRegistrar | null>(null);
const WorkspaceTopBarContentContext = createContext<ReactNode>(null);
export function WorkspaceTopBarExtensionProvider({ children }: { children: ReactNode }) {
const [registration, setRegistration] = useState<WorkspaceTopBarRegistration | null>(null);
const register = useCallback((next: WorkspaceTopBarRegistration) => setRegistration(next), []);
const unregister = useCallback((id: string) => setRegistration((current) => current?.id === id ? null : current), []);
const registrar = useMemo(() => ({ register, unregister }), [register, unregister]);
return (
<WorkspaceTopBarRegistrarContext.Provider value={registrar}>
<WorkspaceTopBarContentContext.Provider value={registration?.content || null}>
{children}
</WorkspaceTopBarContentContext.Provider>
</WorkspaceTopBarRegistrarContext.Provider>
);
}
export function useWorkspaceTopBarExtension(content: ReactNode) {
const registrar = useContext(WorkspaceTopBarRegistrarContext);
const id = useId();
useLayoutEffect(() => {
if (!registrar) return;
registrar.register({ id, content });
return () => registrar.unregister(id);
}, [content, id, registrar]);
}
export function useWorkspaceTopBarContent() {
return useContext(WorkspaceTopBarContentContext);
}
@@ -3,6 +3,7 @@ import { Link, useLocation } from "react-router";
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
import { WorkspaceAccountMenu } from "@/components/layout/workspace-account-menu";
import { useWorkspaceTopBarContent } from "@/components/layout/workspace-top-bar-extension";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -24,21 +25,23 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const { pathname } = useLocation();
const extension = useWorkspaceTopBarContent();
const slug = pathname.split("/").filter(Boolean)[0];
const pageTitle = (slug && PAGE_TITLES[slug]) || "影策";
return (
<header className="app-workspace-topbar flex shrink-0 items-center justify-between gap-3 px-3 sm:px-4">
<div className="flex min-w-0 items-center gap-1.5">
<header className={`app-workspace-topbar flex shrink-0 items-center justify-between gap-2 px-3 sm:px-4 ${extension ? "has-extension" : ""}`}>
<div className="flex min-w-0 flex-1 items-center gap-2">
<button type="button" className="app-workspace-topbar-icon-button" aria-label={sidebarOpen ? "收起侧栏" : "展开侧栏"} onClick={onToggleSidebar}>
{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
</button>
<nav className="flex min-w-0 items-center gap-2 text-[var(--fs-caption)] text-foreground/50" aria-label="当前位置">
<nav className={`${extension ? "hidden 2xl:flex" : "flex"} min-w-0 items-center gap-2 text-[var(--fs-caption)] text-foreground/50`} aria-label="当前位置">
<Link to="/" className="shrink-0 font-medium text-foreground/70 transition-colors hover:text-foreground">影策</Link>
<span className="shrink-0 text-foreground/30">/</span>
<span className="truncate font-medium text-foreground">{pageTitle}</span>
</nav>
{extension ? <div className="min-w-0 flex-1">{extension}</div> : null}
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -0,0 +1,57 @@
import { useEffect, useMemo, useState } from "react";
import { Select } from "antd";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { SKILL_RUNTIME_PROFILES, type SkillRuntimeProfile } from "@/services/skill-runtime";
export function useSkillRuntimeCatalog() {
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
listAddedSkills()
.then((result) => {
if (!cancelled) setSkills(result.skills.filter((skill) => skill.is_added));
})
.catch(() => {
if (!cancelled) setSkills([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { skills, loading };
}
export function SkillRuntimePicker({ skills, loading, value, onChange, placeholder = "选择本次生成使用的技能", profile = "canvas" }: { skills: Skill[]; loading?: boolean; value: string[]; onChange: (skillIds: string[]) => void; placeholder?: string; profile?: SkillRuntimeProfile }) {
const options = useMemo(
() => skills.map((skill) => ({ value: skill.skill_id, label: skill.skill_name, title: skill.description })),
[skills],
);
const maxSkills = SKILL_RUNTIME_PROFILES[profile].maxSkills;
return (
<Select
className="w-full"
style={{ width: "100%" }}
mode="multiple"
allowClear
showSearch
maxCount={maxSkills || undefined}
maxTagCount="responsive"
loading={loading}
value={value}
placeholder={placeholder}
optionFilterProp="label"
options={options}
onChange={onChange}
notFoundContent={loading ? "正在读取技能库" : "暂无已加入的技能"}
aria-label="本次生成使用的技能"
/>
);
}
+99 -2
View File
@@ -11,7 +11,104 @@
object-fit: contain;
}
/* 画布节点顶部还有标题和存储状态,隐藏 Vidstack 小布局的顶部控制组避免互相遮挡。 */
.canvas-video-player-compact .vds-video-layout[data-sm] .vds-controls-group:first-child {
/* 画布节点顶部还有标题和存储状态,隐藏 Vidstack 小布局的顶部控制组避免互相遮挡;全屏时恢复完整工具栏。 */
.canvas-video-player-compact:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:first-child {
display: none;
}
/* 节点内保持紧凑;全屏使用桌面播放器级别的控制尺寸,不依赖 Vidstack 小布局的默认值。 */
.canvas-video-player[data-fullscreen],
.canvas-video-player:fullscreen,
.canvas-video-player:-webkit-full-screen {
--media-fullscreen-button-size: 60px;
--media-sm-fullscreen-button-size: 60px;
--media-button-size: 60px;
--media-button-icon-size: 56%;
--media-slider-track-height: 8px;
--media-slider-focused-track-height: 11px;
--media-slider-thumb-size: 21px;
--media-slider-focused-thumb-size: 24px;
--video-fullscreen-time-font-size: 18px;
--video-sm-time-font-size: 18px;
--video-fullscreen-chapter-title-font-size: 17px;
--video-sm-chapter-title-font-size: 17px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group {
padding-inline: 24px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:nth-last-child(2) {
min-height: 64px;
margin-bottom: -12px;
padding-inline: 28px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:last-child {
min-height: 82px;
gap: 4px;
padding: 8px 24px max(12px, env(safe-area-inset-bottom));
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-time-slider {
--media-slider-height: 64px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-button {
width: 60px;
height: 60px;
min-width: 60px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-button .vds-icon {
width: 34px;
height: 34px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-time {
min-width: max-content;
font-size: 18px;
line-height: 1.25;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-chapter-title {
font-size: 17px;
line-height: 1.3;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout[data-sm] .vds-controls .vds-play-button {
margin-bottom: 0;
transform: none;
}
@media screen and (max-width: 599px) {
.canvas-video-player[data-fullscreen],
.canvas-video-player:fullscreen,
.canvas-video-player:-webkit-full-screen {
--media-fullscreen-button-size: 52px;
--media-sm-fullscreen-button-size: 52px;
--media-button-size: 52px;
--video-fullscreen-time-font-size: 16px;
--video-sm-time-font-size: 16px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-button {
width: 52px;
height: 52px;
min-width: 52px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-button .vds-icon {
width: 29px;
height: 29px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-time {
font-size: 16px;
}
.canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:last-child {
min-height: 72px;
padding-inline: 12px;
}
}