Add full source parity validation workflow
This commit is contained in:
@@ -384,7 +384,7 @@ function BlenderOperatorPalette({ client, commands, onClose, onApplied, onBrowse
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="operator-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section className="operator-search-dialog" role="dialog" aria-modal="true" aria-label="操作搜索"><header><strong>操作搜索</strong><button type="button" aria-label="关闭操作搜索" onClick={onClose}>×</button></header><input autoFocus value={query} placeholder="搜索 Blender 操作..." aria-label="搜索 Blender 操作" onChange={(event) => setQuery(event.target.value)} />{loading ? <p>正在加载原生操作目录…</p> : null}{error ? <p role="alert">{error}</p> : null}<div className="operator-search-results" role="listbox">{staticMatches.map((command) => <button key={`command:${command.id}`} type="button" onClick={() => { onClose(); void command.execute?.(); }}>{command.label}</button>)}{nativeMatches.map(({route, label}) => <button key={`native:${route.id}`} type="button" onClick={() => void chooseRoute(route)}>{label}<small>{route.identifier}</small></button>)}{browserMatches.map(({route, label}) => <button key={`browser:${route.id}`} type="button" onClick={() => { onClose(); dispatchBrowserAction(route); }}>{label}<small>{route.identifier}</small></button>)}{!loading && staticMatches.length === 0 && nativeMatches.length === 0 && browserMatches.length === 0 ? <p>没有匹配的操作</p> : null}</div>{selected ? <form className="operator-property-form" onSubmit={(event) => void executeOperator(event)}><h3>{selected.label}</h3>{selected.properties.map(renderProperty)}<button type="submit" disabled={executing || !client}>{executing ? "执行中…" : "执行"}</button></form> : null}</section></div>;
|
||||
return <div className="operator-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section className="operator-search-dialog" role="dialog" aria-modal="true" aria-label="操作搜索"><header><strong>操作搜索</strong><button type="button" aria-label="关闭操作搜索" onClick={onClose}>×</button></header><input autoFocus value={query} placeholder="搜索 Blender 操作..." aria-label="搜索 Blender 操作" onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && staticMatches[0]?.execute) { event.preventDefault(); onClose(); void staticMatches[0].execute(); } }} />{loading ? <p>正在加载原生操作目录…</p> : null}{error ? <p role="alert">{error}</p> : null}<div className="operator-search-results" role="listbox">{staticMatches.map((command) => <button key={`command:${command.id}`} type="button" onClick={() => { onClose(); void command.execute?.(); }}>{command.label}</button>)}{nativeMatches.map(({route, label}) => <button key={`native:${route.id}`} type="button" onClick={() => void chooseRoute(route)}>{label}<small>{route.identifier}</small></button>)}{browserMatches.map(({route, label}) => <button key={`browser:${route.id}`} type="button" onClick={() => { onClose(); dispatchBrowserAction(route); }}>{label}<small>{route.identifier}</small></button>)}{!loading && staticMatches.length === 0 && nativeMatches.length === 0 && browserMatches.length === 0 ? <p>没有匹配的操作</p> : null}</div>{selected ? <form className="operator-property-form" onSubmit={(event) => void executeOperator(event)}><h3>{selected.label}</h3>{selected.properties.map(renderProperty)}<button type="submit" disabled={executing || !client}>{executing ? "执行中…" : "执行"}</button></form> : null}</section></div>;
|
||||
}
|
||||
|
||||
async function sha256Hex(data: ArrayBuffer): Promise<string> {
|
||||
@@ -1751,30 +1751,6 @@ interface OperatorCommand {
|
||||
execute: () => void;
|
||||
}
|
||||
|
||||
function OverlayFocusTrap({ children, onEscape, className, label }: { children: React.ReactNode; onEscape: () => void; className: string; label: string }) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const focusableSelector = "input, button, select, textarea, a[href], [role='button'], [tabindex]:not([tabindex='-1'])";
|
||||
useEffect(() => {
|
||||
const first = rootRef.current?.querySelector<HTMLElement>(focusableSelector);
|
||||
first?.focus();
|
||||
}, []);
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onEscape();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = [...(rootRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? [])].filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true");
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
};
|
||||
return <div ref={rootRef} className={className} role="dialog" aria-modal="true" aria-label={label} tabIndex={-1} onKeyDown={onKeyDown}>{children}</div>;
|
||||
}
|
||||
|
||||
function MenuPopover({ menu, labels, onClose, onSelect }: { menu: string; labels: readonly string[]; onClose: () => void; onSelect: (label: string) => void }) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { menuRef.current?.querySelector<HTMLElement>("button")?.focus(); }, []);
|
||||
@@ -1791,19 +1767,6 @@ function MenuPopover({ menu, labels, onClose, onSelect }: { menu: string; labels
|
||||
return <div ref={menuRef} className="main-menu-popover" role="menu" aria-label={menu} onKeyDown={onKeyDown}>{labels.map((label) => <button key={label} type="button" role="menuitem" onClick={() => onSelect(label)}>{label}</button>)}</div>;
|
||||
}
|
||||
|
||||
function OperatorSearch({ commands, onClose }: { commands: readonly OperatorCommand[]; onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const matches = commands.filter((command) => `${command.label} ${command.keywords}`.toLowerCase().includes(normalized));
|
||||
const run = (command: OperatorCommand): void => { command.execute(); onClose(); };
|
||||
return (
|
||||
<OverlayFocusTrap className="operator-search" label="Operator Search" onEscape={onClose}>
|
||||
<input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && matches[0]) run(matches[0]); }} placeholder="Search operators" aria-label="搜索操作" />
|
||||
<div className="operator-results">{matches.map((command) => <button key={command.id} type="button" onClick={() => run(command)}>{command.label}</button>)}</div>
|
||||
</OverlayFocusTrap>
|
||||
);
|
||||
}
|
||||
|
||||
function SequencerAuthoringPanel({ scene, revision, frame, onCommand }: { scene: SceneSnapshotIR["scenes"][number]; revision: number; frame: number; onCommand: (command: WebEngineEditCommand) => void }) {
|
||||
const timeline = scene.sequencerTimeline ?? {
|
||||
schemaVersion: 1,
|
||||
@@ -3561,7 +3524,7 @@ export function App() {
|
||||
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING"}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
|
||||
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null}
|
||||
<StorageBudgetPanel budget={storageBudget} onCleanup={() => void cleanupProjectAssets()} />
|
||||
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0 && operation !== "RECALCULATE_NORMALS"} onClick={() => runMeshEdit(operation)}>{operation.replaceAll("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => <button key={primitive} type="button" aria-label={`添加 ${primitive.toLowerCase().replaceAll("_", " ")}`} onClick={() => void applyEditCommand(coreSceneCommands.createPrimitive(primitive))}>Add {primitive.replaceAll("_", " ")}</button>)}<button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
|
||||
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0 && operation !== "RECALCULATE_NORMALS"} onClick={() => runMeshEdit(operation)}>{operation.replaceAll("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => <button key={primitive} type="button" aria-label={primitive === "CUBE" ? "添加立方体" : `添加 ${primitive.toLowerCase().replaceAll("_", " ")}`} onClick={() => void applyEditCommand(coreSceneCommands.createPrimitive(primitive))}>Add {primitive.replaceAll("_", " ")}</button>)}<button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
|
||||
<div className="workspace-grid">
|
||||
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area>
|
||||
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
|
||||
|
||||
@@ -32,7 +32,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
||||
.workspace-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; grid-template-rows: minmax(0, 1fr) 164px; min-height: 0; gap: 1px; background: #111214; }
|
||||
.area-frame { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: #24262a; }
|
||||
.editor-header { display: flex; align-items: center; min-height: 28px; padding: 0 6px; background: #303238; border-bottom: 1px solid #17181b; }
|
||||
.editor-selector, .icon-button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px; background: transparent; color: #e1e3e7; border-radius: 3px; }
|
||||
.editor-selector, .icon-button { display: inline-flex; align-items: center; gap: 6px; min-width: 24px; min-height: 24px; padding: 4px 6px; background: transparent; color: #e1e3e7; border-radius: 3px; }
|
||||
.editor-selector:hover, .icon-button:hover { background: #41434a; }
|
||||
.editor-icon { color: #e37a2c; font-size: 14px; }
|
||||
.icon-button { color: #a9adb5; }
|
||||
@@ -67,7 +67,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
|
||||
.property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; }
|
||||
.grease-pencil-canvas-2d { display: block; width: 100%; max-width: 280px; aspect-ratio: 28 / 15; margin: 7px 0; border: 1px solid #4d5158; border-radius: 3px; background: #202328; cursor: crosshair; touch-action: none; }
|
||||
.grease-pencil-canvas-2d:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
|
||||
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #aeb3bc; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
|
||||
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; min-height: 24px; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #aeb3bc; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
|
||||
.dope-sheet input { min-width: 24px; min-height: 32px; margin-block: 4px; }.keying-set-tools > button, .timeline-markers > button { min-height: 24px; padding: 3px 7px; color: #f1f2f4; background: #41444a; border: 1px solid #656a73; border-radius: 2px; }
|
||||
.status-bar { gap: 16px; min-width: 0; min-height: 24px; overflow: hidden; color: #8f949c; font-size: 11px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }
|
||||
.storage-budget-panel { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 10px; overflow-x: auto; color: #aeb2bb; background: #222427; border-bottom: 1px solid #111214; white-space: nowrap; scrollbar-width: thin; }.storage-budget-panel strong { color: #e4e6e9; font-size: 11px; }.storage-budget-panel span { color: #aeb2bb; font-variant-numeric: tabular-nums; }.storage-budget-panel output { margin-left: auto; color: #e37a2c; font-variant-numeric: tabular-nums; }.storage-budget-panel button { flex: none; padding: 3px 7px; color: #d9dce1; background: #35373d; border: 1px solid #4d5158; border-radius: 3px; }.storage-budget-panel button:disabled { opacity: .45; cursor: default; }
|
||||
.open-progress { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 360px; }.open-progress progress { width: 72px; height: 8px; accent-color: #e37a2c; }.open-progress > span { overflow: hidden; text-overflow: ellipsis; }.open-progress button { width: 20px; height: 20px; padding: 0; color: #d6d8dc; background: #3a3c41; border-radius: 2px; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
77
web/app/src/sequencer/FFmpegMovieProbe.ts
Normal file
77
web/app/src/sequencer/FFmpegMovieProbe.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
parseSequencerCodecProbeRequest,
|
||||
type SequencerCodecProbeRequestIR,
|
||||
type SequencerCodecProbeResultIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
|
||||
interface WorkerResponse {
|
||||
requestId: string;
|
||||
status: "READY" | "BLOCKED";
|
||||
reason: "RUNTIME_UNAVAILABLE" | "DECODE_FAILED" | null;
|
||||
decoded: { width: number; height: number; durationMicros: number } | null;
|
||||
data?: ArrayBuffer;
|
||||
}
|
||||
|
||||
const digest = async (source: ArrayBuffer): Promise<string> => {
|
||||
const result = await crypto.subtle.digest("SHA-256", source);
|
||||
return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
export interface FFmpegMovieFrameIR {
|
||||
decoded: { width: number; height: number; durationMicros: number };
|
||||
rgba: ArrayBuffer;
|
||||
}
|
||||
|
||||
function blocked(request: SequencerCodecProbeRequestIR, reason: "RUNTIME_UNAVAILABLE" | "DECODE_FAILED"): SequencerCodecProbeResultIR {
|
||||
return { ...request, status: "BLOCKED", backend: "FFMPEG_WASM", reason, decoded: null };
|
||||
}
|
||||
|
||||
async function requestFFmpeg(
|
||||
request: SequencerCodecProbeRequestIR,
|
||||
source: ArrayBuffer,
|
||||
type: "probe" | "frame",
|
||||
options: { width?: number; height?: number; signal?: AbortSignal } = {},
|
||||
): Promise<WorkerResponse> {
|
||||
const worker = new Worker(new URL("./ffmpeg-movie-probe.worker.ts", import.meta.url), { type: "module" });
|
||||
const requestId = `ffmpeg-movie:${crypto.randomUUID()}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
worker.onmessage = null;
|
||||
worker.onerror = null;
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
worker.terminate();
|
||||
};
|
||||
const abort = (): void => { cleanup(); reject(new DOMException("FFmpeg movie probe cancelled", "AbortError")); };
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
if (event.data.requestId !== requestId) return;
|
||||
cleanup();
|
||||
resolve(event.data);
|
||||
};
|
||||
worker.onerror = (event) => { cleanup(); reject(new Error(event.message || "FFmpeg movie worker failed")); };
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
if (options.signal?.aborted) { abort(); return; }
|
||||
const payload = source.slice(0);
|
||||
worker.postMessage({ requestId, type, bytes: payload, mimeType: request.mimeType, width: options.width, height: options.height }, [payload]);
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeMovieWithFFmpeg(requestValue: unknown, source: ArrayBuffer, signal?: AbortSignal): Promise<SequencerCodecProbeResultIR> {
|
||||
const request = parseSequencerCodecProbeRequest(requestValue);
|
||||
if (request.stripType !== "MOVIE" || !(source instanceof ArrayBuffer) || source.byteLength !== request.byteLength || await digest(source) !== request.sourceSha256) return blocked(request, "DECODE_FAILED");
|
||||
try {
|
||||
const result = await requestFFmpeg(request, source, "probe", { signal });
|
||||
return result.status === "READY" && result.decoded ? { ...request, status: "READY", backend: "FFMPEG_WASM", reason: null, decoded: result.decoded } : blocked(request, result.reason ?? "DECODE_FAILED");
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
return blocked(request, "RUNTIME_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
|
||||
export async function decodeMovieFrameWithFFmpeg(requestValue: unknown, source: ArrayBuffer, width: number, height: number, signal?: AbortSignal): Promise<FFmpegMovieFrameIR> {
|
||||
const request = parseSequencerCodecProbeRequest(requestValue);
|
||||
if (request.stripType !== "MOVIE" || !(source instanceof ArrayBuffer) || source.byteLength !== request.byteLength || await digest(source) !== request.sourceSha256) throw new Error("SEQUENCER_CACHE_SOURCE_MISMATCH");
|
||||
const result = await requestFFmpeg(request, source, "frame", { width, height, signal });
|
||||
if (result.status !== "READY" || !result.decoded || !result.data) throw new Error(result.reason ?? "SEQUENCER_CODEC_UNSUPPORTED");
|
||||
return { decoded: result.decoded, rgba: result.data };
|
||||
}
|
||||
89
web/app/src/sequencer/ffmpeg-movie-probe.worker.ts
Normal file
89
web/app/src/sequencer/ffmpeg-movie-probe.worker.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
||||
|
||||
interface ProbeRequest {
|
||||
type: "probe" | "frame";
|
||||
requestId: string;
|
||||
bytes: ArrayBuffer;
|
||||
mimeType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface ProbeResponse {
|
||||
requestId: string;
|
||||
status: "READY" | "BLOCKED";
|
||||
reason: "RUNTIME_UNAVAILABLE" | "DECODE_FAILED" | null;
|
||||
decoded: { width: number; height: number; durationMicros: number } | null;
|
||||
data?: ArrayBuffer;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
let loaded = false;
|
||||
const workerScope = self as unknown as { postMessage: (message: unknown, transfer?: Transferable[]) => void };
|
||||
|
||||
function response(requestId: string, patch: Omit<ProbeResponse, "requestId">): void {
|
||||
workerScope.postMessage({ requestId, ...patch }, patch.data ? [patch.data] : []);
|
||||
}
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded) return;
|
||||
const vendorRoot = new URL(self.location.href).pathname.startsWith("/src/") ? "/src/vendor/ffmpeg" : "/vendor/ffmpeg";
|
||||
await ffmpeg.load({
|
||||
coreURL: `${vendorRoot}/ffmpeg-core.js`,
|
||||
wasmURL: `${vendorRoot}/ffmpeg-core.wasm`,
|
||||
});
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function readText(path: string): Promise<string> {
|
||||
const value = await ffmpeg.readFile(path, "utf8");
|
||||
return typeof value === "string" ? value : new TextDecoder().decode(value);
|
||||
}
|
||||
|
||||
async function probe(request: ProbeRequest): Promise<ProbeResponse["decoded"]> {
|
||||
await ensureLoaded();
|
||||
await ffmpeg.writeFile("input.movie", new Uint8Array(request.bytes));
|
||||
await ffmpeg.ffprobe([
|
||||
"-v", "error", "-show_entries", "stream=width,height:format=duration", "-of", "json", "input.movie", "-o", "probe.json",
|
||||
]);
|
||||
const parsed = JSON.parse(await readText("probe.json")) as { streams?: Array<{ width?: number; height?: number }>; format?: { duration?: string } };
|
||||
const stream = parsed.streams?.find((candidate) => Number.isFinite(candidate.width) && Number.isFinite(candidate.height));
|
||||
const width = Math.trunc(stream?.width ?? 0);
|
||||
const height = Math.trunc(stream?.height ?? 0);
|
||||
const duration = Number(parsed.format?.duration ?? 0);
|
||||
if (width < 1 || height < 1 || !Number.isFinite(duration) || duration <= 0) return null;
|
||||
return { width, height, durationMicros: Math.max(1, Math.round(duration * 1_000_000)) };
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<ProbeRequest>) => {
|
||||
const request = event.data;
|
||||
void (async () => {
|
||||
try {
|
||||
const decoded = await probe(request);
|
||||
if (!decoded) {
|
||||
response(request.requestId, { status: "BLOCKED", reason: "DECODE_FAILED", decoded: null });
|
||||
return;
|
||||
}
|
||||
if (request.type === "probe") {
|
||||
response(request.requestId, { status: "READY", reason: null, decoded });
|
||||
return;
|
||||
}
|
||||
const width = Math.max(1, Math.min(4096, Math.trunc(request.width ?? decoded.width)));
|
||||
const height = Math.max(1, Math.min(4096, Math.trunc(request.height ?? decoded.height)));
|
||||
await ffmpeg.exec([
|
||||
"-i", "input.movie", "-vf", `scale=${width}:${height}`, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgba", "frame.rgba",
|
||||
]);
|
||||
const data = await ffmpeg.readFile("frame.rgba");
|
||||
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
||||
if (bytes.byteLength !== width * height * 4) {
|
||||
response(request.requestId, { status: "BLOCKED", reason: "DECODE_FAILED", decoded: null });
|
||||
return;
|
||||
}
|
||||
response(request.requestId, { status: "READY", reason: null, decoded: { width, height, durationMicros: decoded.durationMicros }, data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer });
|
||||
}
|
||||
catch (error) {
|
||||
response(request.requestId, { status: "BLOCKED", reason: "RUNTIME_UNAVAILABLE", decoded: null, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
})();
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type CompositorGraphIR,
|
||||
type CompositorImageBuffer,
|
||||
} from "../compositor/CompositorExecutor";
|
||||
import { createSequencerCodecProbeRequest, probeSequencerCodec } from "../sequencer/SequencerCodecProbe";
|
||||
import { createSequencerCodecProbeRequest, probeSequencerMovieCodecWithFallback } from "../sequencer/SequencerCodecProbe";
|
||||
import { generateInitialSequencerMovieProxyFrame, SequencerMediaProxyCache } from "../sequencer/SequencerMediaProxyCache";
|
||||
|
||||
export interface RenderCompositorMediaRecoveryInput {
|
||||
@@ -278,7 +278,7 @@ async function runMediaRecovery(input: RenderCompositorMediaRecoveryInput): Prom
|
||||
const movie = requireInput(input.movie.data, "movie.data");
|
||||
const sourceSha256 = await sha256(movie);
|
||||
const request = createSequencerCodecProbeRequest("MOVIE", input.movie.mimeType, movie.byteLength, sourceSha256);
|
||||
const capability = await probeSequencerCodec(request, movie.slice(0));
|
||||
const capability = await probeSequencerMovieCodecWithFallback(request, movie.slice(0));
|
||||
const profile = { kind: "MOVIE_RGBA8_FRAME" as const, width: 8, height: 8, colorSpace: "SRGB8" as const, alphaMode: "STRAIGHT" as const };
|
||||
const first = await generateInitialSequencerMovieProxyFrame(request, capability, profile, movie.slice(0));
|
||||
const identityBefore = first.manifest.identitySha256;
|
||||
@@ -300,7 +300,7 @@ async function runMediaRecovery(input: RenderCompositorMediaRecoveryInput): Prom
|
||||
|
||||
const releasedResources = firstCache.stats().entries;
|
||||
const releasedBytes = firstCache.clear();
|
||||
const secondCapability = await probeSequencerCodec(request, movie.slice(0));
|
||||
const secondCapability = await probeSequencerMovieCodecWithFallback(request, movie.slice(0));
|
||||
const second = await generateInitialSequencerMovieProxyFrame(request, secondCapability, profile, movie.slice(0));
|
||||
const secondCache = new SequencerMediaProxyCache(second.data.byteLength);
|
||||
try {
|
||||
|
||||
16
web/app/src/vendor/ffmpeg/ffmpeg-core.js
vendored
Normal file
16
web/app/src/vendor/ffmpeg/ffmpeg-core.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/app/src/vendor/ffmpeg/ffmpeg-core.wasm
vendored
Normal file
BIN
web/app/src/vendor/ffmpeg/ffmpeg-core.wasm
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user