Browse Source

去字幕

feature/video-erase
Luckyu_js 2 weeks ago
parent
commit
09f7fa2279
  1. 6
      CLAUDE.md
  2. 1
      src/app/api/config/route.ts
  3. 2
      src/app/api/models/route.ts
  4. 10
      src/components/canvas/ActiveVideoEditHost.tsx
  5. 4
      src/components/composer/NodeGenerationComposer.tsx
  6. 55
      src/components/media/MediaEditActionDropdown.tsx
  7. 152
      src/components/media/MediaVideoEraseOverlay.tsx
  8. 93
      src/components/media/MediaVideoEraseToolbar.tsx
  9. 24
      src/components/media/video-edit/VideoBoxEraseSession.tsx
  10. 40
      src/components/nodes/GenerateVideoNode.tsx
  11. 40
      src/components/nodes/VideoInputNode.tsx
  12. 44
      src/i18n/index.tsx
  13. 7
      src/store/appConfigStore.ts
  14. 41
      src/utils/__tests__/previewImageUrl.test.ts
  15. 18
      src/utils/previewImageUrl.ts

6
CLAUDE.md

@ -277,9 +277,3 @@ Gemini、Kie、fal、Replicate、WaveSpeed、newapiwg 等老网关的存量代
- 分支命名使用 `feature/<short-description>``fix/<short-description>`
- 所有 PR 默认指向 `master`:`gh pr create --base master`。
- 不要直接 push 到 `master`
## Commit 规范
- 每完成一个逻辑任务或工作单元提交一次。
- 每个 commit 应该原子化、自包含:一个任务 = 一个 commit。
- `.planning` 目录未被追踪,不要提交该目录中的文件。

1
src/app/api/config/route.ts

