Browse Source

视频使用首帧 的压缩图来展示

feature/video-erase
Luckyu_js 2 weeks ago
parent
commit
3605063695
  1. 1
      src/app/api/config/route.ts
  2. 6
      src/components/AssetLibraryDrawer.tsx
  3. 6
      src/components/AssetLibraryManagerModal.tsx
  4. 4
      src/components/AssetPickerModal.tsx
  5. 7
      src/components/MyCreationPickerModal.tsx
  6. 3
      src/components/WorkflowCanvas.tsx
  7. 21
      src/components/media/CustomVideoPlayer.tsx
  8. 13
      src/lib/popiAssetApi.ts
  9. 8
      src/store/appConfigStore.ts
  10. 82
      src/utils/__tests__/videoPosterUrl.test.ts
  11. 54
      src/utils/videoPosterUrl.ts

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

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

6
src/components/AssetLibraryDrawer.tsx

@ -20,7 +20,7 @@ import { useI18n } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { authFetch } from "@/utils/authFetch";
import { createSmartMediaNode, detectSmartMediaKind } from "@/utils/smartMediaNode";
import { toPreviewImageUrl } from "@/lib/popiAssetApi";
import { toPreviewImageUrl, toVideoPosterUrl } from "@/lib/popiAssetApi";
import {
ASSET_LIBRARY_ITEM_DRAG_MIME,
ASSET_LIBRARY_ITEM_KIND_DRAG_MIME_PREFIX,
@ -99,7 +99,7 @@ function getFileKind(item: UserFileItem): AssetLibraryFileKind {
function getTreeThumbUrl(item: UserFileItem): string | null {
const kind = getFileKind(item);
if (kind === "image") return item.url ? toPreviewImageUrl(item.url) : null;
if (kind === "video") return item.coverUrl || null;
if (kind === "video") return toVideoPosterUrl(item.url) || item.coverUrl || null;
return null;
}
@ -110,7 +110,7 @@ function getHoverMediaUrl(item: UserFileItem): string | null {
}
function getVideoPosterUrl(item: UserFileItem): string | null {
return item.coverUrl || null;
return toVideoPosterUrl(item.url) || item.coverUrl || null;
}
function formatCreateTime(value?: string) {

6
src/components/AssetLibraryManagerModal.tsx

@ -29,7 +29,7 @@ import { useWorkflowStore } from "@/store/workflowStore";
import { authFetch } from "@/utils/authFetch";
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload";
import { createSmartMediaNode, detectSmartMediaKind } from "@/utils/smartMediaNode";
import { toPreviewImageUrl } from "@/lib/popiAssetApi";
import { toPreviewImageUrl, toVideoPosterUrl } from "@/lib/popiAssetApi";
import {
getUserFileList,
ROOT_PARENT_ID,
@ -81,7 +81,7 @@ function getFileKind(item: UserFileItem): AssetLibraryFileKind {
function getPreviewUrl(item: UserFileItem): string | null {
const kind = getFileKind(item);
if (kind === "image") return item.url ? toPreviewImageUrl(item.url) : null;
if (kind === "video") return item.coverUrl || item.url || null;
if (kind === "video") return toVideoPosterUrl(item.url) || item.coverUrl || item.url || null;
return null;
}
@ -577,7 +577,7 @@ export function AssetLibraryManagerModal({ open, onClose, onChanged }: AssetLibr
url: file.url,
filename: file.name || `${t("assetLibrary.asset")} ${file.id}`,
mimeType: file.mimeType,
previewUrl: kind === "video" ? file.coverUrl || null : null,
previewUrl: kind === "video" ? toVideoPosterUrl(file.url) || file.coverUrl || null : null,
assetId: kind === "image" ? file.mediaId : file.id,
},
position,

4
src/components/AssetPickerModal.tsx

@ -5,7 +5,7 @@ import { createPortal } from "react-dom";
import { Pagination } from "antd";
import { authFetch } from "@/utils/authFetch";
import { useI18n } from "@/i18n";
import { toPreviewImageUrl } from "@/lib/popiAssetApi";
import { toPreviewImageUrl, toVideoPosterUrl } from "@/lib/popiAssetApi";
export type AssetPickerKind = "image" | "video";
@ -57,7 +57,7 @@ function getAssetPreviewUrl(asset: PopiAssetItem, kind: AssetPickerKind): string
const originalUrl = asset.images?.find(Boolean);
return originalUrl ? toPreviewImageUrl(originalUrl) : null;
}
return asset.cover || asset.video || null;
return toVideoPosterUrl(asset.video) || asset.cover || asset.video || null;
}
function formatDuration(seconds?: number) {

7
src/components/MyCreationPickerModal.tsx

@ -10,7 +10,7 @@ import { useI18n, type TranslationKey } from "@/i18n";
import { useWorkflowStore } from "@/store/workflowStore";
import { authFetch } from "@/utils/authFetch";
import { createSmartMediaNode, type SmartMediaKind } from "@/utils/smartMediaNode";
import { toPreviewImageUrl } from "@/lib/popiAssetApi";
import { toPreviewImageUrl, toVideoPosterUrl } from "@/lib/popiAssetApi";
type MainTabKey = "history" | "characters";
type HistoryMediaTabKey = "image" | "video" | "audio";
@ -180,7 +180,10 @@ function getHistoryPreviewUrl(item: HistoryItem, kind: HistoryTabKey): string |
const originalUrl = firstString(result?.images || undefined);
return originalUrl ? toPreviewImageUrl(originalUrl) : null;
}
if (mediaKind === "video") return result?.cover || result?.video || null;
if (mediaKind === "video") {
const videoUrl = result?.video || item.video || null;
return toVideoPosterUrl(videoUrl) || result?.cover || videoUrl || null;
}
return null;
}

3
src/components/WorkflowCanvas.tsx

@ -53,6 +53,7 @@ import {
import { ChatPanel, type ChatRunAction } from "./ChatPanel";
import { EditOperation } from "@/lib/chat/editOperations";
import { stripBinaryData } from "@/lib/chat/contextBuilder";
import { toVideoPosterUrl } from "@/lib/popiAssetApi";
import { PromptConstructorEditorModal } from "./modals/PromptConstructorEditorModal";
import { MarkdownEditorModal } from "./modals/MarkdownEditorModal";
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
@ -2713,7 +2714,7 @@ export function WorkflowCanvas() {
url: item.url,
filename: item.name,
mimeType: item.mimeType,
previewUrl: kind === "video" ? item.coverUrl || null : null,
previewUrl: kind === "video" ? toVideoPosterUrl(item.url) || item.coverUrl || null : null,
assetId: kind === "image" ? item.mediaId : item.id,
},
position,

21
src/components/media/CustomVideoPlayer.tsx

@ -4,6 +4,8 @@ import { Popover } from "antd";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { preventVideoDoubleClick } from "@/hooks/usePreventVideoFullscreen";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { useAppConfigStore } from "@/store/appConfigStore";
import { buildVideoPosterUrl } from "@/utils/videoPosterUrl";
interface CustomVideoPlayerProps {
src: string;
@ -71,11 +73,18 @@ export function CustomVideoPlayer({
const [volume, setVolume] = useState(muted ? 0 : 1);
const [isMuted, setIsMuted] = useState(muted);
const videoPosterQuality = useAppConfigStore((state) => state.videoPosterQuality);
// 视频封面统一由视频地址现拼(腾讯云 CI 截帧);非 http(s) 地址回退到传入的 poster。
const resolvedPoster = useMemo(
() => buildVideoPosterUrl(src, videoPosterQuality) ?? poster ?? null,
[src, poster, videoPosterQuality]
);
const shouldShowPoster = Boolean(
poster && (isRunning || (!isHovered && !selected && !isManuallyPaused && !isScrubbing))
resolvedPoster && (isRunning || (!isHovered && !selected && !isManuallyPaused && !isScrubbing))
);
const shouldMountSrc = Boolean(
!isRunning && (!poster || isHovered || selected || isManuallyPaused || isScrubbing || isPlaying)
!isRunning && (!resolvedPoster || isHovered || selected || isManuallyPaused || isScrubbing || isPlaying)
);
const playbackUrl = useVideoBlobUrl(shouldMountSrc ? src : null);
const progress = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0;
@ -296,9 +305,9 @@ export function CustomVideoPlayer({
<video
ref={videoRef}
src={playbackUrl ?? undefined}
poster={poster ?? undefined}
poster={resolvedPoster ?? undefined}
className={videoClassName}
preload={poster ? "none" : "metadata"}
preload={resolvedPoster ? "none" : "metadata"}
loop={loop}
muted={muted}
playsInline
@ -328,9 +337,9 @@ export function CustomVideoPlayer({
onError={onError}
/>
{poster && shouldShowPoster && (
{resolvedPoster && shouldShowPoster && (
<img
src={poster}
src={resolvedPoster}
alt=""
className="pointer-events-none absolute inset-0 h-full w-full object-contain"
/>

13
src/lib/popiAssetApi.ts

@ -2,6 +2,7 @@
import { useAppConfigStore } from "@/store/appConfigStore";
import { buildPreviewImageUrl } from "@/utils/previewImageUrl";
import { buildVideoPosterUrl } from "@/utils/videoPosterUrl";
export interface PopiAssetMediaItem {
id: number;
@ -55,6 +56,16 @@ export function toPreviewImageUrl(originalUrl: string | null | undefined): strin
return buildPreviewImageUrl(originalUrl, suffix) ?? originalUrl ?? "";
}
/**
* URL CI snapshot + imageMogr2
* cover/coverThumb
* http(s) null退
*/
export function toVideoPosterUrl(videoUrl: string | null | undefined): string | null {
const quality = useAppConfigStore.getState().videoPosterQuality;
return buildVideoPosterUrl(videoUrl, quality);
}
export function getPopiImageAssetPreview(asset: PopiAssetMediaItem): ResolvedPopiImageAsset | null {
const imageUrl = firstString(asset.images);
if (!imageUrl) return null;
@ -69,7 +80,7 @@ export function getPopiImageAssetPreview(asset: PopiAssetMediaItem): ResolvedPop
}
export function getPopiVideoAssetPreview(asset: PopiAssetMediaItem): ResolvedPopiVideoAsset | null {
const previewPosterUrl = asset.cover || null;
const previewPosterUrl = toVideoPosterUrl(asset.video) || asset.cover || null;
if (!asset.video && !previewPosterUrl) return null;
return {

8
src/store/appConfigStore.ts

@ -6,10 +6,15 @@ import {
readPreviewImageProcessConfig,
type PreviewImageSuffix,
} from "@/utils/previewImageUrl";
import {
DEFAULT_VIDEO_POSTER_QUALITY,
readVideoPosterQualityConfig,
} from "@/utils/videoPosterUrl";
type AppConfigState = {
rawConfig: unknown | null;
previewImageSuffix: PreviewImageSuffix | null;
videoPosterQuality: number;
loading: boolean;
error: string | null;
loadedAt: number | null;
@ -22,6 +27,7 @@ let configRequest: Promise<unknown | null> | null = null;
export const useAppConfigStore = create<AppConfigState>((set, get) => ({
rawConfig: null,
previewImageSuffix: null,
videoPosterQuality: DEFAULT_VIDEO_POSTER_QUALITY,
loading: false,
error: null,
loadedAt: null,
@ -45,6 +51,8 @@ export const useAppConfigStore = create<AppConfigState>((set, get) => ({
set({
rawConfig: payload,
previewImageSuffix,
videoPosterQuality:
readVideoPosterQualityConfig(payload) ?? DEFAULT_VIDEO_POSTER_QUALITY,
loading: false,
error: null,
loadedAt: Date.now(),

82
src/utils/__tests__/videoPosterUrl.test.ts

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_VIDEO_POSTER_QUALITY,
buildVideoPosterUrl,
normalizeVideoPosterQuality,
readVideoPosterQualityConfig,
} from "@/utils/videoPosterUrl";
const VIDEO_URL = "https://statictest.popi.art/media/video/2026/0714/11768.mp4";
describe("buildVideoPosterUrl", () => {
it("appends the Tencent CI snapshot params with the default quality", () => {
expect(buildVideoPosterUrl(VIDEO_URL)).toBe(
`${VIDEO_URL}?ci-process=snapshot&time=1&format=jpg&imageMogr2/format/webp/quality/${DEFAULT_VIDEO_POSTER_QUALITY}`
);
});
it("uses the provided quality", () => {
expect(buildVideoPosterUrl(VIDEO_URL, 60)).toBe(
`${VIDEO_URL}?ci-process=snapshot&time=1&format=jpg&imageMogr2/format/webp/quality/60`
);
});
it("uses & separator when the url already has a query string", () => {
expect(buildVideoPosterUrl(`${VIDEO_URL}?token=abc`, 80)).toBe(
`${VIDEO_URL}?token=abc&ci-process=snapshot&time=1&format=jpg&imageMogr2/format/webp/quality/80`
);
});
it("falls back to the default quality when the value is out of range or invalid", () => {
const expected = `${VIDEO_URL}?ci-process=snapshot&time=1&format=jpg&imageMogr2/format/webp/quality/${DEFAULT_VIDEO_POSTER_QUALITY}`;
expect(buildVideoPosterUrl(VIDEO_URL, 0)).toBe(expected);
expect(buildVideoPosterUrl(VIDEO_URL, 200)).toBe(expected);
expect(buildVideoPosterUrl(VIDEO_URL, Number.NaN)).toBe(expected);
});
it("returns null for non-http urls and empty values", () => {
expect(buildVideoPosterUrl(null)).toBeNull();
expect(buildVideoPosterUrl(undefined)).toBeNull();
expect(buildVideoPosterUrl("")).toBeNull();
expect(buildVideoPosterUrl("blob:https://example.com/abc")).toBeNull();
expect(buildVideoPosterUrl("data:video/mp4;base64,abc")).toBeNull();
});
});
describe("normalizeVideoPosterQuality", () => {
it("accepts numbers and numeric strings within [1, 100]", () => {
expect(normalizeVideoPosterQuality(80)).toBe(80);
expect(normalizeVideoPosterQuality("60")).toBe(60);
expect(normalizeVideoPosterQuality(75.4)).toBe(75);
});
it("rejects out-of-range or invalid values", () => {
expect(normalizeVideoPosterQuality(0)).toBeNull();
expect(normalizeVideoPosterQuality(101)).toBeNull();
expect(normalizeVideoPosterQuality("abc")).toBeNull();
expect(normalizeVideoPosterQuality(null)).toBeNull();
expect(normalizeVideoPosterQuality(undefined)).toBeNull();
});
});
describe("readVideoPosterQualityConfig", () => {
it("reads ossVideoSnapshotQuality from nested system config data", () => {
expect(
readVideoPosterQualityConfig({
data: { systemConfig: { ossVideoSnapshotQuality: 60 } },
})
).toBe(60);
});
it("reads ossVideoSnapshotQuality from top-level system config", () => {
expect(
readVideoPosterQualityConfig({ systemConfig: { ossVideoSnapshotQuality: "70" } })
).toBe(70);
});
it("returns null when the quality is missing or invalid", () => {
expect(readVideoPosterQualityConfig({ data: { systemConfig: {} } })).toBeNull();
expect(readVideoPosterQualityConfig({ systemConfig: { ossVideoSnapshotQuality: 0 } })).toBeNull();
expect(readVideoPosterQualityConfig(null)).toBeNull();
});
});

54
src/utils/videoPosterUrl.ts

@ -0,0 +1,54 @@
/**
* cover / coverThumb
*
* URL CI snapshot + imageMogr2
*
* https://static.example/xx.mp4
* -> https://static.example/xx.mp4?ci-process=snapshot&time=1&format=jpg&imageMogr2/format/webp/quality/80
*/
/** imageMogr2 压缩质量默认值(1-100),可由后端 systemConfig.ossVideoSnapshotQuality 覆盖。 */
export const DEFAULT_VIDEO_POSTER_QUALITY = 80;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
/** 归一化压缩质量:接受 number 或数字字符串,限制在 [1, 100],非法返回 null。 */
export function normalizeVideoPosterQuality(value: unknown): number | null {
const num = typeof value === "string" ? Number(value) : value;
if (typeof num !== "number" || !Number.isFinite(num)) return null;
const rounded = Math.round(num);
if (rounded < 1 || rounded > 100) return null;
return rounded;
}
/** 从 /api/config 响应中读取视频封面压缩质量配置,未配置返回 null。 */
export function readVideoPosterQualityConfig(configResponse: unknown): number | null {
if (!isRecord(configResponse)) return null;
const data = isRecord(configResponse.data) ? configResponse.data : configResponse;
const systemConfig = isRecord(data.systemConfig) ? data.systemConfig : null;
if (!systemConfig) return null;
return normalizeVideoPosterQuality(systemConfig.ossVideoSnapshotQuality);
}
/** 仅 http(s) 地址可做 COS 截帧;blob:/data: 等本地地址不支持。 */
export function isHttpVideoPosterCandidate(url: string | null | undefined): url is string {
return typeof url === "string" && /^https?:\/\//i.test(url);
}
/**
* URL URL
* http(s) blob:/data:/ null退 <video>
*/
export function buildVideoPosterUrl(
videoUrl: string | null | undefined,
quality: number = DEFAULT_VIDEO_POSTER_QUALITY
): string | null {
if (!isHttpVideoPosterCandidate(videoUrl)) return null;
const normalizedQuality = normalizeVideoPosterQuality(quality) ?? DEFAULT_VIDEO_POSTER_QUALITY;
const params = `ci-process=snapshot&time=1&format=jpg&imageMogr2/format/webp/quality/${normalizedQuality}`;
const separator = videoUrl.includes("?") ? "&" : "?";
return `${videoUrl}${separator}${params}`;
}
Loading…
Cancel
Save