@ -70,6 +70,7 @@ type SystemConfig = {
popiartStudioMacInDownloadLink: string;
popiartEvidencePreservationAgreement: string;
aliyunRishDetectionConfidence: number;
ossImageThumbSuffix?: string;
};
export type ConfigPayload = {

2
src/app/api/models/route.ts

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import type { ProviderType } from "@/types";
import type { ModelCapability } from "@/lib/providers";
import type { ModelCapability } from "@/lib/providers/types";
import { getRequestToken, requireLogin } from "@/app/api/_auth";
import { apiErrorResponse } from "@/app/api/_errors";
import {

10
src/components/canvas/ActiveVideoEditHost.tsx

@ -65,17 +65,19 @@ export function ActiveVideoEditHost() {
const videoElRef = useRef<HTMLVideoElement | null>(null);
const videoSource = node ? getNodeVideoSource(node) : null;
const dimensions = useMemo(() => asVideoDimensions(node?.data?.dimensions), [node?.data?.dimensions]);
const styleDims = node ? getWorkflowNodeStyleDimensions(node) : null;
const dimensions = useMemo(() => asVideoDimensions(node?.data?.dimensions), [node?.data?.dimensions]);
useLayoutEffect(() => {
if (targetNodeId && !node) closeTool();
}, [closeTool, node, targetNodeId]);
if (!isVideoEditTool || !node || !videoSource || !styleDims || !dimensions) {
if (!isVideoEditTool || !node || !videoSource || !styleDims) {
return null;
}
const sessionDimensions = dimensions ?? styleDims;
const frameStyle: CSSProperties = {
position: "absolute",
transform: `translate(${node.position.x + parentOffsetX}px, ${node.position.y + parentOffsetY}px)`,
@ -92,14 +94,14 @@ export function ActiveVideoEditHost() {
<VideoBoxEraseSession
nodeId={node.id}
videoRef={videoElRef}
dimensions={dimensions}
dimensions={sessionDimensions}
models={videoModels}
onClose={closeTool}
/>
) : (
<VideoSmartEraseSession
nodeId={node.id}
dimensions={dimensions}
dimensions={sessionDimensions}
models={videoModels}
onClose={closeTool}
/>

4
src/components/composer/NodeGenerationComposer.tsx

@ -13,6 +13,7 @@ import { getNodeImageSource } from "@/utils/getNodeImageSource";
import { isSmartImageAutoTaskNode } from "@/utils/smartMediaMode";
import { isHighDefinitionDerivedImageNode } from "@/utils/highDefinitionNodes";
import { isHighDefinitionDerivedVideoNode } from "@/utils/highDefinitionVideoNodes";
import { getNodeVideoSource } from "@/utils/getNodeVideoSource";
interface NodeGenerationComposerProps {
nodeId: string;
@ -29,6 +30,9 @@ function isToolSupported(tool: NodeToolType, node: WorkflowNode | null): boolean
if (tool === "crop" || tool === "splitGrid" || tool === "outpainting" || tool === "inpainting" || tool === "annotate") {
return Boolean(getNodeImageSource(node));
}
if (tool === "videoSmartErase" || tool === "videoBoxErase") {
return Boolean(getNodeVideoSource(node));
}
return false;
}

55
src/components/media/MediaEditActionDropdown.tsx

@ -3,6 +3,7 @@
import type { ReactNode } from "react";
import type { MenuProps } from "antd";
import type { NodeActionCapsuleAction } from "@/components/nodes/NodeActionCapsule";
import { BoxEraseMenuIcon, SmartEraseMenuIcon } from "@/components/media/MediaToolbarIcons";
export type MediaEditMenuTool = "edit" | "high-definition" | "outpainting" | "inpainting" | "crop" | "annotate";
@ -36,6 +37,60 @@ export function createMediaEditActionDropdown({
};
}
interface VideoEraseActionPanelOptions {
label: string;
icon: ReactNode;
smartEraseLabel: string;
boxEraseLabel: string;
onSmartErase: () => void;
onBoxErase: () => void;
}
export function createVideoEraseActionPanel({
label,
icon,
smartEraseLabel,
boxEraseLabel,
onSmartErase,
onBoxErase,
}: VideoEraseActionPanelOptions): NodeActionCapsuleAction {
return {
key: "video-erase-menu",
icon,
label,
text: label,
section: "feature",
title: label,
panel: ({ close }) => {
const handleSelect = (action: () => void) => {
action();
close();
};
return (
<div className="nodrag nopan nowheel min-w-[132px] rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-2)] p-[5px] text-sm text-[var(--text-primary)] shadow-[var(--shadow-panel)]">
<button
type="button"
className="flex h-9 w-full items-center gap-2 rounded-md px-3 text-left hover:bg-[var(--surface-hover)]"
onClick={() => handleSelect(onSmartErase)}
>
<SmartEraseMenuIcon className="h-4 w-4 shrink-0" />
<span>{smartEraseLabel}</span>
</button>
<button
type="button"
className="mt-1 flex h-9 w-full items-center gap-2 rounded-md px-3 text-left hover:bg-[var(--surface-hover)]"
onClick={() => handleSelect(onBoxErase)}
>
<BoxEraseMenuIcon className="h-4 w-4 shrink-0" />
<span>{boxEraseLabel}</span>
</button>
</div>
);
},
};
}
export function getMediaEditMenuLabel(tool: MediaEditMenuTool) {
switch (tool) {
case "high-definition":

152
src/components/media/MediaVideoEraseOverlay.tsx

@ -11,6 +11,11 @@ export interface VideoEraseFrameRect {
height: number;
}
export interface VideoEraseHistoryState {
canUndo: boolean;
canRedo: boolean;
}
interface DisplayRect {
left: number;
top: number;
@ -27,6 +32,11 @@ interface EraseBox {
height: number;
}
interface EraseHistorySnapshot {
boxes: EraseBox[];
selectedId: number | null;
}
type DragMode = "move" | "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se" | "create";
const MIN_BOX_SIZE = 24;
@ -36,10 +46,12 @@ interface MediaVideoEraseOverlayProps {
naturalWidth?: number;
naturalHeight?: number;
busy?: boolean;
clearSignal?: number;
deleteSelectedSignal?: number;
undoSignal?: number;
redoSignal?: number;
resetSignal?: number;
onBoxesChange?: (boxes: VideoEraseRatioBox[]) => void;
onFrameChange?: (rect: VideoEraseFrameRect | null) => void;
onHistoryStateChange?: (state: VideoEraseHistoryState) => void;
}
function clamp(value: number, min: number, max: number) {
@ -57,6 +69,22 @@ function readVideoNaturalDimensions(el: HTMLVideoElement | null): { width: numbe
return validDimensions(el.videoWidth, el.videoHeight);
}
function cloneBoxes(boxes: EraseBox[]): EraseBox[] {
return boxes.map((box) => ({ ...box }));
}
function snapshotsEqual(a: EraseHistorySnapshot, b: EraseHistorySnapshot) {
if (a.selectedId !== b.selectedId || a.boxes.length !== b.boxes.length) return false;
return a.boxes.every((box, index) => {
const next = b.boxes[index];
return box.id === next.id &&
box.x === next.x &&
box.y === next.y &&
box.width === next.width &&
box.height === next.height;
});
}
function fitBoxToBounds(box: EraseBox, bounds: DisplayRect): EraseBox {
const width = Math.min(Math.max(box.width, MIN_BOX_SIZE), bounds.width);
const height = Math.min(Math.max(box.height, MIN_BOX_SIZE), bounds.height);
@ -95,10 +123,12 @@ export function MediaVideoEraseOverlay({
naturalWidth,
naturalHeight,
busy = false,
clearSignal = 0,
deleteSelectedSignal = 0,
undoSignal = 0,
redoSignal = 0,
resetSignal = 0,
onBoxesChange,
onFrameChange,
onHistoryStateChange,
}: MediaVideoEraseOverlayProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const idRef = useRef(0);
@ -109,13 +139,60 @@ export function MediaVideoEraseOverlay({
startY: number;
origin: EraseBox;
} | null>(null);
const boxesRef = useRef<EraseBox[]>([]);
const selectedIdRef = useRef<number | null>(null);
const historyRef = useRef<EraseHistorySnapshot[]>([{ boxes: [], selectedId: null }]);
const historyIndexRef = useRef(0);
const [bounds, setBounds] = useState<DisplayRect | null>(null);
const [boxes, setBoxes] = useState<EraseBox[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [historyState, setHistoryState] = useState<VideoEraseHistoryState>({ canUndo: false, canRedo: false });
const [naturalDimensions, setNaturalDimensions] = useState<{ width: number; height: number } | null>(() =>
validDimensions(naturalWidth, naturalHeight)
);
const updateHistoryState = useCallback(() => {
setHistoryState({
canUndo: historyIndexRef.current > 0,
canRedo: historyIndexRef.current < historyRef.current.length - 1,
});
}, []);
const applySnapshot = useCallback((snapshot: EraseHistorySnapshot) => {
const nextBoxes = cloneBoxes(snapshot.boxes);
boxesRef.current = nextBoxes;
selectedIdRef.current = snapshot.selectedId;
setBoxes(nextBoxes);
setSelectedId(snapshot.selectedId);
}, []);
const commitHistory = useCallback((snapshot: EraseHistorySnapshot) => {
const normalized = { boxes: cloneBoxes(snapshot.boxes), selectedId: snapshot.selectedId };
const current = historyRef.current[historyIndexRef.current];
if (current && snapshotsEqual(current, normalized)) {
updateHistoryState();
return;
}
const nextHistory = historyRef.current.slice(0, historyIndexRef.current + 1);
nextHistory.push(normalized);
historyRef.current = nextHistory;
historyIndexRef.current = nextHistory.length - 1;
updateHistoryState();
}, [updateHistoryState]);
const setBoxesWithRefs = useCallback((updater: (current: EraseBox[]) => EraseBox[]) => {
setBoxes((current) => {
const next = updater(current);
boxesRef.current = next;
return next;
});
}, []);
const setSelectedIdWithRef = useCallback((next: number | null) => {
selectedIdRef.current = next;
setSelectedId(next);
}, []);
useEffect(() => {
const explicit = validDimensions(naturalWidth, naturalHeight);
if (explicit) {
@ -167,16 +244,33 @@ export function MediaVideoEraseOverlay({
}, [measureBounds]);
useEffect(() => {
if (clearSignal === 0) return;
setBoxes([]);
setSelectedId(null);
}, [clearSignal]);
onHistoryStateChange?.(historyState);
}, [historyState, onHistoryStateChange]);
useEffect(() => {
if (undoSignal === 0) return;
if (historyIndexRef.current <= 0) return;
historyIndexRef.current -= 1;
applySnapshot(historyRef.current[historyIndexRef.current]);
updateHistoryState();
}, [applySnapshot, undoSignal, updateHistoryState]);
useEffect(() => {
if (deleteSelectedSignal === 0) return;
setBoxes((current) => current.filter((box) => box.id !== selectedId));
if (redoSignal === 0) return;
if (historyIndexRef.current >= historyRef.current.length - 1) return;
historyIndexRef.current += 1;
applySnapshot(historyRef.current[historyIndexRef.current]);
updateHistoryState();
}, [applySnapshot, redoSignal, updateHistoryState]);
useEffect(() => {
if (resetSignal === 0) return;
boxesRef.current = [];
selectedIdRef.current = null;
setBoxes([]);
setSelectedId(null);
}, [deleteSelectedSignal]); // eslint-disable-line react-hooks/exhaustive-deps
commitHistory({ boxes: [], selectedId: null });
}, [commitHistory, resetSignal]);
const pointerToOverlay = useCallback((event: React.PointerEvent) => {
const el = overlayRef.current;
@ -196,11 +290,12 @@ export function MediaVideoEraseOverlay({
const startY = clamp(point.y, bounds.top, bounds.top + bounds.height);
const id = ++idRef.current;
const newBox: EraseBox = { id, x: startX, y: startY, width: MIN_BOX_SIZE, height: MIN_BOX_SIZE };
setBoxes((current) => [...current, newBox]);
setSelectedId(id);
setBoxesWithRefs((current) => [...current, newBox]);
setSelectedIdWithRef(id);
dragRef.current = { mode: "create", boxId: id, startX, startY, origin: newBox };
event.currentTarget.setPointerCapture(event.pointerId);
}, [bounds, busy, pointerToOverlay]);
overlayRef.current?.focus();
}, [bounds, busy, pointerToOverlay, setBoxesWithRefs, setSelectedIdWithRef]);
const handleBoxPointerDown = useCallback((event: React.PointerEvent, boxId: number, mode: DragMode) => {
if (busy || !bounds) return;
@ -209,10 +304,11 @@ export function MediaVideoEraseOverlay({
const origin = boxes.find((box) => box.id === boxId);
if (!origin) return;
const point = pointerToOverlay(event);
setSelectedId(boxId);
setSelectedIdWithRef(boxId);
dragRef.current = { mode, boxId, startX: point.x, startY: point.y, origin };
event.currentTarget.setPointerCapture(event.pointerId);
}, [bounds, boxes, busy, pointerToOverlay]);
overlayRef.current?.focus();
}, [bounds, boxes, busy, pointerToOverlay, setSelectedIdWithRef]);
const handlePointerMove = useCallback((event: React.PointerEvent) => {
const drag = dragRef.current;
@ -220,7 +316,7 @@ export function MediaVideoEraseOverlay({
event.preventDefault();
const point = pointerToOverlay(event);
setBoxes((current) => current.map((box) => {
setBoxesWithRefs((current) => current.map((box) => {
if (box.id !== drag.boxId) return box;
if (drag.mode === "create") {
const left = Math.min(drag.startX, point.x);
@ -244,13 +340,29 @@ export function MediaVideoEraseOverlay({
}
return fitBoxToBounds(resizeFree(drag.origin, bounds, drag.mode, point.x, point.y), bounds);
}));
}, [bounds, pointerToOverlay]);
}, [bounds, pointerToOverlay, setBoxesWithRefs]);
const handlePointerUp = useCallback((event: React.PointerEvent) => {
if (!dragRef.current) return;
event.preventDefault();
commitHistory({ boxes: boxesRef.current, selectedId: selectedIdRef.current });
dragRef.current = null;
}, []);
}, [commitHistory]);
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (busy) return;
if (event.key !== "Delete" && event.key !== "Backspace") return;
const currentSelectedId = selectedIdRef.current;
if (currentSelectedId === null) return;
event.preventDefault();
event.stopPropagation();
const nextBoxes = boxesRef.current.filter((box) => box.id !== currentSelectedId);
boxesRef.current = nextBoxes;
selectedIdRef.current = null;
setBoxes(nextBoxes);
setSelectedId(null);
commitHistory({ boxes: nextBoxes, selectedId: null });
}, [busy, commitHistory]);
// 归一化擦除框 + 选中框的显示矩形回调。
useEffect(() => {
@ -287,6 +399,8 @@ export function MediaVideoEraseOverlay({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onKeyDown={handleKeyDown}
tabIndex={0}
data-media-video-erase-overlay
>
{bounds && (

93
src/components/media/MediaVideoEraseToolbar.tsx

@ -1,92 +1,95 @@
"use client";
import type { CSSProperties } from "react";
import { Button, Tooltip } from "antd";
import { ClearOutlined, CloseOutlined, DeleteOutlined } from "@ant-design/icons";
import { CloseOutlined, RedoOutlined, ReloadOutlined, UndoOutlined } from "@ant-design/icons";
import { GenerateActionIcon } from "@/components/media/MediaToolbarIcons";
import { NodeActionCapsuleShell } from "@/components/nodes/NodeActionCapsule";
import type { VideoEraseFrameRect } from "@/components/media/MediaVideoEraseOverlay";
import { useI18n } from "@/i18n";
interface MediaVideoEraseToolbarProps {
frameRect: VideoEraseFrameRect | null;
boxCount: number;
canUndo: boolean;
canRedo: boolean;
canConfirm: boolean;
onDeleteSelected: () => void;
onClear: () => void;
onUndo: () => void;
onRedo: () => void;
onReset: () => void;
onCancel: () => void;
onConfirm: () => void;
}
/**
* 框选擦除工具条:锚定在当前选中框()沿
* MediaInpaintingToolbar
* 框选擦除工具条:固定锚定在节点下方,
*/
export function MediaVideoEraseToolbar({
frameRect,
boxCount,
canUndo,
canRedo,
canConfirm,
onDeleteSelected,
onClear,
onUndo,
onRedo,
onReset,
onCancel,
onConfirm,
}: MediaVideoEraseToolbarProps) {
if (!frameRect) return null;
const centerX = frameRect.left + frameRect.width / 2;
const topToolbarStyle = {
left: centerX,
top: frameRect.top,
transform: "translate(-50%, calc(-100% - 10px))",
width: "max-content",
maxWidth: "calc(100vw - 32px)",
} as CSSProperties;
const { t } = useI18n();
return (
<div
className="nodrag nopan nowheel absolute z-[10005] flex items-center gap-2 whitespace-nowrap rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] px-3 py-2 text-sm text-[var(--text-primary)] shadow-[var(--shadow-panel)]"
style={topToolbarStyle}
<NodeActionCapsuleShell
topClassName="top-[calc(100%+12px)] z-[10005]"
className="nowheel min-w-[320px] justify-between gap-2 whitespace-nowrap px-3 py-2 text-sm text-[var(--text-primary)]"
>
<button
type="button"
aria-label="关闭框选擦除"
aria-label={t("videoErase.closeBoxErase")}
title={t("videoErase.closeBoxErase")}
onClick={onCancel}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-base text-[var(--text-secondary)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
>
<CloseOutlined />
</button>
<span className="shrink-0 text-sm font-medium text-[var(--text-primary)]"></span>
<span className="shrink-0 text-xs text-[var(--text-muted)]">{boxCount} </span>
<span className="shrink-0 text-sm font-medium text-[var(--text-primary)]">{t("videoErase.boxErase")}</span>
<span className="shrink-0 text-xs text-[var(--text-muted)]">{t("videoErase.boxCount", { count: boxCount })}</span>
<div className="mx-1 h-6 w-px shrink-0 bg-[var(--border-subtle)]" />
<Tooltip title="删除选中框">
<Tooltip title={t("videoErase.undo")}>
<Button
type="text"
icon={<DeleteOutlined />}
disabled={boxCount === 0}
onClick={onDeleteSelected}
icon={<UndoOutlined />}
disabled={!canUndo}
onClick={onUndo}
className="!h-9 !w-9 !shrink-0 !rounded-md"
/>
</Tooltip>
<Tooltip title="清空全部">
<Tooltip title={t("videoErase.redo")}>
<Button
type="text"
icon={<ClearOutlined />}
icon={<RedoOutlined />}
disabled={!canRedo}
onClick={onRedo}
className="!h-9 !w-9 !shrink-0 !rounded-md"
/>
</Tooltip>
<Tooltip title={t("videoErase.reset")}>
<Button
type="text"
icon={<ReloadOutlined />}
disabled={boxCount === 0}
onClick={onClear}
onClick={onReset}
className="!h-9 !w-9 !shrink-0 !rounded-md"
/>
</Tooltip>
<div className="mx-1 h-6 w-px shrink-0 bg-[var(--border-subtle)]" />
<button
type="button"
aria-label="生成框选擦除"
aria-label={t("videoErase.generateBoxErase")}
title={t("videoErase.generateBoxErase")}
disabled={!canConfirm}
onClick={onConfirm}
className="flex h-9 shrink-0 items-center gap-2 rounded-lg bg-[var(--text-primary)] px-4 text-sm font-semibold text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-not-allowed disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
>
<GenerateActionIcon />
<span></span>
</button>
</div>
</NodeActionCapsuleShell>
);
}
@ -101,6 +104,8 @@ interface MediaVideoSmartEraseToolbarProps {
* "AI一键去除视频字幕", 207
*/
export function MediaVideoSmartEraseToolbar({ busy = false, onCancel, onConfirm }: MediaVideoSmartEraseToolbarProps) {
const { t } = useI18n();
return (
<NodeActionCapsuleShell
topClassName="top-[calc(100%+12px)] z-[10005]"
@ -108,7 +113,8 @@ export function MediaVideoSmartEraseToolbar({ busy = false, onCancel, onConfirm
>
<button
type="button"
aria-label="关闭智能擦除"
aria-label={t("videoErase.closeSmartErase")}
title={t("videoErase.closeSmartErase")}
disabled={busy}
onClick={onCancel}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-base text-[var(--text-secondary)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)] disabled:opacity-70"
@ -116,18 +122,17 @@ export function MediaVideoSmartEraseToolbar({ busy = false, onCancel, onConfirm
<CloseOutlined />
</button>
<div className="flex min-w-0 flex-col leading-tight">
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<span className="text-xs text-[var(--text-muted)]">AI ,</span>
<span className="text-sm font-semibold text-[var(--text-primary)]">{t("videoErase.smartErase")}</span>
</div>
<button
type="button"
aria-label="生成智能擦除"
aria-label={t("videoErase.generateSmartErase")}
title={t("videoErase.generateSmartErase")}
disabled={busy}
onClick={onConfirm}
className="ml-auto flex h-9 shrink-0 items-center gap-2 rounded-lg bg-[var(--text-primary)] px-4 text-sm font-semibold text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-wait disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
className="ml-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--text-primary)] text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-wait disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
>
<GenerateActionIcon />
<span></span>
</button>
</NodeActionCapsuleShell>
);

24
src/components/media/video-edit/VideoBoxEraseSession.tsx

@ -6,7 +6,7 @@ import type { ProviderModel } from "@/lib/providers/types";
import { useWorkflowStore } from "@/store/workflowStore";
import {
MediaVideoEraseOverlay,
type VideoEraseFrameRect,
type VideoEraseHistoryState,
} from "@/components/media/MediaVideoEraseOverlay";
import { MediaVideoEraseToolbar } from "@/components/media/MediaVideoEraseToolbar";
import {
@ -41,9 +41,10 @@ export function VideoBoxEraseSession({
onClose,
}: VideoBoxEraseSessionProps) {
const [ratioBoxes, setRatioBoxes] = useState<VideoEraseRatioBox[]>([]);
const [frameRect, setFrameRect] = useState<VideoEraseFrameRect | null>(null);
const [clearSignal, setClearSignal] = useState(0);
const [deleteSelectedSignal, setDeleteSelectedSignal] = useState(0);
const [undoSignal, setUndoSignal] = useState(0);
const [redoSignal, setRedoSignal] = useState(0);
const [resetSignal, setResetSignal] = useState(0);
const [historyState, setHistoryState] = useState<VideoEraseHistoryState>({ canUndo: false, canRedo: false });
const busyRef = useRef(false);
const addNode = useWorkflowStore((state) => state.addNode);
@ -82,17 +83,20 @@ export function VideoBoxEraseSession({
mediaElementRef={videoRef}
naturalWidth={dimensions?.width}
naturalHeight={dimensions?.height}
clearSignal={clearSignal}
deleteSelectedSignal={deleteSelectedSignal}
undoSignal={undoSignal}
redoSignal={redoSignal}
resetSignal={resetSignal}
onBoxesChange={setRatioBoxes}
onFrameChange={setFrameRect}
onHistoryStateChange={setHistoryState}
/>
<MediaVideoEraseToolbar
frameRect={frameRect}
boxCount={ratioBoxes.length}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
canConfirm={ratioBoxes.length > 0}
onDeleteSelected={() => setDeleteSelectedSignal((value) => value + 1)}
onClear={() => setClearSignal((value) => value + 1)}
onUndo={() => setUndoSignal((value) => value + 1)}
onRedo={() => setRedoSignal((value) => value + 1)}
onReset={() => setResetSignal((value) => value + 1)}
onCancel={onClose}
onConfirm={handleConfirm}
/>

40
src/components/nodes/GenerateVideoNode.tsx

@ -36,8 +36,8 @@ import { createVideoFrameSmartImageNode } from "@/utils/videoFrameNode";
import { chooseHighDefinitionVideoModel, createHighDefinitionVideoNode } from "@/utils/highDefinitionVideoNodes";
import { isVideoEraseDerivedNode } from "@/utils/videoEraseNodes";
import { getActiveNodeTool, useNodeToolStore } from "@/store/nodeToolStore";
import { createMediaEditActionDropdown } from "@/components/media/MediaEditActionDropdown";
import { BoxEraseMenuIcon, SmartEraseMenuIcon, VideoEraseMenuIcon } from "@/components/media/MediaToolbarIcons";
import { createVideoEraseActionPanel } from "@/components/media/MediaEditActionDropdown";
import { VideoEraseMenuIcon } from "@/components/media/MediaToolbarIcons";
const VIDEO_LOAD_RETRY_DELAYS_MS = [600, 1600];
@ -434,37 +434,13 @@ export function GenerateVideoNodeView({ id, data, selected, suppressEmptyStateDe
handleCreateHighDefinitionVideoNode();
},
},
createMediaEditActionDropdown({
label: "智能去字幕",
createVideoEraseActionPanel({
label: t("videoErase.subtitleRemoval"),
icon: <VideoEraseMenuIcon className="h-6 w-6" />,
onClick: (event) => {
event.preventDefault();
event.stopPropagation();
handleSmartErase();
},
items: [
{
key: "smartErase",
icon: <SmartEraseMenuIcon className="h-4 w-4" />,
label: "智能擦除",
},
{
key: "boxErase",
icon: <BoxEraseMenuIcon className="h-4 w-4" />,
label: "框选擦除",
},
],
menuOnClick: ({ key, domEvent }) => {
domEvent.preventDefault();
domEvent.stopPropagation();
if (key === "smartErase") {
handleSmartErase();
return;
}
if (key === "boxErase") {
handleBoxErase();
}
},
smartEraseLabel: t("videoErase.smartErase"),
boxEraseLabel: t("videoErase.boxErase"),
onSmartErase: handleSmartErase,
onBoxErase: handleBoxErase,
}),
{
key: "save-to-assets",

40
src/components/nodes/VideoInputNode.tsx

@ -25,8 +25,8 @@ import { createVideoFrameSmartImageNode } from "@/utils/videoFrameNode";
import { useModelStore } from "@/store/modelStore";
import { chooseHighDefinitionVideoModel, createHighDefinitionVideoNode } from "@/utils/highDefinitionVideoNodes";
import { getActiveNodeTool, useNodeToolStore } from "@/store/nodeToolStore";
import { createMediaEditActionDropdown } from "@/components/media/MediaEditActionDropdown";
import { BoxEraseMenuIcon, SmartEraseMenuIcon, VideoEraseMenuIcon } from "@/components/media/MediaToolbarIcons";
import { createVideoEraseActionPanel } from "@/components/media/MediaEditActionDropdown";
import { VideoEraseMenuIcon } from "@/components/media/MediaToolbarIcons";
type VideoInputNodeType = Node<VideoInputNodeData, "videoInput">;
const VIDEO_INPUT_DIMENSIONS = defaultNodeDimensions.videoInput;
@ -322,37 +322,13 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro
handleCreateHighDefinitionVideoNode();
},
},
createMediaEditActionDropdown({
label: "智能去字幕",
createVideoEraseActionPanel({
label: t("videoErase.subtitleRemoval"),
icon: <VideoEraseMenuIcon className="h-6 w-6" />,
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
handleSmartErase();
},
items: [
{
key: "smartErase",
icon: <SmartEraseMenuIcon className="h-4 w-4" />,
label: "智能擦除",
},
{
key: "boxErase",
icon: <BoxEraseMenuIcon className="h-4 w-4" />,
label: "框选擦除",
},
],
menuOnClick: ({ key, domEvent }) => {
domEvent.preventDefault();
domEvent.stopPropagation();
if (key === "smartErase") {
handleSmartErase();
return;
}
if (key === "boxErase") {
handleBoxErase();
}
},
smartEraseLabel: t("videoErase.smartErase"),
boxEraseLabel: t("videoErase.boxErase"),
onSmartErase: handleSmartErase,
onBoxErase: handleBoxErase,
}),
{
key: "save-to-assets",

44
src/i18n/index.tsx

@ -502,6 +502,17 @@ const en = {
"videoInput.dropVideo": "Drop video or click",
"videoInput.download": "Download video",
"videoInput.remove": "Remove video",
"videoErase.subtitleRemoval": "Subtitle removal",
"videoErase.smartErase": "Smart erase",
"videoErase.boxErase": "Box erase",
"videoErase.boxCount": "{count} boxes",
"videoErase.undo": "Undo",
"videoErase.redo": "Redo",
"videoErase.reset": "Reset",
"videoErase.closeSmartErase": "Close smart erase",
"videoErase.closeBoxErase": "Close box erase",
"videoErase.generateSmartErase": "Generate smart erase",
"videoErase.generateBoxErase": "Generate box erase",
"audioInput.upload": "Upload audio file",
"audioInput.dropAudio": "Drop audio or click",
"audioInput.download": "Download audio",
@ -1562,6 +1573,17 @@ const zhCN: Dictionary = {
"videoInput.dropVideo": "拖入视频或点击",
"videoInput.download": "下载视频",
"videoInput.remove": "移除视频",
"videoErase.subtitleRemoval": "智能去字幕",
"videoErase.smartErase": "智能擦除",
"videoErase.boxErase": "框选擦除",
"videoErase.boxCount": "{count} 个框",
"videoErase.undo": "撤销",
"videoErase.redo": "重做",
"videoErase.reset": "重置",
"videoErase.closeSmartErase": "关闭智能擦除",
"videoErase.closeBoxErase": "关闭框选擦除",
"videoErase.generateSmartErase": "生成智能擦除",
"videoErase.generateBoxErase": "生成框选擦除",
"audioInput.upload": "上传音频文件",
"audioInput.dropAudio": "拖入音频或点击",
"audioInput.download": "下载音频",
@ -2462,6 +2484,17 @@ const zhTW: Dictionary = {
"videoInput.dropVideo": "拖入影片或點擊",
"videoInput.download": "下載影片",
"videoInput.remove": "移除影片",
"videoErase.subtitleRemoval": "智慧去字幕",
"videoErase.smartErase": "智慧擦除",
"videoErase.boxErase": "框選擦除",
"videoErase.boxCount": "{count} 個框",
"videoErase.undo": "復原",
"videoErase.redo": "重做",
"videoErase.reset": "重置",
"videoErase.closeSmartErase": "關閉智慧擦除",
"videoErase.closeBoxErase": "關閉框選擦除",
"videoErase.generateSmartErase": "生成智慧擦除",
"videoErase.generateBoxErase": "生成框選擦除",
"audioInput.upload": "上傳音訊檔",
"audioInput.dropAudio": "拖入音訊或點擊",
"audioInput.download": "下載音訊",
@ -3203,6 +3236,17 @@ const ja: Dictionary = {
"videoInput.dropVideo": "動画をドロップまたはクリック",
"videoInput.download": "動画をダウンロード",
"videoInput.remove": "動画を削除",
"videoErase.subtitleRemoval": "字幕を削除",
"videoErase.smartErase": "スマート消去",
"videoErase.boxErase": "範囲消去",
"videoErase.boxCount": "{count} 個の範囲",
"videoErase.undo": "元に戻す",
"videoErase.redo": "やり直し",
"videoErase.reset": "リセット",
"videoErase.closeSmartErase": "スマート消去を閉じる",
"videoErase.closeBoxErase": "範囲消去を閉じる",
"videoErase.generateSmartErase": "スマート消去を生成",
"videoErase.generateBoxErase": "範囲消去を生成",
"audioInput.upload": "音声ファイルをアップロード",
"audioInput.dropAudio": "音声をドロップまたはクリック",
"audioInput.download": "音声をダウンロード",

7
src/store/appConfigStore.ts

@ -3,14 +3,13 @@
import { create } from "zustand";
import { authFetch } from "@/utils/authFetch";
import {
DEFAULT_PREVIEW_IMAGE_SUFFIX,
readPreviewImageProcessConfig,
type PreviewImageSuffix,
} from "@/utils/previewImageUrl";
type AppConfigState = {
rawConfig: unknown | null;
previewImageSuffix: PreviewImageSuffix;
previewImageSuffix: PreviewImageSuffix | null;
loading: boolean;
error: string | null;
loadedAt: number | null;
@ -22,7 +21,7 @@ let configRequest: Promise<unknown | null> | null = null;
export const useAppConfigStore = create<AppConfigState>((set, get) => ({
rawConfig: null,
previewImageSuffix: DEFAULT_PREVIEW_IMAGE_SUFFIX,
previewImageSuffix: null,
loading: false,
error: null,
loadedAt: null,
@ -56,7 +55,7 @@ export const useAppConfigStore = create<AppConfigState>((set, get) => ({
set({
loading: false,
error: error instanceof Error ? error.message : "Failed to load app config",
previewImageSuffix: DEFAULT_PREVIEW_IMAGE_SUFFIX,
previewImageSuffix: null,
});
return null;
})

41
src/utils/__tests__/previewImageUrl.test.ts

@ -5,14 +5,14 @@ import {
} from "@/utils/previewImageUrl";
describe("previewImageUrl", () => {
it("adds default OSS suffix to an image url without query", () => {
expect(buildPreviewImageUrl("https://static.popi.art/media/image/a.png")).toBe(
"https://static.popi.art/media/image/a.png?imageMogr2/format/webp/quality/80"
it("returns the original image url when no OSS suffix is configured", () => {
expect(buildPreviewImageUrl("https://static.popi.art/media/image/a.png", null)).toBe(
"https://static.popi.art/media/image/a.png"
);
});
it("preserves existing query params when adding default OSS suffix", () => {
expect(buildPreviewImageUrl("https://static.popi.art/media/image/a.png?x=1")).toBe(
it("preserves existing query params when adding configured OSS suffix", () => {
expect(buildPreviewImageUrl("https://static.popi.art/media/image/a.png?x=1", "imageMogr2/format/webp/quality/80")).toBe(
"https://static.popi.art/media/image/a.png?x=1&imageMogr2/format/webp/quality/80"
);
});
@ -24,9 +24,9 @@ describe("previewImageUrl", () => {
});
it("does not rewrite non-http urls", () => {
expect(buildPreviewImageUrl("data:image/png;base64,abc")).toBe("data:image/png;base64,abc");
expect(buildPreviewImageUrl("blob:https://example.com/abc")).toBe("blob:https://example.com/abc");
expect(buildPreviewImageUrl(null)).toBeNull();
expect(buildPreviewImageUrl("data:image/png;base64,abc", "?imageMogr2/format/webp/quality/60")).toBe("data:image/png;base64,abc");
expect(buildPreviewImageUrl("blob:https://example.com/abc", "?imageMogr2/format/webp/quality/60")).toBe("blob:https://example.com/abc");
expect(buildPreviewImageUrl(null, "?imageMogr2/format/webp/quality/60")).toBeNull();
});
it("reads ossImageThumbSuffix from nested system config data", () => {
@ -41,7 +41,21 @@ describe("previewImageUrl", () => {
).toBe("?imageMogr2/format/webp/quality/60");
});
it("reads ossImageThumbSuffix from content config", () => {
it("reads ossImageThumbSuffix from top-level system config", () => {
expect(
readPreviewImageProcessConfig({
systemConfig: {
ossImageThumbSuffix: "imageMogr2/format/webp/quality/70",
},
})
).toBe("?imageMogr2/format/webp/quality/70");
});
it("returns null when system config suffix is missing", () => {
expect(readPreviewImageProcessConfig({ data: { systemConfig: {}, contentConfig: {} } })).toBeNull();
});
it("does not read ossImageThumbSuffix from content config or response top level", () => {
expect(
readPreviewImageProcessConfig({
data: {
@ -50,13 +64,8 @@ describe("previewImageUrl", () => {
ossImageThumbSuffix: "imageMogr2/format/webp/quality/70",
},
},
ossImageThumbSuffix: "imageMogr2/format/webp/quality/60",
})
).toBe("?imageMogr2/format/webp/quality/70");
});
it("falls back to default suffix when config is missing", () => {
expect(readPreviewImageProcessConfig({ data: { systemConfig: {}, contentConfig: {} } })).toBe(
"?imageMogr2/format/webp/quality/80"
);
).toBeNull();
});
});

18
src/utils/previewImageUrl.ts

@ -1,5 +1,3 @@
export const DEFAULT_PREVIEW_IMAGE_SUFFIX = "?imageMogr2/format/webp/quality/80";
export type PreviewImageSuffix = string;
function isRecord(value: unknown): value is Record<string, unknown> {
@ -18,20 +16,14 @@ function readOssImageThumbSuffix(configSection: unknown): PreviewImageSuffix | n
return normalizePreviewImageSuffix(configSection.ossImageThumbSuffix);
}
export function readPreviewImageProcessConfig(configResponse: unknown): PreviewImageSuffix {
export function readPreviewImageProcessConfig(configResponse: unknown): PreviewImageSuffix | null {
if (isRecord(configResponse)) {
const data = isRecord(configResponse.data) ? configResponse.data : configResponse;
const systemConfig = readOssImageThumbSuffix(data.systemConfig);
if (systemConfig) return systemConfig;
const contentConfig = readOssImageThumbSuffix(data.contentConfig);
if (contentConfig) return contentConfig;
const topLevelConfig = readOssImageThumbSuffix(data);
if (topLevelConfig) return topLevelConfig;
}
return DEFAULT_PREVIEW_IMAGE_SUFFIX;
return null;
}
export function isHttpImagePreviewCandidate(url: string | null | undefined): url is string {
@ -40,12 +32,14 @@ export function isHttpImagePreviewCandidate(url: string | null | undefined): url
export function buildPreviewImageUrl(
originalUrl: string | null | undefined,
suffix: PreviewImageSuffix | null | undefined = DEFAULT_PREVIEW_IMAGE_SUFFIX
suffix: PreviewImageSuffix | null | undefined
): string | null {
if (!originalUrl) return originalUrl ?? null;
if (!isHttpImagePreviewCandidate(originalUrl)) return originalUrl;
const previewSuffix = normalizePreviewImageSuffix(suffix) ?? DEFAULT_PREVIEW_IMAGE_SUFFIX;
const previewSuffix = normalizePreviewImageSuffix(suffix);
if (!previewSuffix) return originalUrl;
const separator = originalUrl.includes("?") ? "&" : "?";
const normalizedSuffix = previewSuffix.replace(/^[?&]/, "");
return `${originalUrl}${separator}${normalizedSuffix}`;

Loading…
Cancel
Save