([]);
+ const [uploadOpen, setUploadOpen] = useState(false);
+ const [reloadKey, setReloadKey] = useState(0);
+
+ const loadBase = async () => {
+ const [catRes, wmRes] = await Promise.all([
+ getHomeMaterialCategories({ page: 1, pageSize: 200 }),
+ getHomeMaterialWatermarks({ page: 1, pageSize: 200, isActive: true }),
+ ]);
+ setCategories(catRes.items);
+ setWatermarks(wmRes.items);
+ };
+
+ const loadConfig = async () => {
+ setConfigLoading(true);
+ try {
+ const cfg = await getHomeMaterialConfig();
+ form.setFieldsValue(cfg);
+ } finally {
+ setConfigLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadConfig();
+ loadBase();
+ }, []);
+
+ const saveConfig = async () => {
+ const values = await form.validateFields();
+ await saveHomeMaterialConfig(values);
+ message.success('配置已保存');
+ };
+
+ const refreshAll = async () => {
+ await loadBase();
+ setReloadKey(k => k + 1);
+ };
+
+ return (
+
+
+
+
+
+
+
+ } onClick={saveConfig}>保存配置
+
+
+
+
+
+
+ } onClick={() => setUploadOpen(true)}>上传素材
+
+ )}
+ >
+ ,
+ },
+ {
+ key: 'categories',
+ label: '行业类别',
+ children: ,
+ },
+ {
+ key: 'watermarks',
+ label: '水印库',
+ children: ,
+ },
+ ]}
+ />
+
+
+ setUploadOpen(false)}
+ categories={categories}
+ watermarks={watermarks}
+ onSuccess={refreshAll}
+ />
+
+ );
+};
+
+export default AdminHomeMaterials;
diff --git a/video-gen-admin/src/pages/homeMaterials/HomeMaterialAssetTable.tsx b/video-gen-admin/src/pages/homeMaterials/HomeMaterialAssetTable.tsx
new file mode 100644
index 00000000..8dd8caa9
--- /dev/null
+++ b/video-gen-admin/src/pages/homeMaterials/HomeMaterialAssetTable.tsx
@@ -0,0 +1,333 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Dropdown, Image, Modal, Select, Space, Table, Tag, message } from 'antd';
+import { DeleteOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
+import {
+ deleteHomeMaterialAsset,
+ getHomeMaterialAssetStatus,
+ getHomeMaterialAssets,
+ regenerateHomeMaterialWatermark,
+} from '../../api';
+import type {
+ HomeMaterialAsset,
+ HomeMaterialAssetQueryParams,
+ HomeMaterialCategory,
+ HomeMaterialWatermark,
+ HomeMaterialWatermarkConfig,
+ HomeMaterialTextWatermarkConfig,
+} from '../../types';
+import { apiUrl } from '../../utils/resourceUrl';
+import WatermarkEditor from './WatermarkEditor';
+
+interface Props {
+ categories: HomeMaterialCategory[];
+ watermarks: HomeMaterialWatermark[];
+ reloadKey?: number;
+}
+
+const statusMap: Record = {
+ draft: { text: '草稿', color: 'default' },
+ processing: { text: '处理中', color: 'processing' },
+ success: { text: '成功', color: 'success' },
+ failed: { text: '失败', color: 'error' },
+};
+
+const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
+ text: '民众普康 AI',
+ opacityLevel: 2,
+ fontSizePx: 28,
+ color: '#ffffff',
+ rotateDeg: -30,
+ gapX: 220,
+ gapY: 140,
+ staggered: true,
+};
+
+const defaultConfig: HomeMaterialWatermarkConfig = {
+ watermarkType: 'image',
+ watermarkId: null,
+ opacityLevel: 6,
+ position: 'bottom_right',
+ customXRatio: null,
+ customYRatio: null,
+ sizeMode: 'ratio',
+ widthRatio: 0.18,
+ widthPx: null,
+ marginX: 24,
+ marginY: 24,
+ textWatermark: defaultTextWatermark,
+};
+
+function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
+ return {
+ text: raw?.text || defaultTextWatermark.text,
+ opacityLevel: raw?.opacityLevel ?? raw?.opacity_level ?? defaultTextWatermark.opacityLevel,
+ fontSizePx: raw?.fontSizePx ?? raw?.font_size_px ?? defaultTextWatermark.fontSizePx,
+ color: raw?.color || defaultTextWatermark.color,
+ rotateDeg: raw?.rotateDeg ?? raw?.rotate_deg ?? defaultTextWatermark.rotateDeg,
+ gapX: raw?.gapX ?? raw?.gap_x ?? defaultTextWatermark.gapX,
+ gapY: raw?.gapY ?? raw?.gap_y ?? defaultTextWatermark.gapY,
+ staggered: raw?.staggered ?? defaultTextWatermark.staggered,
+ };
+}
+
+function normalizeConfig(raw: unknown, fallbackWatermarkId?: string | null): HomeMaterialWatermarkConfig {
+ const data = (raw || {}) as any;
+ const watermarkType = data.watermarkType || data.watermark_type || 'image';
+ return {
+ watermarkType,
+ watermarkId: watermarkType === 'repeated_text' ? null : (data.watermarkId || data.watermark_id || fallbackWatermarkId || null),
+ opacityLevel: data.opacityLevel || data.opacity_level || 6,
+ position: data.position || 'bottom_right',
+ customXRatio: data.customXRatio ?? data.custom_x_ratio ?? null,
+ customYRatio: data.customYRatio ?? data.custom_y_ratio ?? null,
+ sizeMode: data.sizeMode || data.size_mode || 'ratio',
+ widthRatio: data.widthRatio ?? data.width_ratio ?? 0.18,
+ widthPx: data.widthPx ?? data.width_px ?? null,
+ marginX: data.marginX ?? data.margin_x ?? 24,
+ marginY: data.marginY ?? data.margin_y ?? 24,
+ textWatermark: normalizeTextConfig(data.textWatermark || data.text_watermark),
+ };
+}
+
+function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
+ if (config.watermarkType === 'repeated_text') return !!config.textWatermark?.text?.trim();
+ return !!config.watermarkId;
+}
+
+const HomeMaterialAssetTable: React.FC = ({ categories, watermarks, reloadKey }) => {
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [filters, setFilters] = useState({ page: 1, pageSize: 20 });
+ const [preview, setPreview] = useState(null);
+ const [regen, setRegen] = useState(null);
+ const [regenConfig, setRegenConfig] = useState(defaultConfig);
+ const [regenSubmitting, setRegenSubmitting] = useState(false);
+
+ const load = async () => {
+ setLoading(true);
+ try {
+ const res = await getHomeMaterialAssets(filters);
+ setItems(res.items);
+ setTotal(res.total);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { load(); }, [filters, reloadKey]);
+
+ useEffect(() => {
+ const processing = items.filter(i => i.status === 'processing');
+ if (processing.length === 0) return;
+ const timer = window.setInterval(async () => {
+ const statuses = await Promise.all(processing.map(i => getHomeMaterialAssetStatus(i.id).catch(() => null)));
+ const changed = statuses.some(s => s && s.status !== 'processing');
+ if (changed) load();
+ }, 2500);
+ return () => window.clearInterval(timer);
+ }, [items]);
+
+ const openRegenerate = (row: HomeMaterialAsset) => {
+ setRegen(row);
+ setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
+ };
+
+ const getPreviewImageUrl = (row: HomeMaterialAsset) => {
+ if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
+ return apiUrl(row.coverUrl || '');
+ };
+
+ const getVideoUrl = (row: HomeMaterialAsset) => apiUrl(row.watermarkedUrl || row.originalUrl);
+
+ const confirmDelete = (row: HomeMaterialAsset) => {
+ Modal.confirm({
+ title: '确认删除该素材?',
+ content: row.title || row.id,
+ okText: '删除',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ async onOk() {
+ await deleteHomeMaterialAsset(row.id);
+ message.success('删除成功');
+ await load();
+ },
+ });
+ };
+
+ const submitRegenerate = async () => {
+ if (!regen || regenSubmitting) return;
+ if (!isValidConfig(regenConfig)) {
+ message.warning(regenConfig.watermarkType === 'repeated_text' ? '请输入重复文字水印内容' : '请选择水印图片');
+ return;
+ }
+ setRegenSubmitting(true);
+ try {
+ await regenerateHomeMaterialWatermark(regen.id, {
+ watermark_type: regenConfig.watermarkType,
+ watermark_id: regenConfig.watermarkType === 'image' ? regenConfig.watermarkId : null,
+ opacity_level: regenConfig.opacityLevel,
+ position: regenConfig.position,
+ custom_x_ratio: regenConfig.customXRatio,
+ custom_y_ratio: regenConfig.customYRatio,
+ size_mode: regenConfig.sizeMode,
+ width_ratio: regenConfig.widthRatio,
+ width_px: regenConfig.widthPx,
+ margin_x: regenConfig.marginX,
+ margin_y: regenConfig.marginY,
+ text_watermark: regenConfig.watermarkType === 'repeated_text' && regenConfig.textWatermark ? {
+ text: regenConfig.textWatermark.text,
+ opacity_level: regenConfig.textWatermark.opacityLevel,
+ font_size_px: regenConfig.textWatermark.fontSizePx,
+ color: regenConfig.textWatermark.color,
+ rotate_deg: regenConfig.textWatermark.rotateDeg,
+ gap_x: regenConfig.textWatermark.gapX,
+ gap_y: regenConfig.textWatermark.gapY,
+ staggered: regenConfig.textWatermark.staggered,
+ } : null,
+ wait: false,
+ });
+ message.success('已提交重新生成');
+ setRegen(null);
+ await load();
+ } finally {
+ setRegenSubmitting(false);
+ }
+ };
+
+ return (
+ <>
+
+
+ setFilters(f => ({ ...f, page, pageSize })) }}
+ scroll={{ x: 1180 }}
+ columns={[
+ {
+ title: '预览',
+ width: 110,
+ render: (_: unknown, row: HomeMaterialAsset) => {
+ const imageUrl = getPreviewImageUrl(row);
+ if (row.mediaType === 'image') {
+ return imageUrl ? : '-';
+ }
+ const videoUrl = getVideoUrl(row);
+ const posterUrl = apiUrl(row.coverUrl || '');
+ return videoUrl ? (
+
+ ) : '-';
+ },
+ },
+ { title: '标题', dataIndex: 'title', render: (v: string | null) => v || 未命名素材 },
+ { title: '行业', dataIndex: 'categoryName' },
+ { title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
+ { title: '状态', dataIndex: 'status', render: (v: string) => {statusMap[v]?.text || v} },
+ {
+ title: '水印',
+ render: (_: unknown, row: HomeMaterialAsset) => {
+ const cfg = normalizeConfig(row.watermarkConfig, row.watermarkId);
+ return cfg.watermarkType === 'repeated_text' ? '重复文字水印' : (row.watermarkName || '图片水印');
+ },
+ },
+ { title: '排序', dataIndex: 'sortOrder' },
+ { title: '错误', dataIndex: 'errorMessage', ellipsis: true },
+ {
+ title: '操作',
+ width: 150,
+ align: 'center' as const,
+ render: (_: unknown, row: HomeMaterialAsset) => (
+
+
+ , label: '重新生成水印' },
+ { key: 'delete', icon: , label: '删除素材', danger: true },
+ ],
+ onClick: ({ key }) => {
+ if (key === 'regenerate') openRegenerate(row);
+ if (key === 'delete') confirmDelete(row);
+ },
+ }}
+ >
+ }>更多
+
+
+ ),
+ },
+ ]}
+ />
+ setPreview(null)} footer={null} width={900} destroyOnHidden>
+ {preview && (
+
+ 原素材:{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}
+ 水印素材:{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}
+ {preview.mediaType === 'image' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ !regenSubmitting && setRegen(null)}
+ onOk={submitRegenerate}
+ confirmLoading={regenSubmitting}
+ okButtonProps={{ disabled: regenSubmitting || !isValidConfig(regenConfig) }}
+ cancelButtonProps={{ disabled: regenSubmitting }}
+ width={980}
+ destroyOnHidden
+ >
+ {regen && (
+
+ )}
+
+ >
+ );
+};
+
+export default HomeMaterialAssetTable;
diff --git a/video-gen-admin/src/pages/homeMaterials/HomeMaterialCategoryPanel.tsx b/video-gen-admin/src/pages/homeMaterials/HomeMaterialCategoryPanel.tsx
new file mode 100644
index 00000000..cae0d530
--- /dev/null
+++ b/video-gen-admin/src/pages/homeMaterials/HomeMaterialCategoryPanel.tsx
@@ -0,0 +1,156 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, message } from 'antd';
+import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
+import * as AntIcons from '@ant-design/icons';
+import { deleteHomeMaterialCategory, getHomeMaterialCategories, saveHomeMaterialCategory } from '../../api';
+import type { HomeMaterialCategory, HomeMaterialCategoryPayload } from '../../types';
+
+
+const ICON_COMPONENTS = AntIcons as unknown as Record>;
+
+const CATEGORY_ICON_OPTIONS = [
+ { label: '通用应用', value: 'AppstoreOutlined' },
+ { label: '图片素材', value: 'PictureOutlined' },
+ { label: '视频素材', value: 'VideoCameraOutlined' },
+ { label: '电商购物', value: 'ShoppingOutlined' },
+ { label: '门店商铺', value: 'ShopOutlined' },
+ { label: '美妆护肤', value: 'SkinOutlined' },
+ { label: '餐饮食品', value: 'CoffeeOutlined' },
+ { label: '家居生活', value: 'HomeOutlined' },
+ { label: '汽车出行', value: 'CarOutlined' },
+ { label: '医疗健康', value: 'MedicineBoxOutlined' },
+ { label: '教育培训', value: 'BookOutlined' },
+ { label: '数码科技', value: 'LaptopOutlined' },
+ { label: '旅游定位', value: 'EnvironmentOutlined' },
+ { label: '摄影影像', value: 'CameraOutlined' },
+ { label: '礼品活动', value: 'GiftOutlined' },
+ { label: '热门爆款', value: 'FireOutlined' },
+ { label: '品牌服务', value: 'CustomerServiceOutlined' },
+ { label: '营销增长', value: 'RocketOutlined' },
+ { label: '机构企业', value: 'BankOutlined' },
+ { label: '娱乐休闲', value: 'SmileOutlined' },
+];
+
+function renderIconOption(value?: string | null, label?: string) {
+ if (!value) {
+ return 未设置;
+ }
+ const Icon = ICON_COMPONENTS[value];
+ return (
+
+ {Icon ? : null}
+ {label || value}
+ {value}
+
+ );
+}
+
+const HomeMaterialCategoryPanel: React.FC<{ onChanged?: () => void }> = ({ onChanged }) => {
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [page, setPage] = useState(1);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form] = Form.useForm();
+
+ const load = async () => {
+ setLoading(true);
+ try {
+ const res = await getHomeMaterialCategories({ page, pageSize: 20 });
+ setItems(res.items);
+ setTotal(res.total);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { load(); }, [page]);
+
+ const openEdit = (row?: HomeMaterialCategory) => {
+ setEditing(row || null);
+ form.setFieldsValue(row ? {
+ name: row.name,
+ key: row.key,
+ description: row.description,
+ icon: row.icon,
+ is_active: row.isActive,
+ sort_order: row.sortOrder,
+ } : { is_active: true, sort_order: 0 });
+ setModalOpen(true);
+ };
+
+ const submit = async () => {
+ const values = await form.validateFields();
+ await saveHomeMaterialCategory({ ...(values as HomeMaterialCategoryPayload), id: editing?.id });
+ message.success('保存成功');
+ setModalOpen(false);
+ await load();
+ onChanged?.();
+ };
+
+ return (
+ <>
+
+
+
} onClick={() => openEdit()}>新增行业
+
+ renderIconOption(v) },
+ { title: '启用', dataIndex: 'isActive', render: (v: boolean) => v ? '启用' : '禁用' },
+ { title: '素材数', dataIndex: 'assetCount' },
+ { title: '图片/视频', render: (_: unknown, r: HomeMaterialCategory) => `${r.imageCount}/${r.videoCount}` },
+ { title: '排序', dataIndex: 'sortOrder' },
+ {
+ title: '操作',
+ render: (_: unknown, row: HomeMaterialCategory) => (
+
+ } onClick={() => openEdit(row)}>编辑
+ { await deleteHomeMaterialCategory(row.id); message.success('删除成功'); await load(); onChanged?.(); }}>
+ }>删除
+
+
+ ),
+ },
+ ]}
+ />
+ setModalOpen(false)} destroyOnHidden>
+
+
+
+
+ {
+ const text = String((option as any)?.searchText || '').toLowerCase();
+ const value = String(option?.value || '').toLowerCase();
+ const keyword = input.toLowerCase();
+ return text.includes(keyword) || value.includes(keyword);
+ }}
+ options={CATEGORY_ICON_OPTIONS.map(item => ({
+ value: item.value,
+ searchText: `${item.label} ${item.value}`,
+ label: renderIconOption(item.value, item.label),
+ }))}
+ />
+
+
+
+
+
+ >
+ );
+};
+
+export default HomeMaterialCategoryPanel;
diff --git a/video-gen-admin/src/pages/homeMaterials/HomeMaterialUploadModal.tsx b/video-gen-admin/src/pages/homeMaterials/HomeMaterialUploadModal.tsx
new file mode 100644
index 00000000..ba4a3596
--- /dev/null
+++ b/video-gen-admin/src/pages/homeMaterials/HomeMaterialUploadModal.tsx
@@ -0,0 +1,208 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
+import { UploadOutlined } from '@ant-design/icons';
+import { uploadHomeMaterialAsset } from '../../api';
+import type { HomeMaterialCategory, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
+import WatermarkEditor from './WatermarkEditor';
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ categories: HomeMaterialCategory[];
+ watermarks: HomeMaterialWatermark[];
+ onSuccess: () => void;
+}
+
+const defaultConfig: HomeMaterialWatermarkConfig = {
+ watermarkType: 'image',
+ watermarkId: null,
+ opacityLevel: 6,
+ position: 'bottom_right',
+ customXRatio: null,
+ customYRatio: null,
+ sizeMode: 'ratio',
+ widthRatio: 0.18,
+ widthPx: null,
+ marginX: 24,
+ marginY: 24,
+ textWatermark: {
+ text: '民众普康 AI',
+ opacityLevel: 2,
+ fontSizePx: 28,
+ color: '#ffffff',
+ rotateDeg: -30,
+ gapX: 220,
+ gapY: 140,
+ staggered: true,
+ },
+};
+
+function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
+ if (config.watermarkType === 'repeated_text') return !!config.textWatermark?.text?.trim();
+ return !!config.watermarkId;
+}
+
+const HomeMaterialUploadModal: React.FC = ({ open, onClose, categories, watermarks, onSuccess }) => {
+ const [form] = Form.useForm();
+ const [file, setFile] = useState(null);
+ const [fileList, setFileList] = useState([]);
+ const [mediaType, setMediaType] = useState('image');
+ const [config, setConfig] = useState(defaultConfig);
+ const [submitting, setSubmitting] = useState(false);
+ const [mediaObjectUrl, setMediaObjectUrl] = useState('');
+
+ const resetState = () => {
+ setFile(null);
+ setFileList([]);
+ setMediaType('image');
+ setMediaObjectUrl('');
+ setSubmitting(false);
+ };
+
+ useEffect(() => {
+ if (open) {
+ resetState();
+ const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
+ setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null });
+ form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined });
+ }
+ }, [open, watermarks, form]);
+
+ useEffect(() => {
+ return () => {
+ if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
+ };
+ }, [mediaObjectUrl]);
+
+ const mediaUrl = useMemo(() => mediaObjectUrl || undefined, [mediaObjectUrl]);
+
+ const handleBeforeUpload = (selectedFile: File) => {
+ const isImage = selectedFile.type.startsWith('image/');
+ const isVideo = selectedFile.type.startsWith('video/');
+
+ if (mediaType === 'image' && !isImage) {
+ message.warning('当前素材类型为图片,请选择图片文件');
+ return Upload.LIST_IGNORE;
+ }
+ if (mediaType === 'video' && !isVideo) {
+ message.warning('当前素材类型为视频,请选择视频文件');
+ return Upload.LIST_IGNORE;
+ }
+
+ if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
+ const nextUrl = URL.createObjectURL(selectedFile);
+ setFile(selectedFile);
+ setMediaObjectUrl(nextUrl);
+ setFileList([{ uid: selectedFile.name, name: selectedFile.name, status: 'done', originFileObj: selectedFile }]);
+ return false;
+ };
+
+ const handleRemoveFile = () => {
+ setFile(null);
+ setFileList([]);
+ if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
+ setMediaObjectUrl('');
+ };
+
+ const changeMediaType = (value: HomeMaterialMediaType) => {
+ setMediaType(value);
+ handleRemoveFile();
+ };
+
+ const closeModal = () => {
+ if (submitting) return;
+ onClose();
+ };
+
+ const submit = async () => {
+ if (submitting) return;
+ const values = await form.validateFields();
+ if (!file) {
+ message.warning('请选择素材文件');
+ return;
+ }
+ if (config.watermarkType === 'image' && !config.watermarkId) {
+ message.warning('请选择水印图片');
+ return;
+ }
+ if (config.watermarkType === 'repeated_text' && !config.textWatermark?.text?.trim()) {
+ message.warning('请输入重复文字水印内容');
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const res = await uploadHomeMaterialAsset({
+ categoryId: values.category_id,
+ file,
+ mediaType,
+ title: values.title?.trim() || null,
+ watermarkType: config.watermarkType,
+ watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
+ opacityLevel: config.opacityLevel,
+ position: config.position,
+ customXRatio: config.customXRatio,
+ customYRatio: config.customYRatio,
+ sizeMode: config.sizeMode,
+ widthRatio: config.widthRatio,
+ widthPx: config.widthPx,
+ marginX: config.marginX,
+ marginY: config.marginY,
+ textWatermark: config.watermarkType === 'repeated_text' ? config.textWatermark : null,
+ isActive: values.is_active,
+ sortOrder: values.sort_order,
+ wait: false,
+ });
+ message.success(res.message || '已提交水印处理');
+ onSuccess();
+ onClose();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ ({ label: c.name, value: c.id }))} disabled={submitting} />
+
+
+
+
+
+ handleBeforeUpload(f as File)}
+ onRemove={handleRemoveFile}
+ fileList={fileList}
+ maxCount={1}
+ accept={mediaType === 'image' ? 'image/*' : 'video/*'}
+ disabled={submitting}
+ >
+ } loading={submitting} disabled={submitting}>选择{mediaType === 'image' ? '图片' : '视频'}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default HomeMaterialUploadModal;
diff --git a/video-gen-admin/src/pages/homeMaterials/WatermarkEditor.tsx b/video-gen-admin/src/pages/homeMaterials/WatermarkEditor.tsx
new file mode 100644
index 00000000..92815888
--- /dev/null
+++ b/video-gen-admin/src/pages/homeMaterials/WatermarkEditor.tsx
@@ -0,0 +1,183 @@
+import React from 'react';
+import { Col, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
+import type { HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
+import WatermarkPreview from './WatermarkPreview';
+
+interface WatermarkEditorProps {
+ value: HomeMaterialWatermarkConfig;
+ onChange: (next: HomeMaterialWatermarkConfig) => void;
+ watermarks: HomeMaterialWatermark[];
+ mediaUrl?: string | null;
+ mediaType?: HomeMaterialMediaType;
+}
+
+const positionOptions = [
+ { label: '左上角', value: 'top_left' },
+ { label: '顶部居中', value: 'top_center' },
+ { label: '右上角', value: 'top_right' },
+ { label: '左侧居中', value: 'middle_left' },
+ { label: '正中间', value: 'center' },
+ { label: '右侧居中', value: 'middle_right' },
+ { label: '左下角', value: 'bottom_left' },
+ { label: '底部居中', value: 'bottom_center' },
+ { label: '右下角', value: 'bottom_right' },
+ { label: '拖拽自定义', value: 'custom' },
+];
+
+const defaultTextWatermark = {
+ text: '民众普康 AI',
+ opacityLevel: 2,
+ fontSizePx: 28,
+ color: '#ffffff',
+ rotateDeg: -30,
+ gapX: 220,
+ gapY: 140,
+ staggered: true,
+};
+
+const WatermarkEditor: React.FC = ({ value, onChange, watermarks, mediaUrl, mediaType }) => {
+ const patch = (partial: Partial) => onChange({ ...value, ...partial });
+ const patchText = (partial: Partial>) => {
+ patch({ textWatermark: { ...defaultTextWatermark, ...(value.textWatermark || {}), ...partial } });
+ };
+ const watermarkUrl = watermarks.find(w => w.id === value.watermarkId)?.fileUrl;
+ const textWatermark = { ...defaultTextWatermark, ...(value.textWatermark || {}) };
+
+ return (
+
+
+ {
+ const nextType = e.target.value;
+ patch({
+ watermarkType: nextType,
+ textWatermark: nextType === 'repeated_text' ? textWatermark : null,
+ });
+ }}
+ >
+ 图片水印
+ 重复文字水印
+
+
+
+ {value.watermarkType === 'repeated_text' ? (
+
+
+
+ patchText({ text: e.target.value })}
+ placeholder="请输入重复水印文字"
+ />
+
+
+
+
+ patchText({ opacityLevel: v })} />
+
+
+
+
+ patchText({ fontSizePx: Number(v || 28) })} />
+
+
+
+
+ patchText({ color: e.target.value || '#ffffff' })} placeholder="#ffffff" />
+
+
+
+
+ patchText({ rotateDeg: Number(v ?? -30) })} />
+
+
+
+
+ patchText({ gapX: Number(v || 220) })} />
+
+
+
+
+ patchText({ gapY: Number(v || 140) })} />
+
+
+
+
+ patchText({ staggered: checked })} />
+
+
+
+ ) : (
+
+
+
+ patch({ watermarkId: v })}
+ options={watermarks.map(w => ({ label: `${w.name}${w.isDefault ? '(默认)' : ''}`, value: w.id }))}
+ />
+
+
+
+
+ patch({ opacityLevel: v })} />
+
+
+
+
+ patch({ position: v })} options={positionOptions} />
+
+
+
+
+ patch({ sizeMode: v })}
+ options={[{ label: '按素材宽度比例', value: 'ratio' }, { label: '固定像素宽度', value: 'px' }]}
+ />
+
+
+ {value.sizeMode === 'ratio' ? (
+
+
+ patch({ widthRatio: Number(v || 0.18) })} />
+
+
+ ) : (
+
+
+ patch({ widthPx: Number(v || 200) })} />
+
+
+ )}
+
+
+ patch({ marginX: Number(v || 0) })} />
+
+
+
+
+ patch({ marginY: Number(v || 0) })} />
+
+
+
+ )}
+
+
+ patch(partial)}
+ />
+
+ );
+};
+
+export default WatermarkEditor;
diff --git a/video-gen-admin/src/pages/homeMaterials/WatermarkLibraryModal.tsx b/video-gen-admin/src/pages/homeMaterials/WatermarkLibraryModal.tsx
new file mode 100644
index 00000000..0f6eb3e3
--- /dev/null
+++ b/video-gen-admin/src/pages/homeMaterials/WatermarkLibraryModal.tsx
@@ -0,0 +1,207 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Form, Image, Input, Modal, Popconfirm, Space, Switch, Table, Upload, message } from 'antd';
+import { DeleteOutlined, EditOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
+import { deleteHomeMaterialWatermark, getHomeMaterialWatermarks, updateHomeMaterialWatermark, uploadHomeMaterialWatermark } from '../../api';
+import type { HomeMaterialWatermark } from '../../types';
+import { apiUrl } from '../../utils/resourceUrl';
+
+interface Props {
+ open?: boolean;
+ onClose?: () => void;
+ embedded?: boolean;
+ onChanged?: () => void;
+}
+
+const WatermarkLibraryModal: React.FC = ({ open = true, onClose, embedded = false, onChanged }) => {
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [uploadOpen, setUploadOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [file, setFile] = useState(null);
+ const [fileList, setFileList] = useState([]);
+ const [previewUrl, setPreviewUrl] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [form] = Form.useForm();
+
+ const load = async () => {
+ setLoading(true);
+ try {
+ const res = await getHomeMaterialWatermarks({ page: 1, pageSize: 200 });
+ setItems(res.items);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { if (open) load(); }, [open]);
+
+ useEffect(() => {
+ return () => {
+ if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
+ };
+ }, [previewUrl]);
+
+ const resetUploadState = () => {
+ setFile(null);
+ setFileList([]);
+ setPreviewUrl('');
+ setSubmitting(false);
+ };
+
+ const openUpload = () => {
+ setEditing(null);
+ resetUploadState();
+ form.setFieldsValue({ name: '', is_default: false, is_active: true });
+ setUploadOpen(true);
+ };
+
+ const openEdit = (row: HomeMaterialWatermark) => {
+ setEditing(row);
+ resetUploadState();
+ setPreviewUrl(apiUrl(row.fileUrl));
+ form.setFieldsValue({ name: row.name, is_default: row.isDefault, is_active: row.isActive });
+ setUploadOpen(true);
+ };
+
+ const closeUploadModal = () => {
+ if (submitting) return;
+ setUploadOpen(false);
+ resetUploadState();
+ };
+
+ const handleBeforeUpload = (selectedFile: File) => {
+ if (!selectedFile.type.startsWith('image/')) {
+ message.warning('请选择图片文件');
+ return Upload.LIST_IGNORE;
+ }
+
+ if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
+ const nextPreviewUrl = URL.createObjectURL(selectedFile);
+ setFile(selectedFile);
+ setPreviewUrl(nextPreviewUrl);
+ setFileList([{ uid: selectedFile.name, name: selectedFile.name, status: 'done', originFileObj: selectedFile }]);
+ return false;
+ };
+
+ const handleRemoveFile = () => {
+ setFile(null);
+ setFileList([]);
+ if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
+ setPreviewUrl('');
+ };
+
+ const submit = async () => {
+ if (submitting) return;
+ const values = await form.validateFields();
+
+ if (!editing && !file) {
+ message.warning('请选择水印图片');
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ if (editing) {
+ await updateHomeMaterialWatermark(editing.id, { name: values.name, is_default: values.is_default, is_active: values.is_active });
+ } else if (file) {
+ await uploadHomeMaterialWatermark(file, values.name, values.is_default);
+ }
+
+ message.success('保存成功');
+ setUploadOpen(false);
+ resetUploadState();
+ await load();
+ onChanged?.();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const table = (
+ <>
+
+
+
} onClick={openUpload}>上传水印
+
+ },
+ { title: '名称', dataIndex: 'name' },
+ { title: '尺寸', render: (_: unknown, r: HomeMaterialWatermark) => r.width && r.height ? `${r.width}×${r.height}` : '-' },
+ { title: '默认', dataIndex: 'isDefault', render: (v: boolean) => v ? '是' : '否' },
+ { title: '启用', dataIndex: 'isActive', render: (v: boolean) => v ? '启用' : '禁用' },
+ {
+ title: '操作',
+ render: (_: unknown, row: HomeMaterialWatermark) => (
+
+ } onClick={() => openEdit(row)}>编辑
+ { await deleteHomeMaterialWatermark(row.id); message.success('删除成功'); await load(); onChanged?.(); }}>
+ }>删除
+
+
+ ),
+ },
+ ]}
+ />
+
+
+ handleBeforeUpload(f as File)}
+ onRemove={handleRemoveFile}
+ fileList={fileList}
+ maxCount={1}
+ accept="image/png,image/jpeg,image/webp"
+ disabled={submitting}
+ >
+ } loading={submitting} disabled={submitting}>选择图片
+
+ {previewUrl && (
+
+
+
+ )}
+
+ )}
+ {editing && previewUrl && (
+
+
+
+ )}
+
+
+
+
+
+ >
+ );
+
+ if (embedded) return table;
+ return {table};
+};
+
+export default WatermarkLibraryModal;
diff --git a/video-gen-admin/src/pages/homeMaterials/WatermarkPreview.tsx b/video-gen-admin/src/pages/homeMaterials/WatermarkPreview.tsx
new file mode 100644
index 00000000..764dc35e
--- /dev/null
+++ b/video-gen-admin/src/pages/homeMaterials/WatermarkPreview.tsx
@@ -0,0 +1,363 @@
+import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { Spin } from 'antd';
+import { previewHomeMaterialTextWatermark } from '../../api';
+import { apiUrl } from '../../utils/resourceUrl';
+import type { HomeMaterialMediaType, HomeMaterialTextWatermarkPayload, HomeMaterialWatermarkConfig } from '../../types';
+
+interface WatermarkPreviewProps {
+ mediaUrl?: string | null;
+ mediaType?: HomeMaterialMediaType;
+ watermarkUrl?: string | null;
+ config: HomeMaterialWatermarkConfig;
+ onChange?: (patch: Partial) => void;
+}
+
+interface Size {
+ width: number;
+ height: number;
+}
+
+const PREVIEW_MAX_HEIGHT = 360;
+const PREVIEW_MIN_HEIGHT = 280;
+
+function clamp(value: number, min: number, max: number): number {
+ if (!Number.isFinite(value)) return min;
+ return Math.min(max, Math.max(min, value));
+}
+
+function calcStageSize(containerWidth: number, naturalSize: Size | null): Size {
+ const maxWidth = Math.max(0, containerWidth);
+ if (!maxWidth) return { width: 0, height: PREVIEW_MIN_HEIGHT };
+
+ if (!naturalSize?.width || !naturalSize?.height) {
+ return { width: maxWidth, height: PREVIEW_MIN_HEIGHT };
+ }
+
+ const scale = Math.min(maxWidth / naturalSize.width, PREVIEW_MAX_HEIGHT / naturalSize.height);
+ return {
+ width: Math.max(1, Math.round(naturalSize.width * scale)),
+ height: Math.max(1, Math.round(naturalSize.height * scale)),
+ };
+}
+
+function calcWatermarkWidth(config: HomeMaterialWatermarkConfig, stageWidth: number): number {
+ if (config.sizeMode === 'px' && config.widthPx) {
+ return clamp(config.widthPx, 1, Math.max(1, stageWidth));
+ }
+ const ratio = clamp(config.widthRatio ?? 0.18, 0.01, 1);
+ return Math.max(1, stageWidth * ratio);
+}
+
+function calcPresetPosition(config: HomeMaterialWatermarkConfig): React.CSSProperties {
+ const marginX = config.marginX ?? 24;
+ const marginY = config.marginY ?? 24;
+
+ switch (config.position) {
+ case 'top_left':
+ return { left: marginX, top: marginY };
+ case 'top_center':
+ return { left: '50%', top: marginY, transform: 'translateX(-50%)' };
+ case 'top_right':
+ return { right: marginX, top: marginY };
+ case 'middle_left':
+ return { left: marginX, top: '50%', transform: 'translateY(-50%)' };
+ case 'center':
+ return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' };
+ case 'middle_right':
+ return { right: marginX, top: '50%', transform: 'translateY(-50%)' };
+ case 'bottom_left':
+ return { left: marginX, bottom: marginY };
+ case 'bottom_center':
+ return { left: '50%', bottom: marginY, transform: 'translateX(-50%)' };
+ case 'bottom_right':
+ default:
+ return { right: marginX, bottom: marginY };
+ }
+}
+
+function toTextPayload(config: HomeMaterialWatermarkConfig): HomeMaterialTextWatermarkPayload | null {
+ const text = config.textWatermark;
+ if (!text || !text.text?.trim()) return null;
+ return {
+ text: text.text.trim(),
+ opacity_level: text.opacityLevel,
+ font_size_px: text.fontSizePx,
+ color: text.color,
+ rotate_deg: text.rotateDeg,
+ gap_x: text.gapX,
+ gap_y: text.gapY,
+ staggered: text.staggered,
+ };
+}
+
+const WatermarkPreview: React.FC = ({
+ mediaUrl,
+ mediaType = 'image',
+ watermarkUrl,
+ config,
+ onChange,
+}) => {
+ const outerRef = useRef(null);
+ const stageRef = useRef(null);
+ const watermarkRef = useRef(null);
+ const dragOffsetRef = useRef({ x: 0, y: 0 });
+ const previewSeqRef = useRef(0);
+
+ const [containerWidth, setContainerWidth] = useState(0);
+ const [naturalSize, setNaturalSize] = useState(null);
+ const [watermarkSize, setWatermarkSize] = useState({ width: 0, height: 0 });
+ const [dragging, setDragging] = useState(false);
+ const [textPreviewLayer, setTextPreviewLayer] = useState('');
+ const [textPreviewLoading, setTextPreviewLoading] = useState(false);
+ const [textPreviewError, setTextPreviewError] = useState('');
+
+ const mediaSrc = useMemo(() => apiUrl(mediaUrl), [mediaUrl]);
+ const watermarkSrc = useMemo(() => apiUrl(watermarkUrl), [watermarkUrl]);
+ const stageSize = useMemo(() => calcStageSize(containerWidth, naturalSize), [containerWidth, naturalSize]);
+ const watermarkWidth = useMemo(() => calcWatermarkWidth(config, stageSize.width), [config, stageSize.width]);
+ const isRepeatedText = config.watermarkType === 'repeated_text';
+
+ useLayoutEffect(() => {
+ if (!outerRef.current) return;
+ const update = () => setContainerWidth(outerRef.current?.clientWidth || 0);
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(outerRef.current);
+ return () => observer.disconnect();
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!watermarkRef.current) return;
+ const update = () => {
+ const rect = watermarkRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ setWatermarkSize({ width: rect.width, height: rect.height });
+ };
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(watermarkRef.current);
+ return () => observer.disconnect();
+ }, [watermarkSrc, watermarkWidth]);
+
+ useEffect(() => {
+ setNaturalSize(null);
+ setTextPreviewLayer('');
+ setTextPreviewError('');
+ }, [mediaSrc, mediaType]);
+
+ useEffect(() => {
+ if (!isRepeatedText) {
+ setTextPreviewLayer('');
+ setTextPreviewLoading(false);
+ setTextPreviewError('');
+ return;
+ }
+ const textPayload = toTextPayload(config);
+ if (!naturalSize?.width || !naturalSize.height || !textPayload) {
+ setTextPreviewLayer('');
+ return;
+ }
+
+ const seq = ++previewSeqRef.current;
+ setTextPreviewLoading(true);
+ setTextPreviewError('');
+ const timer = window.setTimeout(async () => {
+ try {
+ const res = await previewHomeMaterialTextWatermark({
+ width: naturalSize.width,
+ height: naturalSize.height,
+ text_watermark: textPayload,
+ });
+ if (previewSeqRef.current === seq) {
+ setTextPreviewLayer(res.previewLayerDataUrl);
+ }
+ } catch (e) {
+ if (previewSeqRef.current === seq) {
+ setTextPreviewLayer('');
+ setTextPreviewError(e instanceof Error ? e.message : '文字水印预览生成失败');
+ }
+ } finally {
+ if (previewSeqRef.current === seq) setTextPreviewLoading(false);
+ }
+ }, 300);
+ return () => window.clearTimeout(timer);
+ }, [config, isRepeatedText, naturalSize]);
+
+ useEffect(() => {
+ const onMove = (e: PointerEvent) => {
+ if (!dragging || !stageRef.current || !onChange || isRepeatedText) return;
+
+ const rect = stageRef.current.getBoundingClientRect();
+ const wmWidth = watermarkSize.width || watermarkWidth;
+ const wmHeight = watermarkSize.height || 1;
+ const availableX = Math.max(1, rect.width - wmWidth);
+ const availableY = Math.max(1, rect.height - wmHeight);
+ const left = clamp(e.clientX - rect.left - dragOffsetRef.current.x, 0, availableX);
+ const top = clamp(e.clientY - rect.top - dragOffsetRef.current.y, 0, availableY);
+
+ onChange({
+ position: 'custom',
+ customXRatio: Number((left / availableX).toFixed(4)),
+ customYRatio: Number((top / availableY).toFixed(4)),
+ });
+ };
+ const onUp = () => setDragging(false);
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ window.addEventListener('pointercancel', onUp);
+ return () => {
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ window.removeEventListener('pointercancel', onUp);
+ };
+ }, [dragging, isRepeatedText, onChange, watermarkSize.height, watermarkSize.width, watermarkWidth]);
+
+ const watermarkPositionStyle = useMemo(() => {
+ if (config.position !== 'custom') return calcPresetPosition(config);
+
+ const wmWidth = watermarkSize.width || watermarkWidth;
+ const wmHeight = watermarkSize.height || 1;
+ const availableX = Math.max(0, stageSize.width - wmWidth);
+ const availableY = Math.max(0, stageSize.height - wmHeight);
+ const xRatio = clamp(config.customXRatio ?? 0.5, 0, 1);
+ const yRatio = clamp(config.customYRatio ?? 0.5, 0, 1);
+
+ return {
+ left: availableX * xRatio,
+ top: availableY * yRatio,
+ transform: 'none',
+ };
+ }, [config, stageSize.height, stageSize.width, watermarkSize.height, watermarkSize.width, watermarkWidth]);
+
+ return (
+
+ {mediaSrc ? (
+
+ {mediaType === 'video' ? (
+
+ ) : (
+
上传素材后可预览水印效果
+ )}
+
+ );
+};
+
+export default WatermarkPreview;
diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts
index 7e04e24e..f5306a31 100644
--- a/video-gen-admin/src/types/index.ts
+++ b/video-gen-admin/src/types/index.ts
@@ -856,3 +856,250 @@ export interface AdminCreditRecordQueryParams {
startDate?: string;
endDate?: string;
}
+
+// ── 首页素材行业装修 ──────────────────────────────────────
+
+export type HomeMaterialMediaType = 'image' | 'video';
+export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
+export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
+export type HomeMaterialWatermarkPosition =
+ | 'top_left'
+ | 'top_center'
+ | 'top_right'
+ | 'middle_left'
+ | 'center'
+ | 'middle_right'
+ | 'bottom_left'
+ | 'bottom_center'
+ | 'bottom_right'
+ | 'custom';
+export type HomeMaterialWatermarkSizeMode = 'ratio' | 'px';
+export type HomeMaterialPublicResponseMode = 'grouped' | 'flat';
+
+export interface HomeMaterialConfig {
+ enabled: boolean;
+ title: string;
+ subtitle: string;
+ showOriginalInAdmin: boolean;
+}
+
+export interface HomeMaterialCategory {
+ id: string;
+ name: string;
+ key: string;
+ description?: string | null;
+ icon?: string | null;
+ isActive: boolean;
+ sortOrder: number;
+ assetCount: number;
+ imageCount: number;
+ videoCount: number;
+ createdAt?: string | null;
+ updatedAt?: string | null;
+}
+
+export interface HomeMaterialCategoryPayload {
+ name: string;
+ key: string;
+ description?: string | null;
+ icon?: string | null;
+ is_active: boolean;
+ sort_order: number;
+}
+
+export interface HomeMaterialWatermark {
+ id: string;
+ name: string;
+ fileUrl: string;
+ fileName?: string | null;
+ fileSizeBytes: number;
+ width?: number | null;
+ height?: number | null;
+ isDefault: boolean;
+ isActive: boolean;
+ createdAt?: string | null;
+ updatedAt?: string | null;
+}
+
+export interface HomeMaterialWatermarkPayload {
+ name: string;
+ is_default: boolean;
+ is_active: boolean;
+}
+
+export interface HomeMaterialTextWatermarkConfig {
+ text: string;
+ opacityLevel: number;
+ opacity?: number;
+ fontSizePx: number;
+ color: string;
+ rotateDeg: number;
+ gapX: number;
+ gapY: number;
+ staggered: boolean;
+}
+
+export interface HomeMaterialTextWatermarkPayload {
+ text: string;
+ opacity_level: number;
+ font_size_px: number;
+ color: string;
+ rotate_deg: number;
+ gap_x: number;
+ gap_y: number;
+ staggered: boolean;
+}
+
+export interface HomeMaterialWatermarkConfig {
+ watermarkType: HomeMaterialWatermarkType;
+ watermarkId?: string | null;
+ opacityLevel: number;
+ opacity?: number;
+ position: HomeMaterialWatermarkPosition;
+ customXRatio?: number | null;
+ customYRatio?: number | null;
+ sizeMode: HomeMaterialWatermarkSizeMode;
+ widthRatio?: number | null;
+ widthPx?: number | null;
+ marginX: number;
+ marginY: number;
+ textWatermark?: HomeMaterialTextWatermarkConfig | null;
+}
+
+export interface HomeMaterialAsset {
+ id: string;
+ categoryId: string;
+ categoryName?: string | null;
+ categoryKey?: string | null;
+ title?: string | null;
+ mediaType: HomeMaterialMediaType;
+ status: HomeMaterialAssetStatus;
+ originalUrl?: string | null;
+ watermarkedUrl?: string | null;
+ coverUrl?: string | null;
+ watermarkId?: string | null;
+ watermarkName?: string | null;
+ watermarkConfig?: HomeMaterialWatermarkConfig | Record | null;
+ width?: number | null;
+ height?: number | null;
+ durationSeconds?: number | string | null;
+ fileSizeBytes: number;
+ watermarkedFileSizeBytes?: number | null;
+ isActive: boolean;
+ sortOrder: number;
+ errorMessage?: string | null;
+ processedAt?: string | null;
+ createdAt?: string | null;
+ updatedAt?: string | null;
+}
+
+export interface HomeMaterialAssetUpdatePayload {
+ category_id: string;
+ title?: string | null;
+ is_active: boolean;
+ sort_order: number;
+}
+
+export interface HomeMaterialAssetStatusOut {
+ id: string;
+ status: HomeMaterialAssetStatus;
+ errorMessage?: string | null;
+ originalUrl?: string | null;
+ watermarkedUrl?: string | null;
+ coverUrl?: string | null;
+ processedAt?: string | null;
+}
+
+export interface HomeMaterialUploadResult {
+ id: string;
+ categoryId: string;
+ title?: string | null;
+ mediaType: HomeMaterialMediaType;
+ status: HomeMaterialAssetStatus;
+ originalUrl?: string | null;
+ watermarkedUrl?: string | null;
+ coverUrl?: string | null;
+ watermarkConfig?: HomeMaterialWatermarkConfig | Record | null;
+ message: string;
+}
+
+export interface HomeMaterialListResponse {
+ items: T[];
+ total: number;
+}
+
+export interface HomeMaterialCategoryQueryParams {
+ page?: number;
+ pageSize?: number;
+ keyword?: string;
+ isActive?: boolean;
+}
+
+export interface HomeMaterialWatermarkQueryParams {
+ page?: number;
+ pageSize?: number;
+ isActive?: boolean;
+}
+
+export interface HomeMaterialAssetQueryParams {
+ page?: number;
+ pageSize?: number;
+ categoryId?: string;
+ mediaType?: HomeMaterialMediaType;
+ status?: HomeMaterialAssetStatus;
+ isActive?: boolean;
+ keyword?: string;
+}
+
+export interface HomeMaterialUploadAssetParams {
+ categoryId: string;
+ file: File;
+ mediaType: HomeMaterialMediaType;
+ title?: string | null;
+ watermarkType: HomeMaterialWatermarkType;
+ watermarkId?: string | null;
+ watermarkFile?: File | null;
+ opacityLevel: number;
+ position: HomeMaterialWatermarkPosition;
+ customXRatio?: number | null;
+ customYRatio?: number | null;
+ sizeMode: HomeMaterialWatermarkSizeMode;
+ widthRatio?: number | null;
+ widthPx?: number | null;
+ marginX: number;
+ marginY: number;
+ textWatermark?: HomeMaterialTextWatermarkConfig | null;
+ isActive: boolean;
+ sortOrder: number;
+ wait?: boolean;
+ waitTimeoutSeconds?: number;
+}
+
+export interface HomeMaterialRegeneratePayload {
+ watermark_type: HomeMaterialWatermarkType;
+ watermark_id?: string | null;
+ opacity_level: number;
+ position: HomeMaterialWatermarkPosition;
+ custom_x_ratio?: number | null;
+ custom_y_ratio?: number | null;
+ size_mode: HomeMaterialWatermarkSizeMode;
+ width_ratio?: number | null;
+ width_px?: number | null;
+ margin_x: number;
+ margin_y: number;
+ text_watermark?: HomeMaterialTextWatermarkPayload | null;
+ wait?: boolean;
+ wait_timeout_seconds?: number;
+}
+
+export interface HomeMaterialTextWatermarkPreviewRequest {
+ width: number;
+ height: number;
+ text_watermark: HomeMaterialTextWatermarkPayload;
+}
+
+export interface HomeMaterialTextWatermarkPreviewResponse {
+ width: number;
+ height: number;
+ previewLayerDataUrl: string;
+}
diff --git a/video-gen-admin/src/utils/resourceUrl.ts b/video-gen-admin/src/utils/resourceUrl.ts
new file mode 100644
index 00000000..3e84051d
--- /dev/null
+++ b/video-gen-admin/src/utils/resourceUrl.ts
@@ -0,0 +1,47 @@
+const RAW_API_BASE = String(import.meta.env.VITE_API_BASE || 'http://localhost:8000')
+ .trim()
+ .replace(/\/$/, '');
+
+export const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
+
+export const isAbsoluteLikeUrl = (url: string): boolean => (
+ /^(https?:)?\/\//i.test(url) || /^(blob|data):/i.test(url)
+);
+
+export const isBlobUrl = (url?: string | null): boolean => !!url && /^blob:/i.test(url.trim());
+
+export const apiUrl = (url?: string | null): string => {
+ if (!url) return '';
+ const value = String(url).trim();
+ if (!value) return '';
+ if (isAbsoluteLikeUrl(value)) return value;
+
+ if (!RESOURCE_BASE) {
+ return value.startsWith('/') ? value : `/${value}`;
+ }
+ return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
+};
+
+export const isUrlExpired = (url?: string | null): boolean => {
+ if (!url || isBlobUrl(url)) return false;
+
+ try {
+ const parsed = new URL(apiUrl(url), window.location.origin);
+ const expireValue =
+ parsed.searchParams.get('exp') ||
+ parsed.searchParams.get('expires') ||
+ parsed.searchParams.get('expire') ||
+ parsed.searchParams.get('expires_at') ||
+ parsed.searchParams.get('x-expires');
+
+ if (!expireValue) return false;
+
+ const expireNumber = Number(expireValue);
+ if (!Number.isFinite(expireNumber)) return false;
+
+ const expireMs = expireNumber > 10_000_000_000 ? expireNumber : expireNumber * 1000;
+ return Date.now() >= expireMs;
+ } catch {
+ return false;
+ }
+};
diff --git a/video-gen-api/alembic/versions/e5f260ed1459_add_home_material_decoration.py b/video-gen-api/alembic/versions/e5f260ed1459_add_home_material_decoration.py
new file mode 100644
index 00000000..75844e44
--- /dev/null
+++ b/video-gen-api/alembic/versions/e5f260ed1459_add_home_material_decoration.py
@@ -0,0 +1,153 @@
+"""add home material decoration
+
+Revision ID: e5f260ed1459
+Revises: 170005531deb
+Create Date: 2026-06-30 14:36:51.291905
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'e5f260ed1459'
+down_revision: Union[str, None] = '170005531deb'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('home_material_assets',
+ sa.Column('id', sa.String(length=32), nullable=False),
+ sa.Column('category_id', sa.String(length=32), nullable=False, comment='行业类别ID'),
+ sa.Column('title', sa.String(length=128), nullable=True, comment='素材标题'),
+ sa.Column('media_type', sa.String(length=16), server_default='image', nullable=False, comment='素材类型:image图片,video视频'),
+ sa.Column('original_url', sa.Text(), nullable=False, comment='原始素材URL'),
+ sa.Column('original_storage_path', sa.Text(), nullable=False, comment='原始素材本地路径'),
+ sa.Column('watermarked_url', sa.Text(), nullable=True, comment='水印素材URL'),
+ sa.Column('watermarked_storage_path', sa.Text(), nullable=True, comment='水印素材本地路径'),
+ sa.Column('cover_url', sa.Text(), nullable=True, comment='视频封面URL'),
+ sa.Column('cover_storage_path', sa.Text(), nullable=True, comment='视频封面本地路径'),
+ sa.Column('watermark_id', sa.String(length=32), nullable=True, comment='水印图片ID'),
+ sa.Column('watermark_config_json', sa.Text(), nullable=True, comment='水印配置快照JSON'),
+ sa.Column('status', sa.String(length=32), server_default='draft', nullable=False, comment='处理状态:draft/processing/success/failed'),
+ sa.Column('error_message', sa.Text(), nullable=True, comment='处理失败原因'),
+ sa.Column('width', sa.Integer(), nullable=True, comment='素材宽度'),
+ sa.Column('height', sa.Integer(), nullable=True, comment='素材高度'),
+ sa.Column('duration_seconds', sa.Numeric(precision=10, scale=3), nullable=True, comment='视频时长,图片为空'),
+ sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False, comment='原始文件大小'),
+ sa.Column('watermarked_file_size_bytes', sa.BigInteger(), nullable=True, comment='水印后文件大小'),
+ sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否前台展示'),
+ sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序,越小越靠前'),
+ sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True, comment='处理完成时间'),
+ sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
+ sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('idx_home_material_assets_admin_list', 'home_material_assets', ['deleted_at', 'category_id', 'media_type', 'status', 'is_active', 'sort_order', 'created_at'], unique=False)
+ op.create_index('idx_home_material_assets_category_sort', 'home_material_assets', ['category_id', 'sort_order', 'created_at'], unique=False)
+ op.create_index('idx_home_material_assets_status', 'home_material_assets', ['status'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_category_id'), 'home_material_assets', ['category_id'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_created_by'), 'home_material_assets', ['created_by'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_deleted_at'), 'home_material_assets', ['deleted_at'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_is_active'), 'home_material_assets', ['is_active'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_media_type'), 'home_material_assets', ['media_type'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_processed_at'), 'home_material_assets', ['processed_at'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_sort_order'), 'home_material_assets', ['sort_order'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_status'), 'home_material_assets', ['status'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_title'), 'home_material_assets', ['title'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_updated_by'), 'home_material_assets', ['updated_by'], unique=False)
+ op.create_index(op.f('ix_home_material_assets_watermark_id'), 'home_material_assets', ['watermark_id'], unique=False)
+ op.create_table('home_material_categories',
+ sa.Column('id', sa.String(length=32), nullable=False),
+ sa.Column('name', sa.String(length=64), nullable=False, comment='行业名称'),
+ sa.Column('key', sa.String(length=64), nullable=False, comment='行业唯一标识,前台可按 key 查询'),
+ sa.Column('description', sa.String(length=255), nullable=True, comment='行业描述'),
+ sa.Column('icon', sa.String(length=64), nullable=True, comment='前端图标名称'),
+ sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否启用'),
+ sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序,越小越靠前'),
+ sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
+ sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('idx_home_material_categories_active_sort', 'home_material_categories', ['deleted_at', 'is_active', 'sort_order', 'created_at'], unique=False)
+ op.create_index('idx_home_material_categories_key', 'home_material_categories', ['key'], unique=False)
+ op.create_index(op.f('ix_home_material_categories_created_by'), 'home_material_categories', ['created_by'], unique=False)
+ op.create_index(op.f('ix_home_material_categories_deleted_at'), 'home_material_categories', ['deleted_at'], unique=False)
+ op.create_index(op.f('ix_home_material_categories_is_active'), 'home_material_categories', ['is_active'], unique=False)
+ op.create_index(op.f('ix_home_material_categories_name'), 'home_material_categories', ['name'], unique=False)
+ op.create_index(op.f('ix_home_material_categories_sort_order'), 'home_material_categories', ['sort_order'], unique=False)
+ op.create_index(op.f('ix_home_material_categories_updated_by'), 'home_material_categories', ['updated_by'], unique=False)
+ op.create_table('home_material_watermarks',
+ sa.Column('id', sa.String(length=32), nullable=False),
+ sa.Column('name', sa.String(length=128), nullable=False, comment='水印名称'),
+ sa.Column('file_url', sa.Text(), nullable=False, comment='水印图片URL'),
+ sa.Column('storage_path', sa.Text(), nullable=False, comment='水印图片本地路径'),
+ sa.Column('file_name', sa.String(length=255), nullable=True, comment='原始文件名'),
+ sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False, comment='文件大小'),
+ sa.Column('width', sa.Integer(), nullable=True, comment='水印图片宽度'),
+ sa.Column('height', sa.Integer(), nullable=True, comment='水印图片高度'),
+ sa.Column('is_default', sa.Boolean(), server_default='false', nullable=False, comment='是否默认水印'),
+ sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否启用'),
+ sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
+ sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('idx_home_material_watermarks_active', 'home_material_watermarks', ['deleted_at', 'is_active', 'created_at'], unique=False)
+ op.create_index('idx_home_material_watermarks_default', 'home_material_watermarks', ['deleted_at', 'is_default'], unique=False)
+ op.create_index(op.f('ix_home_material_watermarks_created_by'), 'home_material_watermarks', ['created_by'], unique=False)
+ op.create_index(op.f('ix_home_material_watermarks_deleted_at'), 'home_material_watermarks', ['deleted_at'], unique=False)
+ op.create_index(op.f('ix_home_material_watermarks_is_active'), 'home_material_watermarks', ['is_active'], unique=False)
+ op.create_index(op.f('ix_home_material_watermarks_is_default'), 'home_material_watermarks', ['is_default'], unique=False)
+ op.create_index(op.f('ix_home_material_watermarks_name'), 'home_material_watermarks', ['name'], unique=False)
+ op.create_index(op.f('ix_home_material_watermarks_updated_by'), 'home_material_watermarks', ['updated_by'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_home_material_watermarks_updated_by'), table_name='home_material_watermarks')
+ op.drop_index(op.f('ix_home_material_watermarks_name'), table_name='home_material_watermarks')
+ op.drop_index(op.f('ix_home_material_watermarks_is_default'), table_name='home_material_watermarks')
+ op.drop_index(op.f('ix_home_material_watermarks_is_active'), table_name='home_material_watermarks')
+ op.drop_index(op.f('ix_home_material_watermarks_deleted_at'), table_name='home_material_watermarks')
+ op.drop_index(op.f('ix_home_material_watermarks_created_by'), table_name='home_material_watermarks')
+ op.drop_index('idx_home_material_watermarks_default', table_name='home_material_watermarks')
+ op.drop_index('idx_home_material_watermarks_active', table_name='home_material_watermarks')
+ op.drop_table('home_material_watermarks')
+ op.drop_index(op.f('ix_home_material_categories_updated_by'), table_name='home_material_categories')
+ op.drop_index(op.f('ix_home_material_categories_sort_order'), table_name='home_material_categories')
+ op.drop_index(op.f('ix_home_material_categories_name'), table_name='home_material_categories')
+ op.drop_index(op.f('ix_home_material_categories_is_active'), table_name='home_material_categories')
+ op.drop_index(op.f('ix_home_material_categories_deleted_at'), table_name='home_material_categories')
+ op.drop_index(op.f('ix_home_material_categories_created_by'), table_name='home_material_categories')
+ op.drop_index('idx_home_material_categories_key', table_name='home_material_categories')
+ op.drop_index('idx_home_material_categories_active_sort', table_name='home_material_categories')
+ op.drop_table('home_material_categories')
+ op.drop_index(op.f('ix_home_material_assets_watermark_id'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_updated_by'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_title'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_status'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_sort_order'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_processed_at'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_media_type'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_is_active'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_deleted_at'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_created_by'), table_name='home_material_assets')
+ op.drop_index(op.f('ix_home_material_assets_category_id'), table_name='home_material_assets')
+ op.drop_index('idx_home_material_assets_status', table_name='home_material_assets')
+ op.drop_index('idx_home_material_assets_category_sort', table_name='home_material_assets')
+ op.drop_index('idx_home_material_assets_admin_list', table_name='home_material_assets')
+ op.drop_table('home_material_assets')
+ # ### end Alembic commands ###
diff --git a/video-gen-api/app/api/admin/__init__.py b/video-gen-api/app/api/admin/__init__.py
index af1fee78..627f2440 100644
--- a/video-gen-api/app/api/admin/__init__.py
+++ b/video-gen-api/app/api/admin/__init__.py
@@ -3,8 +3,10 @@ from fastapi import APIRouter
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
from app.api.admin.resource_capacity import router as resource_capacity_router
from app.api.admin.team import router as team_router
+from app.api.admin.home_material import router as home_material_router
router = APIRouter()
router.include_router(video_prompt_schema_config_router)
router.include_router(resource_capacity_router)
router.include_router(team_router)
+router.include_router(home_material_router)
diff --git a/video-gen-api/app/api/admin/home_material.py b/video-gen-api/app/api/admin/home_material.py
new file mode 100644
index 00000000..5ae9ebc1
--- /dev/null
+++ b/video-gen-api/app/api/admin/home_material.py
@@ -0,0 +1,527 @@
+from __future__ import annotations
+
+import json
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, File, Form, Path, Query, UploadFile
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.dependencies import get_admin_user, get_db
+from app.enums.home_material import (
+ HomeMaterialAssetStatus,
+ HomeMaterialMediaType,
+ HomeMaterialOperationEnum,
+ HomeMaterialWatermarkPosition,
+ HomeMaterialWatermarkSizeMode,
+ HomeMaterialWatermarkType,
+)
+from app.models.user import User
+from app.schemas.home_material import (
+ HomeMaterialAssetListOut,
+ HomeMaterialAssetOut,
+ HomeMaterialAssetStatusOut,
+ HomeMaterialAssetUpdate,
+ HomeMaterialCategoryCreate,
+ HomeMaterialCategoryListOut,
+ HomeMaterialCategoryOut,
+ HomeMaterialCategoryUpdate,
+ HomeMaterialConfigOut,
+ HomeMaterialConfigUpdate,
+ HomeMaterialRegenerateWatermarkRequest,
+ HomeMaterialTextWatermarkConfig,
+ HomeMaterialTextWatermarkPreviewRequest,
+ HomeMaterialTextWatermarkPreviewResponse,
+ HomeMaterialUploadResultOut,
+ HomeMaterialWatermarkConfig,
+ HomeMaterialWatermarkListOut,
+ HomeMaterialWatermarkOut,
+ HomeMaterialWatermarkUpdate,
+)
+from app.services.home_material import home_material_service
+from app.services.operation_log import log_operation
+
+router = APIRouter(prefix="/admin/home-material", tags=["admin-home-material"])
+
+
+def _detail(**kwargs) -> str:
+ return json.dumps(kwargs, ensure_ascii=False, default=str)
+
+
+@router.get(
+ "/config",
+ response_model=HomeMaterialConfigOut,
+ summary="获取首页素材展示配置",
+ description="获取首页素材行业装修展示配置。配置存储于 system_configs,key=home_material_showcase_config。enabled=false 时前台不展示该模块。",
+)
+async def get_home_material_config(
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ _ = admin
+ return await home_material_service.get_config(db)
+
+
+@router.put(
+ "/config",
+ response_model=HomeMaterialConfigOut,
+ summary="保存首页素材展示配置",
+ description="保存首页素材展示开关、标题、副标题、后台是否展示原素材配置,并写入操作日志。",
+)
+async def save_home_material_config(
+ req: HomeMaterialConfigUpdate,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result, before, after = await home_material_service.save_config(db, req)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.CONFIG_UPDATE.value,
+ "PUT",
+ "/admin/home-material/config",
+ detail=_detail(before=before, after=after),
+ )
+ await db.commit()
+ return result
+
+
+@router.get(
+ "/categories",
+ response_model=HomeMaterialCategoryListOut,
+ summary="后台首页素材行业列表",
+ description="分页查询首页素材行业。列表不会连表,会先查行业列表,再按 category_id 批量 group by 查询素材数量后 map 回填。",
+)
+async def list_home_material_categories(
+ page: int = Query(1, ge=1, description="页码,默认1。"),
+ page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
+ keyword: str | None = Query(None, description="搜索行业名称或 key。"),
+ is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ _ = admin
+ return await home_material_service.list_categories(db, page=page, page_size=page_size, keyword=keyword, is_active=is_active)
+
+
+@router.post(
+ "/categories",
+ response_model=HomeMaterialCategoryOut,
+ summary="新增首页素材行业",
+ description="新增首页素材行业类别。key 只允许字母、数字、下划线、中划线,且未软删记录内唯一。",
+)
+async def create_home_material_category(
+ req: HomeMaterialCategoryCreate,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result, after = await home_material_service.create_category(db, req, admin.id)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.CATEGORY_CREATE.value,
+ "POST",
+ "/admin/home-material/categories",
+ detail=_detail(after=after),
+ )
+ await db.commit()
+ return result
+
+
+@router.put(
+ "/categories/{category_id}",
+ response_model=HomeMaterialCategoryOut,
+ summary="修改首页素材行业",
+ description="修改首页素材行业名称、key、描述、图标、启用状态和排序,并记录修改前后快照。",
+)
+async def update_home_material_category(
+ req: HomeMaterialCategoryUpdate,
+ category_id: str = Path(..., description="行业ID。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result, before, after = await home_material_service.update_category(db, category_id, req, admin.id)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.CATEGORY_UPDATE.value,
+ "PUT",
+ f"/admin/home-material/categories/{category_id}",
+ detail=_detail(before=before, after=after),
+ )
+ await db.commit()
+ return result
+
+
+@router.delete(
+ "/categories/{category_id}",
+ summary="删除首页素材行业",
+ description="软删首页素材行业。若行业下还有未删除素材,接口会拒绝删除,建议改为禁用。",
+)
+async def delete_home_material_category(
+ category_id: str = Path(..., description="行业ID。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ category, before = await home_material_service.delete_category(db, category_id, admin.id)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.CATEGORY_DELETE.value,
+ "DELETE",
+ f"/admin/home-material/categories/{category_id}",
+ detail=_detail(before=before, after={"deleted_at": category.deleted_at}),
+ )
+ await db.commit()
+ return {"message": "ok"}
+
+
+@router.get(
+ "/watermarks",
+ response_model=HomeMaterialWatermarkListOut,
+ summary="后台水印图片列表",
+ description="分页查询首页素材水印图片库。",
+)
+async def list_home_material_watermarks(
+ page: int = Query(1, ge=1, description="页码,默认1。"),
+ page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
+ is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ _ = admin
+ return await home_material_service.list_watermarks(db, page=page, page_size=page_size, is_active=is_active)
+
+
+@router.post(
+ "/watermarks",
+ response_model=HomeMaterialWatermarkOut,
+ summary="上传水印图片",
+ description="上传水印图片到水印库。支持 png/webp/jpg/jpeg。is_default=true 时自动取消其他默认水印。",
+)
+async def upload_home_material_watermark(
+ file: UploadFile = File(..., description="水印图片文件,建议 png 或 webp。"),
+ name: str | None = Form(None, description="水印名称,不传使用文件名。"),
+ is_default: bool = Form(False, description="是否设为默认水印。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result, after = await home_material_service.upload_watermark(db, file=file, name=name, is_default=is_default, admin_id=admin.id)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.WATERMARK_UPLOAD.value,
+ "POST",
+ "/admin/home-material/watermarks",
+ detail=_detail(after=after),
+ )
+ await db.commit()
+ return result
+
+
+@router.put(
+ "/watermarks/{watermark_id}",
+ response_model=HomeMaterialWatermarkOut,
+ summary="修改水印图片信息",
+ description="修改水印名称、默认状态、启用状态。设为默认水印时自动取消其他默认水印。",
+)
+async def update_home_material_watermark(
+ req: HomeMaterialWatermarkUpdate,
+ watermark_id: str = Path(..., description="水印ID。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result, before, after = await home_material_service.update_watermark(db, watermark_id, req, admin.id)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.WATERMARK_UPDATE.value,
+ "PUT",
+ f"/admin/home-material/watermarks/{watermark_id}",
+ detail=_detail(before=before, after=after),
+ )
+ await db.commit()
+ return result
+
+
+@router.delete(
+ "/watermarks/{watermark_id}",
+ summary="删除水印图片",
+ description="软删水印图片记录,不物理删除文件;历史素材仍保留已生成的水印素材。",
+)
+async def delete_home_material_watermark(
+ watermark_id: str = Path(..., description="水印ID。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ watermark, before = await home_material_service.delete_watermark(db, watermark_id, admin.id)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.WATERMARK_DELETE.value,
+ "DELETE",
+ f"/admin/home-material/watermarks/{watermark_id}",
+ detail=_detail(before=before, after={"deleted_at": watermark.deleted_at}),
+ )
+ await db.commit()
+ return {"message": "ok"}
+
+
+@router.post(
+ "/text-watermark-preview",
+ response_model=HomeMaterialTextWatermarkPreviewResponse,
+ summary="生成重复文字水印精准预览层",
+ description=(
+ "根据素材真实宽高和重复文字水印配置,使用后端固定开源字体生成透明 PNG 预览层 data URL。"
+ "该接口和最终 FFmpeg 叠加前的文字水印层共用同一套渲染逻辑,用于保证预览和实际效果一致。"
+ ),
+)
+async def preview_home_material_text_watermark(
+ req: HomeMaterialTextWatermarkPreviewRequest,
+ admin: User = Depends(get_admin_user),
+):
+ _ = admin
+ return await home_material_service.preview_text_watermark_layer(req)
+
+
+@router.get(
+ "/assets",
+ response_model=HomeMaterialAssetListOut,
+ summary="后台首页素材列表",
+ description="分页查询首页素材。不会连表,先查素材列表,再批量查询行业和水印 map 后回填 category_name/category_key/watermark_name。",
+)
+async def list_home_material_assets(
+ page: int = Query(1, ge=1, description="页码,默认1。"),
+ page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
+ category_id: str | None = Query(None, description="行业ID。"),
+ media_type: HomeMaterialMediaType | None = Query(None, description="素材类型:image图片,video视频。"),
+ status: HomeMaterialAssetStatus | None = Query(None, description="处理状态:draft草稿,processing处理中,success成功,failed失败。"),
+ is_active: bool | None = Query(None, description="是否前台展示。"),
+ keyword: str | None = Query(None, description="素材标题搜索。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ _ = admin
+ return await home_material_service.list_assets(
+ db,
+ page=page,
+ page_size=page_size,
+ category_id=category_id,
+ media_type=media_type,
+ status=status,
+ is_active=is_active,
+ keyword=keyword,
+ )
+
+
+@router.post(
+ "/assets",
+ response_model=HomeMaterialUploadResultOut,
+ summary="上传首页素材并生成水印",
+ description=(
+ "上传图片或视频素材,并使用水印图片生成水印版本。默认 wait=false 立即返回 processing,前端轮询 /assets/{asset_id}/status。"
+ "可传 watermark_id 使用水印库,也可传 watermark_file 临时上传水印;二者都不传时使用默认水印。"
+ "水印透明度 opacity_level 为 1-10;position 支持九宫格和 custom;custom 需要 custom_x_ratio/custom_y_ratio;"
+ "size_mode=ratio 使用 width_ratio,size_mode=px 使用 width_px。"
+ ),
+)
+async def upload_home_material_asset(
+ category_id: str = Form(..., description="行业ID。"),
+ file: UploadFile = File(..., description="图片或视频素材文件。"),
+ media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
+ title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"),
+ watermark_type: HomeMaterialWatermarkType = Form(HomeMaterialWatermarkType.IMAGE, description="水印类型:image 图片水印;repeated_text 重复文字水印。"),
+ watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
+ watermark_file: UploadFile | None = File(None, description="临时图片水印,可选。watermark_type=image 时使用。"),
+ opacity_level: int = Form(6, ge=1, le=10, description="图片水印透明度档位,1-10。"),
+ position: HomeMaterialWatermarkPosition = Form(HomeMaterialWatermarkPosition.BOTTOM_RIGHT, description="水印位置。"),
+ custom_x_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置X比例,position=custom时必填。"),
+ custom_y_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置Y比例,position=custom时必填。"),
+ size_mode: HomeMaterialWatermarkSizeMode = Form(HomeMaterialWatermarkSizeMode.RATIO, description="水印尺寸模式:ratio/px。"),
+ width_ratio: float | None = Form(0.18, ge=0.01, le=1, description="size_mode=ratio时使用,水印宽度占素材宽度比例。"),
+ width_px: int | None = Form(None, ge=1, le=10000, description="size_mode=px时使用,固定水印宽度。"),
+ margin_x: int = Form(24, ge=0, le=2000, description="图片水印横向边距。"),
+ margin_y: int = Form(24, ge=0, le=2000, description="图片水印纵向边距。"),
+ text_watermark_text: str | None = Form(None, description="重复文字水印内容。watermark_type=repeated_text 时必填。"),
+ text_watermark_opacity_level: int = Form(2, ge=1, le=10, description="重复文字透明度档位,1-10。"),
+ text_watermark_font_size_px: int = Form(28, ge=8, le=160, description="重复文字字号 px。"),
+ text_watermark_color: str = Form("#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$", description="重复文字颜色,#RRGGBB。"),
+ text_watermark_rotate_deg: int = Form(-30, ge=-90, le=90, description="重复文字旋转角度。"),
+ text_watermark_gap_x: int = Form(220, ge=20, le=2000, description="重复文字横向间距。"),
+ text_watermark_gap_y: int = Form(140, ge=20, le=2000, description="重复文字纵向间距。"),
+ text_watermark_staggered: bool = Form(True, description="重复文字是否交错排列。"),
+ is_active: bool = Form(True, description="是否前台展示。"),
+ sort_order: int = Form(0, ge=0, le=999999, description="排序值。"),
+ wait: bool = Form(False, description="是否等待处理完成。"),
+ wait_timeout_seconds: int = Form(30, ge=1, le=60, description="wait=true时最长等待秒数。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ text_config = None
+ if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT:
+ text_config = HomeMaterialTextWatermarkConfig(
+ text=text_watermark_text or "",
+ opacity_level=text_watermark_opacity_level,
+ font_size_px=text_watermark_font_size_px,
+ color=text_watermark_color,
+ rotate_deg=text_watermark_rotate_deg,
+ gap_x=text_watermark_gap_x,
+ gap_y=text_watermark_gap_y,
+ staggered=text_watermark_staggered,
+ )
+ config = HomeMaterialWatermarkConfig(
+ watermark_type=watermark_type,
+ watermark_id=watermark_id,
+ opacity_level=opacity_level,
+ position=position,
+ custom_x_ratio=custom_x_ratio,
+ custom_y_ratio=custom_y_ratio,
+ size_mode=size_mode,
+ width_ratio=width_ratio,
+ width_px=width_px,
+ margin_x=margin_x,
+ margin_y=margin_y,
+ text_watermark=text_config,
+ )
+ result, after = await home_material_service.upload_asset(
+ db,
+ category_id=category_id,
+ file=file,
+ media_type=media_type,
+ title=title,
+ watermark_id=watermark_id,
+ watermark_file=watermark_file,
+ watermark_config=config,
+ is_active=is_active,
+ sort_order=sort_order,
+ admin_id=admin.id,
+ )
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.ASSET_UPLOAD.value,
+ "POST",
+ "/admin/home-material/assets",
+ detail=_detail(after=after),
+ )
+ await db.commit()
+ home_material_service.start_watermark_task(result.id)
+ if wait:
+ return await home_material_service.wait_for_asset_result(result.id, wait_timeout_seconds)
+ return result
+
+
+@router.get(
+ "/assets/{asset_id}",
+ response_model=HomeMaterialAssetOut,
+ summary="后台首页素材详情",
+ description="查询单个素材详情。详情仍不使用 ORM relationship,会按 category_id/watermark_id 批量查询 map 后组装。",
+)
+async def get_home_material_asset(
+ asset_id: str = Path(..., description="素材ID。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ _ = admin
+ return await home_material_service.get_asset_detail(db, asset_id)
+
+
+@router.get(
+ "/assets/{asset_id}/status",
+ response_model=HomeMaterialAssetStatusOut,
+ summary="查询首页素材水印处理状态",
+ description="前端上传或重新生成后轮询本接口,status=success 时展示 watermarked_url,status=failed 时展示 error_message 并允许重新生成。",
+)
+async def get_home_material_asset_status(
+ asset_id: str = Path(..., description="素材ID。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ _ = admin
+ return await home_material_service.get_asset_status(db, asset_id)
+
+
+@router.put(
+ "/assets/{asset_id}",
+ response_model=HomeMaterialAssetOut,
+ summary="修改首页素材展示信息",
+ description="只修改行业、标题、启用状态、排序,不重新生成水印。",
+)
+async def update_home_material_asset(
+ req: HomeMaterialAssetUpdate,
+ asset_id: str = Path(..., description="素材ID。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result, before, after = await home_material_service.update_asset(db, asset_id, req, admin.id)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.ASSET_UPDATE.value,
+ "PUT",
+ f"/admin/home-material/assets/{asset_id}",
+ detail=_detail(before=before, after=after),
+ )
+ await db.commit()
+ return result
+
+
+@router.post(
+ "/assets/{asset_id}/regenerate-watermark",
+ response_model=HomeMaterialUploadResultOut,
+ summary="重新生成首页素材水印",
+ description="基于原始素材和新的水印配置重新生成水印文件。默认 wait=false,前端轮询状态接口。失败时保留旧 watermarked_url,但 status=failed。",
+)
+async def regenerate_home_material_watermark(
+ req: HomeMaterialRegenerateWatermarkRequest,
+ asset_id: str = Path(..., description="素材ID。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result, before, after = await home_material_service.prepare_regenerate(db, asset_id, req, admin.id)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.ASSET_REGENERATE_WATERMARK.value,
+ "POST",
+ f"/admin/home-material/assets/{asset_id}/regenerate-watermark",
+ detail=_detail(before=before, after=after),
+ )
+ await db.commit()
+ home_material_service.start_watermark_task(asset_id)
+ if req.wait:
+ return await home_material_service.wait_for_asset_result(asset_id, req.wait_timeout_seconds)
+ return result
+
+
+@router.delete(
+ "/assets/{asset_id}",
+ summary="删除首页素材",
+ description="软删素材记录,不物理删除原文件和水印文件,避免误删历史展示资源。",
+)
+async def delete_home_material_asset(
+ asset_id: str = Path(..., description="素材ID。"),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ asset, before = await home_material_service.delete_asset(db, asset_id, admin.id)
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ HomeMaterialOperationEnum.ASSET_DELETE.value,
+ "DELETE",
+ f"/admin/home-material/assets/{asset_id}",
+ detail=_detail(before=before, after={"deleted_at": asset.deleted_at}),
+ )
+ await db.commit()
+ return {"message": "ok"}
diff --git a/video-gen-api/app/api/v1/__init__.py b/video-gen-api/app/api/v1/__init__.py
index 017aa2fe..72863a45 100644
--- a/video-gen-api/app/api/v1/__init__.py
+++ b/video-gen-api/app/api/v1/__init__.py
@@ -28,6 +28,7 @@ from app.api.v1.material_consumption import router as material_consumption_route
from app.api.v1.open_type import router as open_type_router
from app.api.v1.resources_material import router as resources_material_router
from app.api.v1.contact import router as contact_router
+from app.api.v1.home_materials import router as home_materials_router
from app.api.admin import router as admin_module_router
api_router = APIRouter()
@@ -59,4 +60,5 @@ api_router.include_router(material_consumption_router)
api_router.include_router(open_type_router)
api_router.include_router(resources_material_router)
api_router.include_router(contact_router)
+api_router.include_router(home_materials_router)
api_router.include_router(admin_module_router)
diff --git a/video-gen-api/app/api/v1/home_materials.py b/video-gen-api/app/api/v1/home_materials.py
new file mode 100644
index 00000000..5254b455
--- /dev/null
+++ b/video-gen-api/app/api/v1/home_materials.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.dependencies import get_db
+from app.enums.home_material import HomeMaterialMediaType, HomeMaterialPublicResponseMode
+from app.schemas.home_material import (
+ HomeMaterialPublicCategoryListOut,
+ HomeMaterialPublicFlatOut,
+ HomeMaterialPublicGroupedOut,
+)
+from app.services.home_material import home_material_service
+
+router = APIRouter(prefix="/home-materials", tags=["home-materials"])
+
+
+@router.get(
+ "/categories",
+ response_model=HomeMaterialPublicCategoryListOut,
+ summary="获取首页素材行业列表",
+ description=(
+ "获取首页素材行业列表,用于前端渲染行业 Tab/筛选。"
+ "只返回已启用、未软删行业;only_has_assets=true 时只返回存在 success + is_active + watermarked_url 的行业。"
+ "统计素材数量时不连表,先查行业列表,再按 category_id 批量 group by 查询素材数量并 map 回填。"
+ ),
+)
+async def get_public_home_material_categories(
+ with_asset_count: bool = Query(True, description="是否返回每个行业下可展示素材数量。"),
+ media_type: HomeMaterialMediaType | None = Query(None, description="统计指定素材类型:image图片,video视频。不传表示全部。"),
+ only_has_assets: bool = Query(True, description="是否只返回有可展示素材的行业。"),
+ db: AsyncSession = Depends(get_db),
+):
+ return await home_material_service.get_public_categories(
+ db,
+ with_asset_count=with_asset_count,
+ media_type=media_type,
+ only_has_assets=only_has_assets,
+ )
+
+
+@router.get(
+ "",
+ response_model=HomeMaterialPublicGroupedOut | HomeMaterialPublicFlatOut,
+ summary="获取首页素材展示数据",
+ description=(
+ "获取首页素材展示数据。支持不传行业返回全部行业分组;支持 category_id/category_key/category_ids/category_keys 按行业过滤,"
+ "优先级为 category_id > category_key > category_ids > category_keys。"
+ "response_mode=grouped 时按行业分组并限制每个行业 limit_per_category 条;"
+ "response_mode=flat 时返回平铺分页素材流。前台接口只返回水印素材 url,不返回原始素材 original_url。"
+ ),
+)
+async def get_public_home_materials(
+ category_id: str | None = Query(None, description="单个行业ID。优先级最高。"),
+ category_key: str | None = Query(None, description="单个行业key。未传 category_id 时生效。"),
+ category_ids: str | None = Query(None, description="多个行业ID,英文逗号分隔。未传单行业参数时生效。"),
+ category_keys: str | None = Query(None, description="多个行业key,英文逗号分隔。优先级最低。"),
+ media_type: HomeMaterialMediaType | None = Query(None, description="素材类型筛选:image图片,video视频。不传表示全部。"),
+ limit_per_category: int = Query(8, ge=1, le=50, description="grouped 模式下每个行业最多返回几条素材,默认8,最大50。"),
+ include_empty_categories: bool = Query(False, description="grouped 模式下是否返回无素材行业。默认 false。"),
+ response_mode: HomeMaterialPublicResponseMode = Query(HomeMaterialPublicResponseMode.GROUPED, description="返回模式:grouped按行业分组,flat平铺分页。"),
+ page: int = Query(1, ge=1, description="flat 模式页码,默认1。"),
+ page_size: int = Query(20, ge=1, le=100, description="flat 模式每页数量,默认20,最大100。"),
+ db: AsyncSession = Depends(get_db),
+):
+ return await home_material_service.get_public_home_materials(
+ db,
+ category_id=category_id,
+ category_key=category_key,
+ category_ids=category_ids,
+ category_keys=category_keys,
+ media_type=media_type,
+ limit_per_category=limit_per_category,
+ include_empty_categories=include_empty_categories,
+ response_mode=response_mode,
+ page=page,
+ page_size=page_size,
+ )
diff --git a/video-gen-api/app/config.py b/video-gen-api/app/config.py
index 29c753ef..727c69eb 100644
--- a/video-gen-api/app/config.py
+++ b/video-gen-api/app/config.py
@@ -65,6 +65,27 @@ class Settings(BaseSettings):
STORAGE_VIDEO_COVER_LOCAL_PATH: str = "./storage/generate/covers"
UPLOAD_LOCAL_PATH: str = "./storage/uploads"
+ # 首页素材行业装修配置。
+ # 文件实际挂载仍走 /uploads,目录在 UPLOAD_LOCAL_PATH/home_materials 下。
+ HOME_MATERIAL_MAX_IMAGE_MB: int = 20
+ HOME_MATERIAL_MAX_VIDEO_MB: int = 300
+ HOME_MATERIAL_MAX_WATERMARK_MB: int = 10
+ HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS: int = 300
+ HOME_MATERIAL_IMAGE_WATERMARK_CONCURRENCY: int = 4
+ HOME_MATERIAL_VIDEO_WATERMARK_CONCURRENCY: int = 2
+ HOME_MATERIAL_FFMPEG_TIMEOUT_SECONDS: int = 600
+ HOME_MATERIAL_PROCESSING_STALE_MINUTES: int = 30
+ # 重复文字水印配置。
+ # 字体必须使用开源可商用字体文件,推荐 Noto Sans CJK SC / Source Han Sans SC,且前端预览层和后端实际生成均由后端使用该字体渲染。
+ # 不允许自动 fallback 到系统字体,避免预览不一致和字体授权风险。
+ HOME_MATERIAL_TEXT_WATERMARK_FONT: str = "./resource/font/NotoSansCJKsc-Regular.otf"
+ HOME_MATERIAL_TEXT_WATERMARK_FONT_LICENSE: str = "SIL Open Font License 1.1"
+ HOME_MATERIAL_TEXT_WATERMARK_MAX_TEXT_LENGTH: int = 64
+ HOME_MATERIAL_TEXT_WATERMARK_MAX_FONT_SIZE: int = 160
+ HOME_MATERIAL_TEXT_WATERMARK_MAX_GAP: int = 2000
+ HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_WIDTH: int = 8192
+ HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_HEIGHT: int = 8192
+
# 本地视频封面截帧配置。
# 说明:
# - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。
diff --git a/video-gen-api/app/enums/__init__.py b/video-gen-api/app/enums/__init__.py
index 9272bc44..cf7cee1f 100644
--- a/video-gen-api/app/enums/__init__.py
+++ b/video-gen-api/app/enums/__init__.py
@@ -12,3 +12,4 @@ from app.enums.sms import *
from app.enums.notification import *
from app.enums.resource_capacity import *
from app.enums.team import *
+from app.enums.home_material import *
diff --git a/video-gen-api/app/enums/home_material.py b/video-gen-api/app/enums/home_material.py
new file mode 100644
index 00000000..d914a4d4
--- /dev/null
+++ b/video-gen-api/app/enums/home_material.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+from enum import StrEnum
+
+
+class HomeMaterialConfigKeyEnum(StrEnum):
+ """首页素材装修配置 key。"""
+
+ SHOWCASE_CONFIG = "home_material_showcase_config"
+
+
+class HomeMaterialMediaType(StrEnum):
+ """首页素材媒体类型。"""
+
+ IMAGE = "image"
+ VIDEO = "video"
+
+
+class HomeMaterialAssetStatus(StrEnum):
+ """首页素材水印处理状态。"""
+
+ DRAFT = "draft"
+ PROCESSING = "processing"
+ SUCCESS = "success"
+ FAILED = "failed"
+
+
+
+
+class HomeMaterialWatermarkType(StrEnum):
+ """首页素材水印类型。"""
+
+ IMAGE = "image"
+ REPEATED_TEXT = "repeated_text"
+
+
+class HomeMaterialWatermarkPosition(StrEnum):
+ """水印位置枚举。"""
+
+ TOP_LEFT = "top_left"
+ TOP_CENTER = "top_center"
+ TOP_RIGHT = "top_right"
+ MIDDLE_LEFT = "middle_left"
+ CENTER = "center"
+ MIDDLE_RIGHT = "middle_right"
+ BOTTOM_LEFT = "bottom_left"
+ BOTTOM_CENTER = "bottom_center"
+ BOTTOM_RIGHT = "bottom_right"
+ CUSTOM = "custom"
+
+
+class HomeMaterialWatermarkSizeMode(StrEnum):
+ """水印尺寸模式。"""
+
+ RATIO = "ratio"
+ PX = "px"
+
+
+class HomeMaterialPublicResponseMode(StrEnum):
+ """前台素材返回模式。"""
+
+ GROUPED = "grouped"
+ FLAT = "flat"
+
+
+class HomeMaterialOperationEnum(StrEnum):
+ """后台操作日志 action 枚举。"""
+
+ CONFIG_UPDATE = "更新首页素材展示配置"
+ CATEGORY_CREATE = "新增首页素材行业"
+ CATEGORY_UPDATE = "修改首页素材行业"
+ CATEGORY_DELETE = "删除首页素材行业"
+ WATERMARK_UPLOAD = "上传首页素材水印"
+ WATERMARK_UPDATE = "修改首页素材水印"
+ WATERMARK_DELETE = "删除首页素材水印"
+ ASSET_UPLOAD = "上传首页素材"
+ ASSET_UPDATE = "修改首页素材"
+ ASSET_DELETE = "删除首页素材"
+ ASSET_REGENERATE_WATERMARK = "重新生成首页素材水印"
+
+
+HOME_MATERIAL_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
+HOME_MATERIAL_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
+HOME_MATERIAL_WATERMARK_EXTENSIONS = {".png", ".webp", ".jpg", ".jpeg"}
+
+HOME_MATERIAL_DEFAULT_CONFIG = {
+ "enabled": False,
+ "title": "行业素材案例",
+ "subtitle": "精选图片与视频素材展示",
+ "show_original_in_admin": True,
+}
diff --git a/video-gen-api/app/models/__init__.py b/video-gen-api/app/models/__init__.py
index 44331509..1d2f5b9b 100644
--- a/video-gen-api/app/models/__init__.py
+++ b/video-gen-api/app/models/__init__.py
@@ -30,6 +30,7 @@ from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp
+from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
__all__ = [
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
@@ -44,4 +45,5 @@ __all__ = [
"ModuleGenerationProject", "ModuleGenerationStep",
"ShotReplicateTaskSet", "ShotReplicateSegment",
"UserOAuth", "UserOAuthAccount", "UserOAuthApp",
+ "HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
]
diff --git a/video-gen-api/app/models/home_material/__init__.py b/video-gen-api/app/models/home_material/__init__.py
new file mode 100644
index 00000000..ece0ec50
--- /dev/null
+++ b/video-gen-api/app/models/home_material/__init__.py
@@ -0,0 +1,9 @@
+from app.models.home_material.asset import HomeMaterialAsset
+from app.models.home_material.category import HomeMaterialCategory
+from app.models.home_material.watermark import HomeMaterialWatermark
+
+__all__ = [
+ "HomeMaterialAsset",
+ "HomeMaterialCategory",
+ "HomeMaterialWatermark",
+]
diff --git a/video-gen-api/app/models/home_material/asset.py b/video-gen-api/app/models/home_material/asset.py
new file mode 100644
index 00000000..2d333269
--- /dev/null
+++ b/video-gen-api/app/models/home_material/asset.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from decimal import Decimal
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, Boolean, DateTime, Index, Integer, Numeric, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.enums.home_material import HomeMaterialAssetStatus, HomeMaterialMediaType
+from app.models.base import Base, SoftDeleteMixin, TimestampMixin
+
+
+class HomeMaterialAsset(Base, TimestampMixin, SoftDeleteMixin):
+ """首页素材资产。"""
+
+ __tablename__ = "home_material_assets"
+ __table_args__ = (
+ Index("idx_home_material_assets_admin_list", "deleted_at", "category_id", "media_type", "status", "is_active", "sort_order", "created_at"),
+ Index("idx_home_material_assets_category_sort", "category_id", "sort_order", "created_at"),
+ Index("idx_home_material_assets_status", "status"),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ category_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="行业类别ID")
+ title: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True, comment="素材标题")
+ media_type: Mapped[str] = mapped_column(
+ String(16),
+ default=HomeMaterialMediaType.IMAGE.value,
+ server_default=HomeMaterialMediaType.IMAGE.value,
+ nullable=False,
+ index=True,
+ comment="素材类型:image图片,video视频",
+ )
+ original_url: Mapped[str] = mapped_column(Text, nullable=False, comment="原始素材URL")
+ original_storage_path: Mapped[str] = mapped_column(Text, nullable=False, comment="原始素材本地路径")
+ watermarked_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印素材URL")
+ watermarked_storage_path: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印素材本地路径")
+ cover_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面URL")
+ cover_storage_path: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面本地路径")
+ watermark_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="水印图片ID")
+ watermark_config_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印配置快照JSON")
+ status: Mapped[str] = mapped_column(
+ String(32),
+ default=HomeMaterialAssetStatus.DRAFT.value,
+ server_default=HomeMaterialAssetStatus.DRAFT.value,
+ nullable=False,
+ index=True,
+ comment="处理状态:draft/processing/success/failed",
+ )
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="处理失败原因")
+ width: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材宽度")
+ height: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材高度")
+ duration_seconds: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True, comment="视频时长,图片为空")
+ file_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False, comment="原始文件大小")
+ watermarked_file_size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True, comment="水印后文件大小")
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", nullable=False, index=True, comment="是否前台展示")
+ sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False, index=True, comment="排序,越小越靠前")
+ processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="处理完成时间")
+ created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="创建管理员ID")
+ updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="更新管理员ID")
diff --git a/video-gen-api/app/models/home_material/category.py b/video-gen-api/app/models/home_material/category.py
new file mode 100644
index 00000000..145afee8
--- /dev/null
+++ b/video-gen-api/app/models/home_material/category.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from sqlalchemy import Boolean, Index, Integer, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base, SoftDeleteMixin, TimestampMixin
+
+
+class HomeMaterialCategory(Base, TimestampMixin, SoftDeleteMixin):
+ """首页素材行业类别。"""
+
+ __tablename__ = "home_material_categories"
+ __table_args__ = (
+ Index("idx_home_material_categories_active_sort", "deleted_at", "is_active", "sort_order", "created_at"),
+ Index("idx_home_material_categories_key", "key"),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="行业名称")
+ key: Mapped[str] = mapped_column(String(64), nullable=False, comment="行业唯一标识,前台可按 key 查询")
+ description: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="行业描述")
+ icon: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="前端图标名称")
+ is_active: Mapped[bool] = mapped_column(
+ Boolean,
+ default=True,
+ server_default="true",
+ nullable=False,
+ index=True,
+ comment="是否启用",
+ )
+ sort_order: Mapped[int] = mapped_column(
+ Integer,
+ default=0,
+ server_default="0",
+ nullable=False,
+ index=True,
+ comment="排序,越小越靠前",
+ )
+ created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="创建管理员ID")
+ updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="更新管理员ID")
diff --git a/video-gen-api/app/models/home_material/watermark.py b/video-gen-api/app/models/home_material/watermark.py
new file mode 100644
index 00000000..2a427c63
--- /dev/null
+++ b/video-gen-api/app/models/home_material/watermark.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from sqlalchemy import BigInteger, Boolean, Index, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base, SoftDeleteMixin, TimestampMixin
+
+
+class HomeMaterialWatermark(Base, TimestampMixin, SoftDeleteMixin):
+ """首页素材水印图片库。"""
+
+ __tablename__ = "home_material_watermarks"
+ __table_args__ = (
+ Index("idx_home_material_watermarks_active", "deleted_at", "is_active", "created_at"),
+ Index("idx_home_material_watermarks_default", "deleted_at", "is_default"),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="水印名称")
+ file_url: Mapped[str] = mapped_column(Text, nullable=False, comment="水印图片URL")
+ storage_path: Mapped[str] = mapped_column(Text, nullable=False, comment="水印图片本地路径")
+ file_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="原始文件名")
+ file_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False, comment="文件大小")
+ width: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="水印图片宽度")
+ height: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="水印图片高度")
+ is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False, index=True, comment="是否默认水印")
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", nullable=False, index=True, comment="是否启用")
+ created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="创建管理员ID")
+ updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="更新管理员ID")
diff --git a/video-gen-api/app/schemas/home_material.py b/video-gen-api/app/schemas/home_material.py
new file mode 100644
index 00000000..8c1d117e
--- /dev/null
+++ b/video-gen-api/app/schemas/home_material.py
@@ -0,0 +1,301 @@
+from __future__ import annotations
+
+from decimal import Decimal
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+from app.enums.home_material import (
+ HomeMaterialAssetStatus,
+ HomeMaterialMediaType,
+ HomeMaterialPublicResponseMode,
+ HomeMaterialWatermarkPosition,
+ HomeMaterialWatermarkSizeMode,
+ HomeMaterialWatermarkType,
+)
+from app.schemas.common import NaiveDatetimeOptional
+
+
+class HomeMaterialConfigOut(BaseModel):
+ enabled: bool = Field(False, description="首页是否展示素材装修模块。false 时前台行业和素材接口返回 enabled=false 且列表为空。")
+ title: str = Field("行业素材案例", max_length=64, description="首页素材模块标题。")
+ subtitle: str = Field("精选图片与视频素材展示", max_length=128, description="首页素材模块副标题。")
+ show_original_in_admin: bool = Field(True, description="后台是否展示原始素材链接。前台接口永远不返回原始素材链接。")
+
+
+class HomeMaterialConfigUpdate(BaseModel):
+ enabled: bool = Field(False, description="首页是否展示素材装修模块。")
+ title: str = Field("行业素材案例", min_length=1, max_length=64, description="首页素材模块标题。")
+ subtitle: str = Field("精选图片与视频素材展示", max_length=128, description="首页素材模块副标题。")
+ show_original_in_admin: bool = Field(True, description="后台是否展示原始素材链接。")
+
+
+class HomeMaterialCategoryCreate(BaseModel):
+ name: str = Field(..., min_length=1, max_length=64, description="行业名称,例如:美妆护肤、食品生鲜。")
+ key: str = Field(..., min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$", description="行业唯一标识,只允许字母、数字、下划线、中划线;前台可通过 category_key 查询。")
+ description: str | None = Field(None, max_length=255, description="行业描述。")
+ icon: str | None = Field(None, max_length=64, description="前端图标名称,例如 PictureOutlined、VideoCameraOutlined。")
+ is_active: bool = Field(True, description="是否启用。禁用后前台不展示。")
+ sort_order: int = Field(0, ge=0, le=999999, description="排序值,越小越靠前。")
+
+
+class HomeMaterialCategoryUpdate(HomeMaterialCategoryCreate):
+ pass
+
+
+class HomeMaterialCategoryOut(BaseModel):
+ id: str = Field(..., description="行业ID。")
+ name: str = Field(..., description="行业名称。")
+ key: str = Field(..., description="行业唯一标识。")
+ description: str | None = Field(None, description="行业描述。")
+ icon: str | None = Field(None, description="前端图标名称。")
+ is_active: bool = Field(True, description="是否启用。")
+ sort_order: int = Field(0, description="排序值。")
+ asset_count: int = Field(0, description="可展示素材总数。后台列表按未删除素材统计,前台按 success + is_active 统计。")
+ image_count: int = Field(0, description="图片素材数量。")
+ video_count: int = Field(0, description="视频素材数量。")
+ created_at: NaiveDatetimeOptional = Field(None, description="创建时间。")
+ updated_at: NaiveDatetimeOptional = Field(None, description="更新时间。")
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class HomeMaterialCategoryListOut(BaseModel):
+ items: list[HomeMaterialCategoryOut] = Field(default_factory=list, description="行业列表。")
+ total: int = Field(0, description="符合条件的行业总数。")
+
+
+class HomeMaterialWatermarkOut(BaseModel):
+ id: str = Field(..., description="水印ID。")
+ name: str = Field(..., description="水印名称。")
+ file_url: str = Field(..., description="水印图片URL。")
+ file_name: str | None = Field(None, description="上传文件名。")
+ file_size_bytes: int = Field(0, description="文件大小,单位字节。")
+ width: int | None = Field(None, description="水印图片宽度。")
+ height: int | None = Field(None, description="水印图片高度。")
+ is_default: bool = Field(False, description="是否默认水印。")
+ is_active: bool = Field(True, description="是否启用。")
+ created_at: NaiveDatetimeOptional = None
+ updated_at: NaiveDatetimeOptional = None
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class HomeMaterialWatermarkUpdate(BaseModel):
+ name: str = Field(..., min_length=1, max_length=128, description="水印名称。")
+ is_default: bool = Field(False, description="是否设为默认水印。设为 true 时会自动取消其他默认水印。")
+ is_active: bool = Field(True, description="是否启用。")
+
+
+class HomeMaterialWatermarkListOut(BaseModel):
+ items: list[HomeMaterialWatermarkOut] = Field(default_factory=list, description="水印列表。")
+ total: int = Field(0, description="符合条件的水印总数。")
+
+
+class HomeMaterialTextWatermarkConfig(BaseModel):
+ """重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。"""
+
+ text: str = Field(..., min_length=1, max_length=64, description="重复水印文字。")
+ opacity_level: int = Field(2, ge=1, le=10, description="文字透明度档位,1-10;1=0.1,10=1.0。")
+ opacity: float = Field(0.2, ge=0.1, le=1.0, description="实际透明度,后端根据 opacity_level 计算。")
+ font_size_px: int = Field(28, ge=8, le=160, description="字号,单位 px。")
+ color: str = Field("#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$", description="文字颜色,#RRGGBB。")
+ rotate_deg: int = Field(-30, ge=-90, le=90, description="文字旋转角度,单位度。")
+ gap_x: int = Field(220, ge=20, le=2000, description="横向平铺间距。")
+ gap_y: int = Field(140, ge=20, le=2000, description="纵向平铺间距。")
+ staggered: bool = Field(True, description="是否交错排列。")
+
+ @field_validator("text")
+ @classmethod
+ def validate_text(cls, value: str) -> str:
+ value = (value or "").strip()
+ if not value:
+ raise ValueError("重复文字水印内容不能为空")
+ return value
+
+ @model_validator(mode="after")
+ def fill_opacity(self) -> "HomeMaterialTextWatermarkConfig":
+ self.opacity = round(self.opacity_level / 10, 2)
+ return self
+
+
+class HomeMaterialWatermarkConfig(BaseModel):
+ watermark_type: HomeMaterialWatermarkType = Field(HomeMaterialWatermarkType.IMAGE, description="水印类型:image=图片水印;repeated_text=重复文字水印。旧数据无该字段时默认 image。")
+ watermark_id: str | None = Field(None, description="图片水印ID。watermark_type=image 时使用。上传素材时可由 watermark_id 指定,也可通过临时 watermark_file 生成。")
+ opacity_level: int = Field(6, ge=1, le=10, description="图片水印透明度档位,1-10;1=0.1,10=1.0。")
+ opacity: float = Field(0.6, ge=0.1, le=1.0, description="图片水印实际透明度,后端根据 opacity_level 计算。")
+ position: HomeMaterialWatermarkPosition = Field(HomeMaterialWatermarkPosition.BOTTOM_RIGHT, description="图片水印位置枚举。")
+ custom_x_ratio: float | None = Field(None, ge=0, le=1, description="图片水印自定义拖拽位置 X 比例,position=custom 时使用,0-1。")
+ custom_y_ratio: float | None = Field(None, ge=0, le=1, description="图片水印自定义拖拽位置 Y 比例,position=custom 时使用,0-1。")
+ size_mode: HomeMaterialWatermarkSizeMode = Field(HomeMaterialWatermarkSizeMode.RATIO, description="图片水印尺寸模式:ratio=按素材宽度比例;px=固定像素宽度。")
+ width_ratio: float | None = Field(0.18, ge=0.01, le=1, description="图片水印 size_mode=ratio 时使用,水印宽度占素材宽度比例,例如 0.18。")
+ width_px: int | None = Field(None, ge=1, le=10000, description="图片水印 size_mode=px 时使用,固定水印宽度。")
+ margin_x: int = Field(24, ge=0, le=2000, description="图片水印横向边距,九宫格位置使用。")
+ margin_y: int = Field(24, ge=0, le=2000, description="图片水印纵向边距,九宫格位置使用。")
+ text_watermark: HomeMaterialTextWatermarkConfig | None = Field(None, description="重复文字水印配置。watermark_type=repeated_text 时必填。")
+
+ @model_validator(mode="after")
+ def validate_by_type(self) -> "HomeMaterialWatermarkConfig":
+ self.opacity = round(self.opacity_level / 10, 2)
+ if self.watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT:
+ if self.text_watermark is None:
+ raise ValueError("watermark_type=repeated_text 时 text_watermark 必填")
+ self.watermark_id = None
+ return self
+
+ if self.position == HomeMaterialWatermarkPosition.CUSTOM:
+ if self.custom_x_ratio is None or self.custom_y_ratio is None:
+ raise ValueError("position=custom 时 custom_x_ratio 和 custom_y_ratio 必填")
+ if self.size_mode == HomeMaterialWatermarkSizeMode.RATIO and self.width_ratio is None:
+ raise ValueError("size_mode=ratio 时 width_ratio 必填")
+ if self.size_mode == HomeMaterialWatermarkSizeMode.PX and self.width_px is None:
+ raise ValueError("size_mode=px 时 width_px 必填")
+ self.text_watermark = None
+ return self
+
+
+class HomeMaterialTextWatermarkPreviewRequest(BaseModel):
+ width: int = Field(..., ge=1, le=8192, description="预览层宽度,建议传素材真实宽度。")
+ height: int = Field(..., ge=1, le=8192, description="预览层高度,建议传素材真实高度。")
+ text_watermark: HomeMaterialTextWatermarkConfig = Field(..., description="重复文字水印配置。")
+
+
+class HomeMaterialTextWatermarkPreviewResponse(BaseModel):
+ width: int = Field(..., description="预览层宽度。")
+ height: int = Field(..., description="预览层高度。")
+ preview_layer_data_url: str = Field(..., description="透明 PNG 预览层 data URL。")
+
+
+class HomeMaterialAssetOut(BaseModel):
+ id: str = Field(..., description="素材ID。")
+ category_id: str = Field(..., description="行业ID。")
+ category_name: str | None = Field(None, description="行业名称,列表接口批量查询回填。")
+ category_key: str | None = Field(None, description="行业key,列表接口批量查询回填。")
+ title: str | None = Field(None, description="素材标题。")
+ media_type: HomeMaterialMediaType = Field(..., description="素材类型:image=图片,video=视频。")
+ status: HomeMaterialAssetStatus = Field(..., description="处理状态:draft草稿,processing处理中,success成功,failed失败。")
+ original_url: str | None = Field(None, description="原始素材URL,仅后台返回;前台不返回。")
+ watermarked_url: str | None = Field(None, description="水印素材URL。")
+ cover_url: str | None = Field(None, description="视频封面URL。")
+ watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。")
+ watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。")
+ watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。")
+ width: int | None = Field(None, description="素材宽度。")
+ height: int | None = Field(None, description="素材高度。")
+ duration_seconds: Decimal | float | None = Field(None, description="视频时长,图片为空。")
+ file_size_bytes: int = Field(0, description="原始文件大小。")
+ watermarked_file_size_bytes: int | None = Field(None, description="水印后文件大小。")
+ is_active: bool = Field(True, description="是否前台展示。")
+ sort_order: int = Field(0, description="排序值。")
+ error_message: str | None = Field(None, description="失败原因。")
+ processed_at: NaiveDatetimeOptional = Field(None, description="处理完成时间。")
+ created_at: NaiveDatetimeOptional = None
+ updated_at: NaiveDatetimeOptional = None
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class HomeMaterialAssetListOut(BaseModel):
+ items: list[HomeMaterialAssetOut] = Field(default_factory=list, description="素材列表。")
+ total: int = Field(0, description="符合条件的素材总数。")
+
+
+class HomeMaterialAssetUpdate(BaseModel):
+ category_id: str = Field(..., description="行业ID。")
+ title: str | None = Field(None, max_length=128, description="素材标题。")
+ is_active: bool = Field(True, description="是否前台展示。")
+ sort_order: int = Field(0, ge=0, le=999999, description="排序值。")
+
+ @field_validator("title")
+ @classmethod
+ def clean_title(cls, value: str | None) -> str | None:
+ value = (value or "").strip()
+ return value or None
+
+
+class HomeMaterialRegenerateWatermarkRequest(HomeMaterialWatermarkConfig):
+ wait: bool = Field(False, description="是否等待水印处理完成。默认 false,前端轮询状态。")
+ wait_timeout_seconds: int = Field(30, ge=1, le=60, description="wait=true 时最长等待秒数,最大 60 秒。")
+
+
+class HomeMaterialAssetStatusOut(BaseModel):
+ id: str = Field(..., description="素材ID。")
+ status: HomeMaterialAssetStatus = Field(..., description="处理状态。")
+ error_message: str | None = Field(None, description="失败原因。")
+ original_url: str | None = Field(None, description="原始素材URL,仅后台返回。")
+ watermarked_url: str | None = Field(None, description="水印素材URL。")
+ cover_url: str | None = Field(None, description="视频封面URL。")
+ processed_at: NaiveDatetimeOptional = Field(None, description="处理完成时间。")
+
+
+class HomeMaterialPublicCategoryOut(BaseModel):
+ id: str = Field(..., description="行业ID。")
+ name: str = Field(..., description="行业名称。")
+ key: str = Field(..., description="行业key。")
+ icon: str | None = Field(None, description="前端图标名。")
+ description: str | None = Field(None, description="行业描述。")
+ sort_order: int = Field(0, description="排序。")
+ asset_count: int = Field(0, description="可展示素材总数。")
+ image_count: int = Field(0, description="图片素材数量。")
+ video_count: int = Field(0, description="视频素材数量。")
+
+
+class HomeMaterialPublicCategoryListOut(BaseModel):
+ enabled: bool = Field(False, description="首页素材展示开关。false 时 items 为空。")
+ title: str = Field("行业素材案例", description="首页素材模块标题。")
+ subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。")
+ items: list[HomeMaterialPublicCategoryOut] = Field(default_factory=list, description="行业列表。")
+
+
+class HomeMaterialPublicAssetOut(BaseModel):
+ id: str = Field(..., description="素材ID。")
+ title: str | None = Field(None, description="素材标题。")
+ media_type: HomeMaterialMediaType = Field(..., description="素材类型。")
+ url: str = Field(..., description="前台展示URL,永远返回 watermarked_url。")
+ cover_url: str | None = Field(None, description="视频封面URL。")
+ width: int | None = Field(None, description="宽度。")
+ height: int | None = Field(None, description="高度。")
+ duration_seconds: Decimal | float | None = Field(None, description="视频时长。")
+ sort_order: int = Field(0, description="排序。")
+
+
+class HomeMaterialPublicCategoryGroupOut(BaseModel):
+ category: HomeMaterialPublicCategoryOut
+ assets: list[HomeMaterialPublicAssetOut] = Field(default_factory=list)
+
+
+class HomeMaterialPublicGroupedOut(BaseModel):
+ enabled: bool = Field(True, description="首页素材展示开关。")
+ title: str = Field("行业素材案例", description="首页素材模块标题。")
+ subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。")
+ response_mode: HomeMaterialPublicResponseMode = Field(HomeMaterialPublicResponseMode.GROUPED, description="返回模式。")
+ categories: list[HomeMaterialPublicCategoryGroupOut] = Field(default_factory=list, description="行业分组素材。")
+
+
+class HomeMaterialPublicFlatItemOut(HomeMaterialPublicAssetOut):
+ category_id: str = Field(..., description="行业ID。")
+ category_name: str = Field(..., description="行业名称。")
+ category_key: str = Field(..., description="行业key。")
+ category_icon: str | None = Field(None, description="行业图标。")
+
+
+class HomeMaterialPublicFlatOut(BaseModel):
+ enabled: bool = Field(True, description="首页素材展示开关。")
+ title: str = Field("行业素材案例", description="首页素材模块标题。")
+ subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。")
+ response_mode: HomeMaterialPublicResponseMode = Field(HomeMaterialPublicResponseMode.FLAT, description="返回模式。")
+ items: list[HomeMaterialPublicFlatItemOut] = Field(default_factory=list, description="平铺素材列表。")
+ total: int = Field(0, description="总数。")
+
+
+class HomeMaterialUploadResultOut(BaseModel):
+ id: str = Field(..., description="素材ID。")
+ category_id: str = Field(..., description="行业ID。")
+ title: str | None = Field(None, description="素材标题。")
+ media_type: HomeMaterialMediaType = Field(..., description="素材类型。")
+ status: HomeMaterialAssetStatus = Field(..., description="处理状态。")
+ original_url: str | None = Field(None, description="原始素材URL。")
+ watermarked_url: str | None = Field(None, description="水印素材URL。")
+ cover_url: str | None = Field(None, description="视频封面URL。")
+ watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。")
+ message: str = Field("", description="提示消息。")
diff --git a/video-gen-api/app/services/home_material/__init__.py b/video-gen-api/app/services/home_material/__init__.py
new file mode 100644
index 00000000..5fac09ca
--- /dev/null
+++ b/video-gen-api/app/services/home_material/__init__.py
@@ -0,0 +1,3 @@
+from app.services.home_material.service import HomeMaterialService, home_material_service
+
+__all__ = ["HomeMaterialService", "home_material_service"]
diff --git a/video-gen-api/app/services/home_material/query.py b/video-gen-api/app/services/home_material/query.py
new file mode 100644
index 00000000..2233cb07
--- /dev/null
+++ b/video-gen-api/app/services/home_material/query.py
@@ -0,0 +1,412 @@
+from __future__ import annotations
+
+import json
+from collections import defaultdict
+from typing import Any, Iterable
+
+from sqlalchemy import case, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.sql import Select
+
+from app.enums.home_material import (
+ HomeMaterialAssetStatus,
+ HomeMaterialMediaType,
+ HomeMaterialPublicResponseMode,
+)
+from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
+from app.schemas.home_material import (
+ HomeMaterialAssetOut,
+ HomeMaterialCategoryOut,
+ HomeMaterialPublicAssetOut,
+ HomeMaterialPublicCategoryGroupOut,
+ HomeMaterialPublicCategoryOut,
+ HomeMaterialPublicFlatItemOut,
+ HomeMaterialWatermarkOut,
+)
+
+
+def _unique(values: Iterable[str | None]) -> list[str]:
+ return list({v for v in values if v})
+
+
+def _load_json(value: str | None) -> dict[str, Any] | None:
+ if not value:
+ return None
+ try:
+ data = json.loads(value)
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
+
+
+class HomeMaterialQueryService:
+ """首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
+
+ async def batch_categories_map(self, db: AsyncSession, category_ids: Iterable[str | None]) -> dict[str, HomeMaterialCategory]:
+ ids = _unique(category_ids)
+ if not ids:
+ return {}
+ result = await db.execute(
+ select(HomeMaterialCategory).where(HomeMaterialCategory.id.in_(ids), HomeMaterialCategory.deleted_at.is_(None))
+ )
+ return {row.id: row for row in result.scalars().all()}
+
+ async def batch_watermarks_map(self, db: AsyncSession, watermark_ids: Iterable[str | None]) -> dict[str, HomeMaterialWatermark]:
+ ids = _unique(watermark_ids)
+ if not ids:
+ return {}
+ result = await db.execute(
+ select(HomeMaterialWatermark).where(HomeMaterialWatermark.id.in_(ids), HomeMaterialWatermark.deleted_at.is_(None))
+ )
+ return {row.id: row for row in result.scalars().all()}
+
+ async def asset_counts_map(
+ self,
+ db: AsyncSession,
+ category_ids: Iterable[str] | None = None,
+ *,
+ media_type: HomeMaterialMediaType | str | None = None,
+ public_only: bool = False,
+ ) -> dict[str, dict[str, int]]:
+ stmt = select(
+ HomeMaterialAsset.category_id,
+ func.count(HomeMaterialAsset.id).label("asset_count"),
+ func.coalesce(func.sum(case((HomeMaterialAsset.media_type == HomeMaterialMediaType.IMAGE.value, 1), else_=0)), 0).label("image_count"),
+ func.coalesce(func.sum(case((HomeMaterialAsset.media_type == HomeMaterialMediaType.VIDEO.value, 1), else_=0)), 0).label("video_count"),
+ ).where(HomeMaterialAsset.deleted_at.is_(None))
+ ids = _unique(category_ids or [])
+ if ids:
+ stmt = stmt.where(HomeMaterialAsset.category_id.in_(ids))
+ if media_type:
+ stmt = stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
+ if public_only:
+ stmt = stmt.where(
+ HomeMaterialAsset.is_active.is_(True),
+ HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
+ HomeMaterialAsset.watermarked_url.is_not(None),
+ )
+ stmt = stmt.group_by(HomeMaterialAsset.category_id)
+ result = await db.execute(stmt)
+ out: dict[str, dict[str, int]] = {}
+ for row in result.all():
+ out[row.category_id] = {
+ "asset_count": int(row.asset_count or 0),
+ "image_count": int(row.image_count or 0),
+ "video_count": int(row.video_count or 0),
+ }
+ return out
+
+ def category_to_out(self, category: HomeMaterialCategory, counts: dict[str, int] | None = None) -> HomeMaterialCategoryOut:
+ c = counts or {}
+ return HomeMaterialCategoryOut(
+ id=category.id,
+ name=category.name,
+ key=category.key,
+ description=category.description,
+ icon=category.icon,
+ is_active=category.is_active,
+ sort_order=category.sort_order,
+ asset_count=int(c.get("asset_count", 0)),
+ image_count=int(c.get("image_count", 0)),
+ video_count=int(c.get("video_count", 0)),
+ created_at=category.created_at,
+ updated_at=category.updated_at,
+ )
+
+ def watermark_to_out(self, watermark: HomeMaterialWatermark) -> HomeMaterialWatermarkOut:
+ return HomeMaterialWatermarkOut(
+ id=watermark.id,
+ name=watermark.name,
+ file_url=watermark.file_url,
+ file_name=watermark.file_name,
+ file_size_bytes=watermark.file_size_bytes,
+ width=watermark.width,
+ height=watermark.height,
+ is_default=watermark.is_default,
+ is_active=watermark.is_active,
+ created_at=watermark.created_at,
+ updated_at=watermark.updated_at,
+ )
+
+ def asset_to_out(
+ self,
+ asset: HomeMaterialAsset,
+ *,
+ category_map: dict[str, HomeMaterialCategory] | None = None,
+ watermark_map: dict[str, HomeMaterialWatermark] | None = None,
+ include_original: bool = True,
+ ) -> HomeMaterialAssetOut:
+ category = (category_map or {}).get(asset.category_id)
+ watermark = (watermark_map or {}).get(asset.watermark_id or "")
+ return HomeMaterialAssetOut(
+ id=asset.id,
+ category_id=asset.category_id,
+ category_name=category.name if category else None,
+ category_key=category.key if category else None,
+ title=asset.title,
+ media_type=HomeMaterialMediaType(asset.media_type),
+ status=HomeMaterialAssetStatus(asset.status),
+ original_url=asset.original_url if include_original else None,
+ watermarked_url=asset.watermarked_url,
+ cover_url=asset.cover_url,
+ watermark_id=asset.watermark_id,
+ watermark_name=watermark.name if watermark else None,
+ watermark_config=_load_json(asset.watermark_config_json),
+ width=asset.width,
+ height=asset.height,
+ duration_seconds=asset.duration_seconds,
+ file_size_bytes=asset.file_size_bytes,
+ watermarked_file_size_bytes=asset.watermarked_file_size_bytes,
+ is_active=asset.is_active,
+ sort_order=asset.sort_order,
+ error_message=asset.error_message,
+ processed_at=asset.processed_at,
+ created_at=asset.created_at,
+ updated_at=asset.updated_at,
+ )
+
+ async def list_admin_assets(
+ self,
+ db: AsyncSession,
+ *,
+ page: int,
+ page_size: int,
+ category_id: str | None = None,
+ media_type: HomeMaterialMediaType | str | None = None,
+ status: HomeMaterialAssetStatus | str | None = None,
+ is_active: bool | None = None,
+ keyword: str | None = None,
+ include_original: bool = True,
+ ) -> tuple[list[HomeMaterialAssetOut], int]:
+ stmt = select(HomeMaterialAsset).where(HomeMaterialAsset.deleted_at.is_(None))
+ count_stmt = select(func.count(HomeMaterialAsset.id)).where(HomeMaterialAsset.deleted_at.is_(None))
+ conditions = []
+ if category_id:
+ conditions.append(HomeMaterialAsset.category_id == category_id)
+ if media_type:
+ conditions.append(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
+ if status:
+ conditions.append(HomeMaterialAsset.status == HomeMaterialAssetStatus(status).value)
+ if is_active is not None:
+ conditions.append(HomeMaterialAsset.is_active.is_(is_active))
+ if keyword:
+ conditions.append(HomeMaterialAsset.title.ilike(f"%{keyword}%"))
+ for condition in conditions:
+ stmt = stmt.where(condition)
+ count_stmt = count_stmt.where(condition)
+ total = int((await db.execute(count_stmt)).scalar_one() or 0)
+ result = await db.execute(
+ stmt.order_by(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ assets = result.scalars().all()
+ category_map = await self.batch_categories_map(db, [a.category_id for a in assets])
+ watermark_map = await self.batch_watermarks_map(db, [a.watermark_id for a in assets])
+ return [self.asset_to_out(a, category_map=category_map, watermark_map=watermark_map, include_original=include_original) for a in assets], total
+
+ async def resolve_category_ids(
+ self,
+ db: AsyncSession,
+ *,
+ category_id: str | None = None,
+ category_key: str | None = None,
+ category_ids: str | None = None,
+ category_keys: str | None = None,
+ active_only: bool = True,
+ ) -> list[str] | None:
+ """解析前台行业筛选参数,优先级:category_id > category_key > category_ids > category_keys。None 表示不限制。"""
+ if category_id:
+ return [category_id]
+ stmt = select(HomeMaterialCategory.id).where(HomeMaterialCategory.deleted_at.is_(None))
+ if active_only:
+ stmt = stmt.where(HomeMaterialCategory.is_active.is_(True))
+ if category_key:
+ result = await db.execute(stmt.where(HomeMaterialCategory.key == category_key).limit(1))
+ cid = result.scalar_one_or_none()
+ return [cid] if cid else []
+ if category_ids:
+ return [v.strip() for v in category_ids.split(",") if v.strip()]
+ if category_keys:
+ keys = [v.strip() for v in category_keys.split(",") if v.strip()]
+ if not keys:
+ return []
+ result = await db.execute(stmt.where(HomeMaterialCategory.key.in_(keys)))
+ return list(result.scalars().all())
+ return None
+
+ async def list_public_categories(
+ self,
+ db: AsyncSession,
+ *,
+ with_asset_count: bool = True,
+ media_type: HomeMaterialMediaType | str | None = None,
+ only_has_assets: bool = True,
+ ) -> list[HomeMaterialPublicCategoryOut]:
+ result = await db.execute(
+ select(HomeMaterialCategory)
+ .where(HomeMaterialCategory.deleted_at.is_(None), HomeMaterialCategory.is_active.is_(True))
+ .order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc())
+ )
+ categories = result.scalars().all()
+ counts = await self.asset_counts_map(db, [c.id for c in categories], media_type=media_type, public_only=True) if with_asset_count or only_has_assets else {}
+ items: list[HomeMaterialPublicCategoryOut] = []
+ for category in categories:
+ c = counts.get(category.id, {})
+ if only_has_assets and int(c.get("asset_count", 0)) <= 0:
+ continue
+ items.append(
+ HomeMaterialPublicCategoryOut(
+ id=category.id,
+ name=category.name,
+ key=category.key,
+ icon=category.icon,
+ description=category.description,
+ sort_order=category.sort_order,
+ asset_count=int(c.get("asset_count", 0)),
+ image_count=int(c.get("image_count", 0)),
+ video_count=int(c.get("video_count", 0)),
+ )
+ )
+ return items
+
+ async def list_public_grouped(
+ self,
+ db: AsyncSession,
+ *,
+ category_ids: list[str] | None,
+ media_type: HomeMaterialMediaType | str | None,
+ limit_per_category: int,
+ include_empty_categories: bool,
+ ) -> list[HomeMaterialPublicCategoryGroupOut]:
+ cat_stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.deleted_at.is_(None), HomeMaterialCategory.is_active.is_(True))
+ if category_ids is not None:
+ if not category_ids:
+ return []
+ cat_stmt = cat_stmt.where(HomeMaterialCategory.id.in_(category_ids))
+ cat_result = await db.execute(cat_stmt.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc()))
+ categories = cat_result.scalars().all()
+ ids = [c.id for c in categories]
+ if not ids:
+ return []
+
+ asset_base = select(
+ HomeMaterialAsset.id.label("id"),
+ func.row_number()
+ .over(
+ partition_by=HomeMaterialAsset.category_id,
+ order_by=(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc()),
+ )
+ .label("rn"),
+ ).where(
+ HomeMaterialAsset.deleted_at.is_(None),
+ HomeMaterialAsset.is_active.is_(True),
+ HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
+ HomeMaterialAsset.watermarked_url.is_not(None),
+ HomeMaterialAsset.category_id.in_(ids),
+ )
+ if media_type:
+ asset_base = asset_base.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
+ ranked = asset_base.subquery()
+ asset_result = await db.execute(
+ select(HomeMaterialAsset)
+ .where(HomeMaterialAsset.id.in_(select(ranked.c.id).where(ranked.c.rn <= limit_per_category)))
+ .order_by(HomeMaterialAsset.category_id.asc(), HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
+ )
+ grouped: dict[str, list[HomeMaterialAsset]] = defaultdict(list)
+ for asset in asset_result.scalars().all():
+ grouped[asset.category_id].append(asset)
+
+ out: list[HomeMaterialPublicCategoryGroupOut] = []
+ for category in categories:
+ assets = grouped.get(category.id, [])
+ if not include_empty_categories and not assets:
+ continue
+ out.append(
+ HomeMaterialPublicCategoryGroupOut(
+ id=category.id,
+ name=category.name,
+ key=category.key,
+ icon=category.icon,
+ description=category.description,
+ sort_order=category.sort_order,
+ assets=[
+ HomeMaterialPublicAssetOut(
+ id=a.id,
+ title=a.title,
+ media_type=HomeMaterialMediaType(a.media_type),
+ url=a.watermarked_url or "",
+ cover_url=a.cover_url,
+ width=a.width,
+ height=a.height,
+ duration_seconds=a.duration_seconds,
+ sort_order=a.sort_order,
+ )
+ for a in assets
+ ],
+ )
+ )
+ return out
+
+ async def list_public_flat(
+ self,
+ db: AsyncSession,
+ *,
+ category_ids: list[str] | None,
+ media_type: HomeMaterialMediaType | str | None,
+ page: int,
+ page_size: int,
+ ) -> tuple[list[HomeMaterialPublicFlatItemOut], int]:
+ stmt = select(HomeMaterialAsset).where(
+ HomeMaterialAsset.deleted_at.is_(None),
+ HomeMaterialAsset.is_active.is_(True),
+ HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
+ HomeMaterialAsset.watermarked_url.is_not(None),
+ )
+ count_stmt = select(func.count(HomeMaterialAsset.id)).where(
+ HomeMaterialAsset.deleted_at.is_(None),
+ HomeMaterialAsset.is_active.is_(True),
+ HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
+ HomeMaterialAsset.watermarked_url.is_not(None),
+ )
+ if category_ids is not None:
+ if not category_ids:
+ return [], 0
+ stmt = stmt.where(HomeMaterialAsset.category_id.in_(category_ids))
+ count_stmt = count_stmt.where(HomeMaterialAsset.category_id.in_(category_ids))
+ if media_type:
+ stmt = stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
+ count_stmt = count_stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
+ total = int((await db.execute(count_stmt)).scalar_one() or 0)
+ result = await db.execute(
+ stmt.order_by(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ assets = result.scalars().all()
+ category_map = await self.batch_categories_map(db, [a.category_id for a in assets])
+ items: list[HomeMaterialPublicFlatItemOut] = []
+ for asset in assets:
+ category = category_map.get(asset.category_id)
+ if not category:
+ continue
+ items.append(
+ HomeMaterialPublicFlatItemOut(
+ id=asset.id,
+ category_id=category.id,
+ category_name=category.name,
+ category_key=category.key,
+ title=asset.title,
+ media_type=HomeMaterialMediaType(asset.media_type),
+ url=asset.watermarked_url or "",
+ cover_url=asset.cover_url,
+ width=asset.width,
+ height=asset.height,
+ duration_seconds=asset.duration_seconds,
+ sort_order=asset.sort_order,
+ )
+ )
+ return items, total
+
+
+query_service = HomeMaterialQueryService()
diff --git a/video-gen-api/app/services/home_material/service.py b/video-gen-api/app/services/home_material/service.py
new file mode 100644
index 00000000..b51e45e7
--- /dev/null
+++ b/video-gen-api/app/services/home_material/service.py
@@ -0,0 +1,693 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from fastapi import HTTPException, UploadFile, status
+from sqlalchemy import func, or_, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.enums.home_material import (
+ HOME_MATERIAL_DEFAULT_CONFIG,
+ HomeMaterialAssetStatus,
+ HomeMaterialConfigKeyEnum,
+ HomeMaterialMediaType,
+ HomeMaterialPublicResponseMode,
+ HomeMaterialWatermarkPosition,
+ HomeMaterialWatermarkSizeMode,
+ HomeMaterialWatermarkType,
+)
+from app.models.base import async_session
+from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
+from app.models.system_config import SystemConfig
+from app.schemas.home_material import (
+ HomeMaterialAssetListOut,
+ HomeMaterialAssetOut,
+ HomeMaterialAssetStatusOut,
+ HomeMaterialAssetUpdate,
+ HomeMaterialCategoryCreate,
+ HomeMaterialCategoryListOut,
+ HomeMaterialCategoryOut,
+ HomeMaterialCategoryUpdate,
+ HomeMaterialConfigOut,
+ HomeMaterialConfigUpdate,
+ HomeMaterialPublicCategoryListOut,
+ HomeMaterialPublicFlatOut,
+ HomeMaterialPublicGroupedOut,
+ HomeMaterialRegenerateWatermarkRequest,
+ HomeMaterialTextWatermarkPreviewRequest,
+ HomeMaterialTextWatermarkPreviewResponse,
+ HomeMaterialUploadResultOut,
+ HomeMaterialWatermarkConfig,
+ HomeMaterialWatermarkListOut,
+ HomeMaterialWatermarkOut,
+ HomeMaterialWatermarkUpdate,
+)
+from app.services.home_material.query import query_service
+from app.services.home_material.storage import storage_service
+from app.services.home_material.watermark_processor import watermark_processor
+from app.utils.id_gen import generate_id
+
+
+def _json_dumps(data: Any) -> str:
+ return json.dumps(data, ensure_ascii=False, default=str)
+
+
+def _json_loads(value: str | None) -> dict[str, Any] | None:
+ if not value:
+ return None
+ try:
+ data = json.loads(value)
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
+
+
+def _clean_title(value: str | None) -> str | None:
+ value = (value or "").strip()
+ return value or None
+
+
+def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
+ """兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
+ data = dict(value or {})
+ watermark_type = data.get("watermark_type") or HomeMaterialWatermarkType.IMAGE.value
+ data["watermark_type"] = watermark_type
+ if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
+ data["watermark_id"] = None
+ return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
+ if fallback_watermark_id and not data.get("watermark_id"):
+ data["watermark_id"] = fallback_watermark_id
+ return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
+
+
+def _config_to_out(data: dict[str, Any] | None) -> HomeMaterialConfigOut:
+ raw = {**HOME_MATERIAL_DEFAULT_CONFIG, **(data or {})}
+ return HomeMaterialConfigOut(
+ enabled=bool(raw.get("enabled", False)),
+ title=str(raw.get("title") or HOME_MATERIAL_DEFAULT_CONFIG["title"]),
+ subtitle=str(raw.get("subtitle") or HOME_MATERIAL_DEFAULT_CONFIG["subtitle"]),
+ show_original_in_admin=bool(raw.get("show_original_in_admin", True)),
+ )
+
+
+def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
+ if not asset:
+ return None
+ return {
+ "id": asset.id,
+ "category_id": asset.category_id,
+ "title": asset.title,
+ "media_type": asset.media_type,
+ "status": asset.status,
+ "original_url": asset.original_url,
+ "watermarked_url": asset.watermarked_url,
+ "cover_url": asset.cover_url,
+ "watermark_id": asset.watermark_id,
+ "watermark_config": _json_loads(asset.watermark_config_json),
+ "is_active": asset.is_active,
+ "sort_order": asset.sort_order,
+ "deleted_at": asset.deleted_at,
+ }
+
+
+def _category_snapshot(category: HomeMaterialCategory | None) -> dict[str, Any] | None:
+ if not category:
+ return None
+ return {
+ "id": category.id,
+ "name": category.name,
+ "key": category.key,
+ "description": category.description,
+ "icon": category.icon,
+ "is_active": category.is_active,
+ "sort_order": category.sort_order,
+ "deleted_at": category.deleted_at,
+ }
+
+
+def _watermark_snapshot(watermark: HomeMaterialWatermark | None) -> dict[str, Any] | None:
+ if not watermark:
+ return None
+ return {
+ "id": watermark.id,
+ "name": watermark.name,
+ "file_url": watermark.file_url,
+ "is_default": watermark.is_default,
+ "is_active": watermark.is_active,
+ "deleted_at": watermark.deleted_at,
+ }
+
+
+async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
+ """
+ 写入后立即返回 ORM 对象前必须显式刷新。
+
+ 本模块模型继承 TimestampMixin,updated_at 使用 onupdate=func.now()。
+ AsyncSession 下 flush 后直接访问 updated_at/created_at 可能触发隐式 IO,
+ 导致 MissingGreenlet。统一 flush + refresh,避免响应组装阶段懒加载。
+ """
+ await db.flush()
+ await db.refresh(obj)
+
+
+class HomeMaterialService:
+ """首页素材装修主业务服务。API 层只调用本服务,内部再委托 query/storage/processor。"""
+
+ async def get_config(self, db: AsyncSession) -> HomeMaterialConfigOut:
+ result = await db.execute(
+ select(SystemConfig.value)
+ .where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
+ .limit(1)
+ )
+ value = result.scalar_one_or_none()
+ if not value:
+ return _config_to_out(None)
+ return _config_to_out(_json_loads(value))
+
+ async def save_config(self, db: AsyncSession, req: HomeMaterialConfigUpdate) -> tuple[HomeMaterialConfigOut, dict[str, Any], dict[str, Any]]:
+ before = await self.get_config(db)
+ after = HomeMaterialConfigOut(**req.model_dump())
+ result = await db.execute(
+ select(SystemConfig)
+ .where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
+ .limit(1)
+ )
+ config = result.scalar_one_or_none()
+ payload = after.model_dump()
+ if config:
+ config.value = _json_dumps(payload)
+ config.description = "首页素材行业装修展示配置"
+ db.add(config)
+ else:
+ db.add(
+ SystemConfig(
+ id=generate_id(),
+ key=HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value,
+ value=_json_dumps(payload),
+ description="首页素材行业装修展示配置",
+ )
+ )
+ return after, before.model_dump(), after.model_dump()
+
+ async def list_categories(
+ self,
+ db: AsyncSession,
+ *,
+ page: int,
+ page_size: int,
+ keyword: str | None = None,
+ is_active: bool | None = None,
+ ) -> HomeMaterialCategoryListOut:
+ stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.deleted_at.is_(None))
+ count_stmt = select(func.count(HomeMaterialCategory.id)).where(HomeMaterialCategory.deleted_at.is_(None))
+ conditions = []
+ if keyword:
+ like = f"%{keyword}%"
+ conditions.append(or_(HomeMaterialCategory.name.ilike(like), HomeMaterialCategory.key.ilike(like)))
+ if is_active is not None:
+ conditions.append(HomeMaterialCategory.is_active.is_(is_active))
+ for cond in conditions:
+ stmt = stmt.where(cond)
+ count_stmt = count_stmt.where(cond)
+ total = int((await db.execute(count_stmt)).scalar_one() or 0)
+ result = await db.execute(
+ stmt.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ categories = result.scalars().all()
+ counts = await query_service.asset_counts_map(db, [c.id for c in categories])
+ return HomeMaterialCategoryListOut(
+ items=[query_service.category_to_out(c, counts.get(c.id)) for c in categories],
+ total=total,
+ )
+
+ async def create_category(self, db: AsyncSession, req: HomeMaterialCategoryCreate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any]]:
+ await self._ensure_category_key_available(db, req.key)
+ category = HomeMaterialCategory(
+ id=generate_id(),
+ name=req.name,
+ key=req.key,
+ description=req.description,
+ icon=req.icon,
+ is_active=req.is_active,
+ sort_order=req.sort_order,
+ created_by=admin_id,
+ updated_by=admin_id,
+ )
+ db.add(category)
+ await _flush_refresh(db, category)
+ return query_service.category_to_out(category), _category_snapshot(category) or {}
+
+ async def update_category(self, db: AsyncSession, category_id: str, req: HomeMaterialCategoryUpdate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any], dict[str, Any]]:
+ category = await self._get_category(db, category_id)
+ before = _category_snapshot(category) or {}
+ if req.key != category.key:
+ await self._ensure_category_key_available(db, req.key, exclude_id=category_id)
+ category.name = req.name
+ category.key = req.key
+ category.description = req.description
+ category.icon = req.icon
+ category.is_active = req.is_active
+ category.sort_order = req.sort_order
+ category.updated_by = admin_id
+ db.add(category)
+ await _flush_refresh(db, category)
+ after = _category_snapshot(category) or {}
+ return query_service.category_to_out(category), before, after
+
+ async def delete_category(self, db: AsyncSession, category_id: str, admin_id: str | None) -> tuple[HomeMaterialCategory, dict[str, Any]]:
+ category = await self._get_category(db, category_id)
+ count = int((await db.execute(select(func.count(HomeMaterialAsset.id)).where(HomeMaterialAsset.category_id == category_id, HomeMaterialAsset.deleted_at.is_(None)))).scalar_one() or 0)
+ if count > 0:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该行业下仍有素材,请先删除素材或禁用行业")
+ before = _category_snapshot(category) or {}
+ category.deleted_at = datetime.now(timezone.utc)
+ category.updated_by = admin_id
+ db.add(category)
+ return category, before
+
+ async def _ensure_category_key_available(self, db: AsyncSession, key: str, exclude_id: str | None = None) -> None:
+ stmt = select(HomeMaterialCategory.id).where(HomeMaterialCategory.key == key, HomeMaterialCategory.deleted_at.is_(None))
+ if exclude_id:
+ stmt = stmt.where(HomeMaterialCategory.id != exclude_id)
+ exists = (await db.execute(stmt.limit(1))).scalar_one_or_none()
+ if exists:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="行业 key 已存在")
+
+ async def _get_category(self, db: AsyncSession, category_id: str, *, active_only: bool = False) -> HomeMaterialCategory:
+ stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.id == category_id, HomeMaterialCategory.deleted_at.is_(None))
+ if active_only:
+ stmt = stmt.where(HomeMaterialCategory.is_active.is_(True))
+ result = await db.execute(stmt.limit(1))
+ category = result.scalar_one_or_none()
+ if not category:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="行业不存在或已删除")
+ return category
+
+ async def upload_watermark(self, db: AsyncSession, *, file: UploadFile, name: str | None, is_default: bool, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any]]:
+ stored = await storage_service.save_watermark_file(file)
+ info = await watermark_processor.probe(stored.storage_path)
+ if is_default:
+ await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None)).values(is_default=False))
+ watermark = HomeMaterialWatermark(
+ id=generate_id(),
+ name=name or stored.file_name,
+ file_url=stored.file_url,
+ storage_path=stored.storage_path,
+ file_name=stored.file_name,
+ file_size_bytes=stored.file_size_bytes,
+ width=info.width,
+ height=info.height,
+ is_default=is_default,
+ is_active=True,
+ created_by=admin_id,
+ updated_by=admin_id,
+ )
+ db.add(watermark)
+ await _flush_refresh(db, watermark)
+ return query_service.watermark_to_out(watermark), _watermark_snapshot(watermark) or {}
+
+ async def list_watermarks(self, db: AsyncSession, *, page: int, page_size: int, is_active: bool | None = None) -> HomeMaterialWatermarkListOut:
+ stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None))
+ count_stmt = select(func.count(HomeMaterialWatermark.id)).where(HomeMaterialWatermark.deleted_at.is_(None))
+ if is_active is not None:
+ stmt = stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
+ count_stmt = count_stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
+ total = int((await db.execute(count_stmt)).scalar_one() or 0)
+ result = await db.execute(
+ stmt.order_by(HomeMaterialWatermark.is_default.desc(), HomeMaterialWatermark.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ return HomeMaterialWatermarkListOut(items=[query_service.watermark_to_out(w) for w in result.scalars().all()], total=total)
+
+ async def update_watermark(self, db: AsyncSession, watermark_id: str, req: HomeMaterialWatermarkUpdate, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any], dict[str, Any]]:
+ watermark = await self._get_watermark(db, watermark_id)
+ before = _watermark_snapshot(watermark) or {}
+ if req.is_default:
+ await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.id != watermark_id).values(is_default=False))
+ watermark.name = req.name
+ watermark.is_default = req.is_default
+ watermark.is_active = req.is_active
+ watermark.updated_by = admin_id
+ db.add(watermark)
+ await _flush_refresh(db, watermark)
+ return query_service.watermark_to_out(watermark), before, _watermark_snapshot(watermark) or {}
+
+ async def delete_watermark(self, db: AsyncSession, watermark_id: str, admin_id: str | None) -> tuple[HomeMaterialWatermark, dict[str, Any]]:
+ watermark = await self._get_watermark(db, watermark_id)
+ before = _watermark_snapshot(watermark) or {}
+ watermark.deleted_at = datetime.now(timezone.utc)
+ watermark.is_active = False
+ watermark.is_default = False
+ watermark.updated_by = admin_id
+ db.add(watermark)
+ return watermark, before
+
+ async def _get_watermark(self, db: AsyncSession, watermark_id: str, *, active_only: bool = False) -> HomeMaterialWatermark:
+ stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.id == watermark_id, HomeMaterialWatermark.deleted_at.is_(None))
+ if active_only:
+ stmt = stmt.where(HomeMaterialWatermark.is_active.is_(True))
+ result = await db.execute(stmt.limit(1))
+ watermark = result.scalar_one_or_none()
+ if not watermark:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="水印不存在或已删除")
+ return watermark
+
+ async def upload_asset(
+ self,
+ db: AsyncSession,
+ *,
+ category_id: str,
+ file: UploadFile,
+ media_type: HomeMaterialMediaType,
+ title: str | None,
+ watermark_id: str | None,
+ watermark_file: UploadFile | None,
+ watermark_config: HomeMaterialWatermarkConfig,
+ is_active: bool,
+ sort_order: int,
+ admin_id: str | None,
+ ) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
+ await self._get_category(db, category_id, active_only=False)
+ clean_title = _clean_title(title)
+ watermark_type = watermark_config.watermark_type
+ final_watermark_id: str | None = None
+ if watermark_type == HomeMaterialWatermarkType.IMAGE:
+ final_watermark_id = watermark_id
+ if watermark_file is not None:
+ watermark_out, _ = await self.upload_watermark(db, file=watermark_file, name=f"临时水印-{clean_title or '首页素材'}", is_default=False, admin_id=admin_id)
+ final_watermark_id = watermark_out.id
+ if not final_watermark_id:
+ default_wm = await db.execute(
+ select(HomeMaterialWatermark)
+ .where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.is_active.is_(True), HomeMaterialWatermark.is_default.is_(True))
+ .limit(1)
+ )
+ wm = default_wm.scalar_one_or_none()
+ final_watermark_id = wm.id if wm else None
+ if not final_watermark_id:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先选择或上传水印图片")
+ await self._get_watermark(db, final_watermark_id, active_only=True)
+ elif watermark_file is not None:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印模式不允许上传图片水印文件")
+
+ stored = await storage_service.save_asset_file(file, media_type)
+ probe = await watermark_processor.probe(stored.storage_path)
+ if media_type == HomeMaterialMediaType.VIDEO:
+ max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
+ if probe.duration_seconds is not None and probe.duration_seconds > max_duration:
+ storage_service.safe_remove(stored.storage_path)
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
+
+ cfg = watermark_config.model_copy(update={"watermark_id": final_watermark_id, "opacity": round(watermark_config.opacity_level / 10, 2)})
+ asset = HomeMaterialAsset(
+ id=generate_id(),
+ category_id=category_id,
+ title=clean_title,
+ media_type=media_type.value,
+ original_url=stored.file_url,
+ original_storage_path=stored.storage_path,
+ watermark_id=final_watermark_id,
+ watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
+ status=HomeMaterialAssetStatus.PROCESSING.value,
+ width=probe.width,
+ height=probe.height,
+ duration_seconds=probe.duration_seconds,
+ file_size_bytes=stored.file_size_bytes,
+ is_active=is_active,
+ sort_order=sort_order,
+ created_by=admin_id,
+ updated_by=admin_id,
+ )
+ db.add(asset)
+ await _flush_refresh(db, asset)
+ return self._upload_result(asset, message="素材已上传,水印处理中"), _asset_snapshot(asset) or {}
+
+ async def list_assets(
+ self,
+ db: AsyncSession,
+ *,
+ page: int,
+ page_size: int,
+ category_id: str | None,
+ media_type: HomeMaterialMediaType | str | None,
+ status: HomeMaterialAssetStatus | str | None,
+ is_active: bool | None,
+ keyword: str | None,
+ ) -> HomeMaterialAssetListOut:
+ config = await self.get_config(db)
+ items, total = await query_service.list_admin_assets(
+ db,
+ page=page,
+ page_size=page_size,
+ category_id=category_id,
+ media_type=media_type,
+ status=status,
+ is_active=is_active,
+ keyword=keyword,
+ include_original=config.show_original_in_admin,
+ )
+ return HomeMaterialAssetListOut(items=items, total=total)
+
+ async def get_asset_detail(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetOut:
+ asset = await self._get_asset(db, asset_id)
+ category_map = await query_service.batch_categories_map(db, [asset.category_id])
+ watermark_map = await query_service.batch_watermarks_map(db, [asset.watermark_id])
+ config = await self.get_config(db)
+ return query_service.asset_to_out(asset, category_map=category_map, watermark_map=watermark_map, include_original=config.show_original_in_admin)
+
+ async def update_asset(self, db: AsyncSession, asset_id: str, req: HomeMaterialAssetUpdate, admin_id: str | None) -> tuple[HomeMaterialAssetOut, dict[str, Any], dict[str, Any]]:
+ asset = await self._get_asset(db, asset_id)
+ await self._get_category(db, req.category_id)
+ before = _asset_snapshot(asset) or {}
+ asset.category_id = req.category_id
+ asset.title = req.title
+ asset.is_active = req.is_active
+ asset.sort_order = req.sort_order
+ asset.updated_by = admin_id
+ db.add(asset)
+ await _flush_refresh(db, asset)
+ after = _asset_snapshot(asset) or {}
+ return await self.get_asset_detail(db, asset_id), before, after
+
+ async def prepare_regenerate(
+ self,
+ db: AsyncSession,
+ asset_id: str,
+ req: HomeMaterialRegenerateWatermarkRequest,
+ admin_id: str | None,
+ ) -> tuple[HomeMaterialUploadResultOut, dict[str, Any], dict[str, Any]]:
+ asset = await self._get_asset(db, asset_id)
+ before = _asset_snapshot(asset) or {}
+ req_data = req.model_dump(exclude={"wait", "wait_timeout_seconds"})
+ if req.watermark_type == HomeMaterialWatermarkType.IMAGE:
+ final_watermark_id = req.watermark_id or asset.watermark_id
+ await self._get_watermark(db, final_watermark_id or "", active_only=True)
+ cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": final_watermark_id})
+ else:
+ cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": None})
+ asset.watermark_id = cfg.watermark_id
+ asset.watermark_config_json = _json_dumps(cfg.model_dump(mode="json"))
+ asset.status = HomeMaterialAssetStatus.PROCESSING.value
+ asset.error_message = None
+ asset.updated_by = admin_id
+ db.add(asset)
+ await _flush_refresh(db, asset)
+ return self._upload_result(asset, message="水印重新生成中"), before, _asset_snapshot(asset) or {}
+
+ async def delete_asset(self, db: AsyncSession, asset_id: str, admin_id: str | None) -> tuple[HomeMaterialAsset, dict[str, Any]]:
+ asset = await self._get_asset(db, asset_id)
+ before = _asset_snapshot(asset) or {}
+ asset.deleted_at = datetime.now(timezone.utc)
+ asset.updated_by = admin_id
+ db.add(asset)
+ return asset, before
+
+ async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
+ asset = await self._get_asset(db, asset_id)
+ return HomeMaterialAssetStatusOut(
+ id=asset.id,
+ status=HomeMaterialAssetStatus(asset.status),
+ error_message=asset.error_message,
+ original_url=asset.original_url,
+ watermarked_url=asset.watermarked_url,
+ cover_url=asset.cover_url,
+ processed_at=asset.processed_at,
+ )
+
+ async def _get_asset(self, db: AsyncSession, asset_id: str) -> HomeMaterialAsset:
+ result = await db.execute(
+ select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id, HomeMaterialAsset.deleted_at.is_(None)).limit(1)
+ )
+ asset = result.scalar_one_or_none()
+ if not asset:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在或已删除")
+ return asset
+
+ def _upload_result(self, asset: HomeMaterialAsset, *, message: str) -> HomeMaterialUploadResultOut:
+ return HomeMaterialUploadResultOut(
+ id=asset.id,
+ category_id=asset.category_id,
+ title=asset.title,
+ media_type=HomeMaterialMediaType(asset.media_type),
+ status=HomeMaterialAssetStatus(asset.status),
+ original_url=asset.original_url,
+ watermarked_url=asset.watermarked_url,
+ cover_url=asset.cover_url,
+ watermark_config=_json_loads(asset.watermark_config_json),
+ message=message,
+ )
+
+ async def wait_for_asset_result(self, asset_id: str, timeout_seconds: int) -> HomeMaterialUploadResultOut:
+ deadline = asyncio.get_running_loop().time() + max(1, min(timeout_seconds, 60))
+ while True:
+ async with async_session() as db:
+ result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
+ asset = result.scalar_one_or_none()
+ if not asset:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在")
+ if asset.status in (HomeMaterialAssetStatus.SUCCESS.value, HomeMaterialAssetStatus.FAILED.value):
+ return self._upload_result(asset, message="水印处理完成" if asset.status == HomeMaterialAssetStatus.SUCCESS.value else "水印处理失败")
+ if asyncio.get_running_loop().time() >= deadline:
+ async with async_session() as db:
+ result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
+ asset = result.scalar_one()
+ return self._upload_result(asset, message="水印仍在处理中,请继续轮询状态")
+ await asyncio.sleep(0.5)
+
+ def start_watermark_task(self, asset_id: str) -> None:
+ asyncio.create_task(self._run_watermark_task(asset_id))
+
+ async def _run_watermark_task(self, asset_id: str) -> None:
+ try:
+ async with async_session() as db:
+ asset = await self._get_asset(db, asset_id)
+ cfg = _normalize_watermark_config_dict(_json_loads(asset.watermark_config_json), asset.watermark_id)
+ watermark_path: str | None = None
+ if cfg.get("watermark_type") == HomeMaterialWatermarkType.IMAGE.value:
+ watermark = await self._get_watermark(db, cfg.get("watermark_id") or asset.watermark_id or "", active_only=True)
+ watermark_path = watermark.storage_path
+ output_path, output_url = storage_service.build_watermarked_target(asset.id, asset.media_type)
+ cover_path = cover_url = None
+ if asset.media_type == HomeMaterialMediaType.VIDEO.value:
+ cover_path, cover_url = storage_service.build_cover_target(asset.id)
+ result = await watermark_processor.apply_watermark(
+ media_type=HomeMaterialMediaType(asset.media_type),
+ source_path=asset.original_storage_path,
+ watermark_path=watermark_path,
+ output_path=output_path,
+ config=cfg,
+ cover_path=cover_path,
+ )
+ asset.watermarked_storage_path = result.output_path
+ asset.watermarked_url = output_url
+ asset.cover_storage_path = result.cover_path
+ asset.cover_url = cover_url if result.cover_path else None
+ asset.width = result.width
+ asset.height = result.height
+ asset.duration_seconds = result.duration_seconds
+ asset.watermarked_file_size_bytes = result.file_size_bytes
+ asset.status = HomeMaterialAssetStatus.SUCCESS.value
+ asset.error_message = None
+ asset.processed_at = datetime.now(timezone.utc)
+ db.add(asset)
+ await db.commit()
+ except Exception as exc:
+ async with async_session() as db:
+ result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
+ asset = result.scalar_one_or_none()
+ if asset:
+ asset.status = HomeMaterialAssetStatus.FAILED.value
+ asset.error_message = str(exc)[:4000]
+ db.add(asset)
+ await db.commit()
+
+ async def preview_text_watermark_layer(self, req: HomeMaterialTextWatermarkPreviewRequest) -> HomeMaterialTextWatermarkPreviewResponse:
+ data_url = await asyncio.to_thread(
+ watermark_processor.generate_repeated_text_layer_data_url,
+ width=req.width,
+ height=req.height,
+ text_config=req.text_watermark,
+ )
+ return HomeMaterialTextWatermarkPreviewResponse(
+ width=req.width,
+ height=req.height,
+ preview_layer_data_url=data_url,
+ )
+
+ async def mark_stale_processing_failed(self, db: AsyncSession) -> int:
+ minutes = int(getattr(settings, "HOME_MATERIAL_PROCESSING_STALE_MINUTES", 30))
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
+ result = await db.execute(
+ update(HomeMaterialAsset)
+ .where(
+ HomeMaterialAsset.deleted_at.is_(None),
+ HomeMaterialAsset.status == HomeMaterialAssetStatus.PROCESSING.value,
+ HomeMaterialAsset.updated_at < cutoff,
+ )
+ .values(status=HomeMaterialAssetStatus.FAILED.value, error_message="处理任务超时或服务重启,请重新生成水印")
+ )
+ return int(result.rowcount or 0)
+
+ async def get_public_categories(
+ self,
+ db: AsyncSession,
+ *,
+ with_asset_count: bool,
+ media_type: HomeMaterialMediaType | str | None,
+ only_has_assets: bool,
+ ) -> HomeMaterialPublicCategoryListOut:
+ config = await self.get_config(db)
+ if not config.enabled:
+ return HomeMaterialPublicCategoryListOut(enabled=False, title=config.title, subtitle=config.subtitle, items=[])
+ items = await query_service.list_public_categories(db, with_asset_count=with_asset_count, media_type=media_type, only_has_assets=only_has_assets)
+ return HomeMaterialPublicCategoryListOut(enabled=True, title=config.title, subtitle=config.subtitle, items=items)
+
+ async def get_public_home_materials(
+ self,
+ db: AsyncSession,
+ *,
+ category_id: str | None,
+ category_key: str | None,
+ category_ids: str | None,
+ category_keys: str | None,
+ media_type: HomeMaterialMediaType | str | None,
+ limit_per_category: int,
+ include_empty_categories: bool,
+ response_mode: HomeMaterialPublicResponseMode,
+ page: int,
+ page_size: int,
+ ) -> HomeMaterialPublicGroupedOut | HomeMaterialPublicFlatOut:
+ config = await self.get_config(db)
+ if not config.enabled:
+ if response_mode == HomeMaterialPublicResponseMode.FLAT:
+ return HomeMaterialPublicFlatOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=[], total=0)
+ return HomeMaterialPublicGroupedOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=[])
+ resolved_ids = await query_service.resolve_category_ids(
+ db,
+ category_id=category_id,
+ category_key=category_key,
+ category_ids=category_ids,
+ category_keys=category_keys,
+ active_only=True,
+ )
+ if response_mode == HomeMaterialPublicResponseMode.FLAT:
+ items, total = await query_service.list_public_flat(db, category_ids=resolved_ids, media_type=media_type, page=page, page_size=page_size)
+ return HomeMaterialPublicFlatOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=items, total=total)
+ categories = await query_service.list_public_grouped(
+ db,
+ category_ids=resolved_ids,
+ media_type=media_type,
+ limit_per_category=limit_per_category,
+ include_empty_categories=include_empty_categories,
+ )
+ return HomeMaterialPublicGroupedOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=categories)
+
+
+home_material_service = HomeMaterialService()
diff --git a/video-gen-api/app/services/home_material/storage.py b/video-gen-api/app/services/home_material/storage.py
new file mode 100644
index 00000000..42f88201
--- /dev/null
+++ b/video-gen-api/app/services/home_material/storage.py
@@ -0,0 +1,170 @@
+from __future__ import annotations
+
+import os
+import shutil
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Iterable
+
+from fastapi import HTTPException, UploadFile, status
+
+from app.config import settings
+from app.enums.home_material import (
+ HOME_MATERIAL_IMAGE_EXTENSIONS,
+ HOME_MATERIAL_VIDEO_EXTENSIONS,
+ HOME_MATERIAL_WATERMARK_EXTENSIONS,
+ HomeMaterialMediaType,
+)
+from app.utils.id_gen import generate_id
+
+
+@dataclass(frozen=True)
+class StoredFile:
+ storage_path: str
+ file_url: str
+ file_name: str
+ file_size_bytes: int
+ suffix: str
+
+
+class HomeMaterialStorageService:
+ """首页素材文件存储服务。只处理文件、目录、URL,不访问数据库。"""
+
+ def __init__(self) -> None:
+ self.upload_root = Path(settings.UPLOAD_LOCAL_PATH).resolve()
+ self.home_root = self.upload_root / "home_materials"
+
+ def _date_dir(self) -> str:
+ return datetime.now().strftime("%Y/%m/%d")
+
+ def _safe_suffix(self, filename: str | None) -> str:
+ return Path(filename or "").suffix.lower()
+
+ def _assert_extension(self, suffix: str, allowed: Iterable[str], label: str) -> None:
+ if suffix not in set(allowed):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"{label}文件类型不支持:{suffix or '无扩展名'}",
+ )
+
+ def _limit_mb(self, media_type: HomeMaterialMediaType | str) -> int:
+ if media_type == HomeMaterialMediaType.VIDEO or str(media_type) == HomeMaterialMediaType.VIDEO.value:
+ return int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_MB", 300))
+ return int(getattr(settings, "HOME_MATERIAL_MAX_IMAGE_MB", 20))
+
+ def _build_target(self, relative_dir: str, suffix: str) -> tuple[Path, str]:
+ file_id = generate_id()
+ relative = Path(relative_dir) / self._date_dir() / f"{file_id}{suffix}"
+ path = self.home_root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return path, f"/uploads/home_materials/{relative.as_posix()}"
+
+ async def save_upload_file(
+ self,
+ file: UploadFile,
+ *,
+ relative_dir: str,
+ allowed_extensions: Iterable[str],
+ label: str,
+ max_mb: int | None = None,
+ ) -> StoredFile:
+ suffix = self._safe_suffix(file.filename)
+ self._assert_extension(suffix, allowed_extensions, label)
+ target_path, file_url = self._build_target(relative_dir, suffix)
+ max_bytes = int(max_mb or 0) * 1024 * 1024 if max_mb else None
+ tmp_path = target_path.with_name(target_path.name + ".part")
+ size = 0
+ try:
+ with tmp_path.open("wb") as out:
+ while True:
+ chunk = await file.read(1024 * 1024)
+ if not chunk:
+ break
+ size += len(chunk)
+ if max_bytes and size > max_bytes:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"{label}文件大小超限,最大 {max_mb}MB",
+ )
+ out.write(chunk)
+ os.replace(tmp_path, target_path)
+ except Exception:
+ try:
+ if tmp_path.exists():
+ tmp_path.unlink()
+ finally:
+ raise
+ finally:
+ await file.seek(0)
+
+ return StoredFile(
+ storage_path=str(target_path),
+ file_url=file_url,
+ file_name=file.filename or target_path.name,
+ file_size_bytes=size,
+ suffix=suffix,
+ )
+
+ async def save_asset_file(self, file: UploadFile, media_type: HomeMaterialMediaType | str) -> StoredFile:
+ media = HomeMaterialMediaType(media_type)
+ if media == HomeMaterialMediaType.IMAGE:
+ return await self.save_upload_file(
+ file,
+ relative_dir="original/images",
+ allowed_extensions=HOME_MATERIAL_IMAGE_EXTENSIONS,
+ label="图片素材",
+ max_mb=self._limit_mb(media),
+ )
+ return await self.save_upload_file(
+ file,
+ relative_dir="original/videos",
+ allowed_extensions=HOME_MATERIAL_VIDEO_EXTENSIONS,
+ label="视频素材",
+ max_mb=self._limit_mb(media),
+ )
+
+ async def save_watermark_file(self, file: UploadFile) -> StoredFile:
+ return await self.save_upload_file(
+ file,
+ relative_dir="watermarks",
+ allowed_extensions=HOME_MATERIAL_WATERMARK_EXTENSIONS,
+ label="水印图片",
+ max_mb=int(getattr(settings, "HOME_MATERIAL_MAX_WATERMARK_MB", 10)),
+ )
+
+ def build_watermarked_target(self, asset_id: str, media_type: HomeMaterialMediaType | str) -> tuple[str, str]:
+ media = HomeMaterialMediaType(media_type)
+ suffix = ".png" if media == HomeMaterialMediaType.IMAGE else ".mp4"
+ folder = "watermarked/images" if media == HomeMaterialMediaType.IMAGE else "watermarked/videos"
+ relative = Path(folder) / self._date_dir() / f"{asset_id}{suffix}"
+ path = self.home_root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return str(path), f"/uploads/home_materials/{relative.as_posix()}"
+
+ def build_cover_target(self, asset_id: str) -> tuple[str, str]:
+ relative = Path("covers") / self._date_dir() / f"{asset_id}.jpg"
+ path = self.home_root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return str(path), f"/uploads/home_materials/{relative.as_posix()}"
+
+ @staticmethod
+ def file_size(path: str | None) -> int | None:
+ if not path:
+ return None
+ try:
+ return Path(path).stat().st_size
+ except FileNotFoundError:
+ return None
+
+ @staticmethod
+ def safe_remove(path: str | None) -> None:
+ if not path:
+ return
+ try:
+ Path(path).unlink(missing_ok=True)
+ except Exception:
+ pass
+
+
+storage_service = HomeMaterialStorageService()
diff --git a/video-gen-api/app/services/home_material/watermark_processor.py b/video-gen-api/app/services/home_material/watermark_processor.py
new file mode 100644
index 00000000..7ee6bf18
--- /dev/null
+++ b/video-gen-api/app/services/home_material/watermark_processor.py
@@ -0,0 +1,622 @@
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+import math
+import os
+import shutil
+import subprocess
+import tempfile
+from dataclasses import dataclass
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+from fastapi import HTTPException, status
+
+try:
+ from PIL import Image, ImageDraw, ImageFont
+except Exception: # pragma: no cover - 运行时给出明确错误
+ Image = None # type: ignore[assignment]
+ ImageDraw = None # type: ignore[assignment]
+ ImageFont = None # type: ignore[assignment]
+
+from app.config import settings
+from app.enums.home_material import (
+ HomeMaterialMediaType,
+ HomeMaterialWatermarkPosition,
+ HomeMaterialWatermarkSizeMode,
+ HomeMaterialWatermarkType,
+)
+from app.schemas.home_material import HomeMaterialTextWatermarkConfig, HomeMaterialWatermarkConfig
+
+
+@dataclass(frozen=True)
+class MediaProbeInfo:
+ width: int | None = None
+ height: int | None = None
+ duration_seconds: Decimal | None = None
+
+
+@dataclass(frozen=True)
+class WatermarkProcessResult:
+ output_path: str
+ width: int | None
+ height: int | None
+ duration_seconds: Decimal | None
+ file_size_bytes: int
+ cover_path: str | None = None
+
+
+class HomeMaterialWatermarkProcessor:
+ """首页素材 FFmpeg 水印处理器。纯处理层,不访问数据库。"""
+
+ def __init__(self) -> None:
+ self._image_semaphore = asyncio.Semaphore(int(getattr(settings, "HOME_MATERIAL_IMAGE_WATERMARK_CONCURRENCY", 4)))
+ self._video_semaphore = asyncio.Semaphore(int(getattr(settings, "HOME_MATERIAL_VIDEO_WATERMARK_CONCURRENCY", 2)))
+
+ def _ffmpeg_bin(self) -> str:
+ configured = getattr(settings, "FFMPEG_BIN", "") or ""
+ if configured:
+ return configured
+ found = shutil.which("ffmpeg") or shutil.which("ffmpeg.exe")
+ if not found:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="未找到 ffmpeg,请配置 FFMPEG_BIN 或安装 ffmpeg")
+ return found
+
+ def _ffprobe_bin(self) -> str:
+ configured = getattr(settings, "FFMPEG_BIN", "") or ""
+ if configured:
+ p = Path(configured)
+ candidate = p.with_name("ffprobe.exe" if p.name.endswith(".exe") else "ffprobe")
+ if candidate.exists():
+ return str(candidate)
+ found = shutil.which("ffprobe") or shutil.which("ffprobe.exe")
+ if not found:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="未找到 ffprobe,请确认 ffmpeg 环境完整")
+ return found
+
+ @staticmethod
+ def _run_blocking(args: list[str], timeout: int) -> tuple[str, str]:
+ """在线程里执行 FFmpeg/ffprobe。
+
+ Windows 下 uvicorn/watchfiles 有概率使用不支持子进程的 SelectorEventLoop,
+ asyncio.create_subprocess_exec 会直接抛 NotImplementedError。这里统一改为
+ subprocess.run + asyncio.to_thread,仍然不会阻塞 Web Server 事件循环。
+ """
+ creationflags = 0
+ if os.name == "nt":
+ creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
+ try:
+ completed = subprocess.run(
+ args,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=timeout,
+ check=False,
+ creationflags=creationflags,
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise RuntimeError("FFmpeg 处理超时") from exc
+ out = completed.stdout.decode("utf-8", errors="ignore")
+ err = completed.stderr.decode("utf-8", errors="ignore")
+ if completed.returncode != 0:
+ raise RuntimeError(err[-2000:] or f"FFmpeg 退出码异常:{completed.returncode}")
+ return out, err
+
+ async def _run(self, args: list[str], timeout: int | None = None) -> tuple[str, str]:
+ effective_timeout = timeout or int(getattr(settings, "HOME_MATERIAL_FFMPEG_TIMEOUT_SECONDS", 600))
+ return await asyncio.to_thread(self._run_blocking, args, effective_timeout)
+
+ async def probe(self, path: str) -> MediaProbeInfo:
+ args = [
+ self._ffprobe_bin(),
+ "-v",
+ "error",
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "stream=width,height,duration",
+ "-of",
+ "json",
+ path,
+ ]
+ stdout, _ = await self._run(args, timeout=30)
+ try:
+ data = json.loads(stdout or "{}")
+ stream = (data.get("streams") or [{}])[0]
+ duration_raw = stream.get("duration")
+ return MediaProbeInfo(
+ width=int(stream["width"]) if stream.get("width") is not None else None,
+ height=int(stream["height"]) if stream.get("height") is not None else None,
+ duration_seconds=Decimal(str(duration_raw)).quantize(Decimal("0.001")) if duration_raw not in (None, "N/A") else None,
+ )
+ except Exception:
+ return MediaProbeInfo()
+
+ @staticmethod
+ def _value(config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any], key: str, default: Any = None) -> Any:
+ if isinstance(config, dict):
+ return config.get(key, default)
+ return getattr(config, key, default)
+
+ def _watermark_type(self, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> str:
+ raw = self._value(config, "watermark_type", None) or HomeMaterialWatermarkType.IMAGE.value
+ return HomeMaterialWatermarkType(raw).value
+
+ def _watermark_width(self, source_width: int | None, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> int:
+ size_mode = self._value(config, "size_mode", HomeMaterialWatermarkSizeMode.RATIO.value)
+ if hasattr(size_mode, "value"):
+ size_mode = size_mode.value
+ width_px = self._value(config, "width_px", None)
+ width_ratio = self._value(config, "width_ratio", 0.18) or 0.18
+ if size_mode == HomeMaterialWatermarkSizeMode.PX.value and width_px:
+ return max(1, int(width_px))
+ base_width = source_width or 1080
+ return max(1, int(base_width * float(width_ratio)))
+
+ def _overlay_xy(self, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> tuple[str, str]:
+ position = self._value(config, "position", HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value)
+ if hasattr(position, "value"):
+ position = position.value
+ margin_x = int(self._value(config, "margin_x", 24) or 24)
+ margin_y = int(self._value(config, "margin_y", 24) or 24)
+ custom_x_ratio = self._value(config, "custom_x_ratio", None)
+ custom_y_ratio = self._value(config, "custom_y_ratio", None)
+
+ if position == HomeMaterialWatermarkPosition.CUSTOM.value:
+ x_ratio = float(custom_x_ratio if custom_x_ratio is not None else 0.5)
+ y_ratio = float(custom_y_ratio if custom_y_ratio is not None else 0.5)
+ return f"(main_w-overlay_w)*{x_ratio:.6f}", f"(main_h-overlay_h)*{y_ratio:.6f}"
+
+ positions: dict[str, tuple[str, str]] = {
+ HomeMaterialWatermarkPosition.TOP_LEFT.value: (str(margin_x), str(margin_y)),
+ HomeMaterialWatermarkPosition.TOP_CENTER.value: ("(main_w-overlay_w)/2", str(margin_y)),
+ HomeMaterialWatermarkPosition.TOP_RIGHT.value: (f"main_w-overlay_w-{margin_x}", str(margin_y)),
+ HomeMaterialWatermarkPosition.MIDDLE_LEFT.value: (str(margin_x), "(main_h-overlay_h)/2"),
+ HomeMaterialWatermarkPosition.CENTER.value: ("(main_w-overlay_w)/2", "(main_h-overlay_h)/2"),
+ HomeMaterialWatermarkPosition.MIDDLE_RIGHT.value: (f"main_w-overlay_w-{margin_x}", "(main_h-overlay_h)/2"),
+ HomeMaterialWatermarkPosition.BOTTOM_LEFT.value: (str(margin_x), f"main_h-overlay_h-{margin_y}"),
+ HomeMaterialWatermarkPosition.BOTTOM_CENTER.value: ("(main_w-overlay_w)/2", f"main_h-overlay_h-{margin_y}"),
+ HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value: (f"main_w-overlay_w-{margin_x}", f"main_h-overlay_h-{margin_y}"),
+ }
+ return positions.get(str(position), positions[HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value])
+
+ def _opacity(self, config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any]) -> float:
+ level = int(self._value(config, "opacity_level", 6) or 6)
+ return min(max(level, 1), 10) / 10
+
+ def _filter_complex_image_watermark(self, source_width: int | None, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> str:
+ wm_width = self._watermark_width(source_width, config)
+ opacity = self._opacity(config)
+ x, y = self._overlay_xy(config)
+ return f"[1:v]format=rgba,colorchannelmixer=aa={opacity:.2f},scale={wm_width}:-1[wm];[0:v][wm]overlay={x}:{y}[v]"
+
+ @staticmethod
+ def _hex_to_rgb(value: str) -> tuple[int, int, int]:
+ raw = (value or "#ffffff").strip()
+ if not raw.startswith("#") or len(raw) != 7:
+ raw = "#ffffff"
+ try:
+ return int(raw[1:3], 16), int(raw[3:5], 16), int(raw[5:7], 16)
+ except Exception:
+ return 255, 255, 255
+
+ def _font_path(self) -> str:
+ configured = str(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_FONT", "") or "").strip()
+ if not configured:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="重复文字水印字体未配置,请配置 HOME_MATERIAL_TEXT_WATERMARK_FONT 为开源可商用字体文件路径,例如 Noto Sans CJK SC / Source Han Sans SC。",
+ )
+ path = Path(configured)
+ if not path.exists() or not path.is_file():
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"重复文字水印字体文件不存在或不可读:{configured}",
+ )
+ return str(path)
+
+ def _assert_pillow_available(self) -> None:
+ if Image is None or ImageDraw is None or ImageFont is None:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="重复文字水印依赖 Pillow,请先安装 pillow,并配置 HOME_MATERIAL_TEXT_WATERMARK_FONT。",
+ )
+
+ def _text_config(self, config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any]) -> HomeMaterialTextWatermarkConfig | dict[str, Any]:
+ if isinstance(config, HomeMaterialTextWatermarkConfig):
+ return config
+ if isinstance(config, HomeMaterialWatermarkConfig):
+ if config.text_watermark is None:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印配置不能为空")
+ return config.text_watermark
+ text_config = config.get("text_watermark")
+ if not isinstance(text_config, dict):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印配置不能为空")
+ return text_config
+
+ def generate_repeated_text_layer_file(
+ self,
+ *,
+ width: int,
+ height: int,
+ text_config: HomeMaterialTextWatermarkConfig | dict[str, Any],
+ output_path: str,
+ ) -> str:
+ """用固定开源字体生成透明重复文字水印层。
+
+ 该方法同时用于前端精准预览和最终 FFmpeg overlay,保证前端看到的透明层和实际叠加层来自同一套渲染逻辑。
+ """
+ self._assert_pillow_available()
+ font_path = self._font_path()
+
+ safe_width = max(1, int(width))
+ safe_height = max(1, int(height))
+ max_text_length = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_TEXT_LENGTH", 64))
+ max_font_size = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_FONT_SIZE", 160))
+ max_gap = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_GAP", 2000))
+
+ text = str(self._value(text_config, "text", "") or "").strip()[:max_text_length]
+ if not text:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印内容不能为空")
+ font_size = max(8, min(max_font_size, int(self._value(text_config, "font_size_px", 28) or 28)))
+ rotate_deg = max(-90, min(90, int(self._value(text_config, "rotate_deg", -30) or 0)))
+ gap_x = max(20, min(max_gap, int(self._value(text_config, "gap_x", 220) or 220)))
+ gap_y = max(20, min(max_gap, int(self._value(text_config, "gap_y", 140) or 140)))
+ staggered = bool(self._value(text_config, "staggered", True))
+ opacity = self._opacity(text_config)
+ r, g, b = self._hex_to_rgb(str(self._value(text_config, "color", "#ffffff") or "#ffffff"))
+ alpha = int(round(opacity * 255))
+
+ font = ImageFont.truetype(font_path, font_size)
+ measure = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
+ measure_draw = ImageDraw.Draw(measure)
+ bbox = measure_draw.textbbox((0, 0), text, font=font)
+ text_w = max(1, bbox[2] - bbox[0])
+ text_h = max(1, bbox[3] - bbox[1])
+ padding = max(8, int(font_size * 0.8))
+
+ text_img = Image.new("RGBA", (text_w + padding * 2, text_h + padding * 2), (0, 0, 0, 0))
+ text_draw = ImageDraw.Draw(text_img)
+ text_draw.text((padding - bbox[0], padding - bbox[1]), text, font=font, fill=(r, g, b, alpha))
+ if rotate_deg:
+ text_img = text_img.rotate(rotate_deg, resample=Image.Resampling.BICUBIC, expand=True)
+
+ layer = Image.new("RGBA", (safe_width, safe_height), (0, 0, 0, 0))
+ tile_w, tile_h = text_img.size
+ step_x = max(gap_x, int(tile_w * 0.8))
+ step_y = max(gap_y, int(tile_h * 0.8))
+ start_x = -tile_w
+ start_y = -tile_h
+ end_x = safe_width + tile_w
+ end_y = safe_height + tile_h
+
+ row = 0
+ y = start_y
+ while y <= end_y:
+ offset = step_x // 2 if staggered and row % 2 == 1 else 0
+ x = start_x + offset
+ while x <= end_x:
+ layer.alpha_composite(text_img, (int(x), int(y)))
+ x += step_x
+ y += step_y
+ row += 1
+
+ target = Path(output_path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ layer.save(str(target), format="PNG")
+ return str(target)
+
+ def generate_repeated_text_layer_data_url(
+ self,
+ *,
+ width: int,
+ height: int,
+ text_config: HomeMaterialTextWatermarkConfig | dict[str, Any],
+ ) -> str:
+ max_width = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_WIDTH", 8192))
+ max_height = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_HEIGHT", 8192))
+ safe_width = max(1, min(max_width, int(width)))
+ safe_height = max(1, min(max_height, int(height)))
+ with tempfile.NamedTemporaryFile(prefix="home_material_text_wm_", suffix=".png", delete=False) as tmp:
+ tmp_path = tmp.name
+ try:
+ self.generate_repeated_text_layer_file(width=safe_width, height=safe_height, text_config=text_config, output_path=tmp_path)
+ raw = Path(tmp_path).read_bytes()
+ return "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
+ finally:
+ try:
+ Path(tmp_path).unlink(missing_ok=True)
+ except Exception:
+ pass
+
+ async def apply_watermark(
+ self,
+ *,
+ media_type: HomeMaterialMediaType | str,
+ source_path: str,
+ watermark_path: str | None,
+ output_path: str,
+ config: HomeMaterialWatermarkConfig | dict[str, Any],
+ cover_path: str | None = None,
+ ) -> WatermarkProcessResult:
+ media = HomeMaterialMediaType(media_type)
+ watermark_type = self._watermark_type(config)
+ if media == HomeMaterialMediaType.IMAGE:
+ async with self._image_semaphore:
+ if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
+ return await self._apply_image_repeated_text_watermark(source_path, output_path, config)
+ if not watermark_path:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片水印文件不能为空")
+ return await self._apply_image_watermark(source_path, watermark_path, output_path, config)
+ async with self._video_semaphore:
+ if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
+ return await self._apply_video_repeated_text_watermark(source_path, output_path, config, cover_path=cover_path)
+ if not watermark_path:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片水印文件不能为空")
+ return await self._apply_video_watermark(source_path, watermark_path, output_path, config, cover_path=cover_path)
+
+ async def _apply_image_watermark(
+ self,
+ source_path: str,
+ watermark_path: str,
+ output_path: str,
+ config: HomeMaterialWatermarkConfig | dict[str, Any],
+ ) -> WatermarkProcessResult:
+ source_info = await self.probe(source_path)
+ tmp_path = output_path + ".part"
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
+ filter_complex = self._filter_complex_image_watermark(source_info.width, config)
+ args = [
+ self._ffmpeg_bin(),
+ "-y",
+ "-i",
+ source_path,
+ "-i",
+ watermark_path,
+ "-filter_complex",
+ filter_complex,
+ "-map",
+ "[v]",
+ "-frames:v",
+ "1",
+ "-f",
+ "image2",
+ "-vcodec",
+ "png",
+ tmp_path,
+ ]
+ try:
+ await self._run(args)
+ os.replace(tmp_path, output_path)
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+ output_info = await self.probe(output_path)
+ return WatermarkProcessResult(
+ output_path=output_path,
+ width=output_info.width or source_info.width,
+ height=output_info.height or source_info.height,
+ duration_seconds=None,
+ file_size_bytes=os.path.getsize(output_path),
+ )
+
+ async def _apply_video_watermark(
+ self,
+ source_path: str,
+ watermark_path: str,
+ output_path: str,
+ config: HomeMaterialWatermarkConfig | dict[str, Any],
+ cover_path: str | None = None,
+ ) -> WatermarkProcessResult:
+ source_info = await self.probe(source_path)
+ max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
+ if source_info.duration_seconds is not None and source_info.duration_seconds > Decimal(max_duration):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
+
+ tmp_path = output_path + ".part"
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
+ filter_complex = self._filter_complex_image_watermark(source_info.width, config)
+ args = [
+ self._ffmpeg_bin(),
+ "-y",
+ "-i",
+ source_path,
+ "-i",
+ watermark_path,
+ "-filter_complex",
+ filter_complex,
+ "-map",
+ "[v]",
+ "-map",
+ "0:a?",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-crf",
+ "23",
+ "-c:a",
+ "copy",
+ "-movflags",
+ "+faststart",
+ "-f",
+ "mp4",
+ tmp_path,
+ ]
+ try:
+ await self._run(args)
+ os.replace(tmp_path, output_path)
+ finally:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+
+ output_info = await self.probe(output_path)
+ generated_cover = None
+ if cover_path:
+ generated_cover = await self.generate_cover(output_path, cover_path)
+ return WatermarkProcessResult(
+ output_path=output_path,
+ width=output_info.width or source_info.width,
+ height=output_info.height or source_info.height,
+ duration_seconds=output_info.duration_seconds or source_info.duration_seconds,
+ file_size_bytes=os.path.getsize(output_path),
+ cover_path=generated_cover,
+ )
+
+ async def _apply_image_repeated_text_watermark(
+ self,
+ source_path: str,
+ output_path: str,
+ config: HomeMaterialWatermarkConfig | dict[str, Any],
+ ) -> WatermarkProcessResult:
+ source_info = await self.probe(source_path)
+ width = int(source_info.width or 1080)
+ height = int(source_info.height or 1920)
+ tmp_path = output_path + ".part"
+ layer_path = output_path + ".text-layer.png"
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
+ self.generate_repeated_text_layer_file(width=width, height=height, text_config=self._text_config(config), output_path=layer_path)
+ args = [
+ self._ffmpeg_bin(),
+ "-y",
+ "-i",
+ source_path,
+ "-i",
+ layer_path,
+ "-filter_complex",
+ "[1:v]format=rgba[wm];[0:v][wm]overlay=0:0[v]",
+ "-map",
+ "[v]",
+ "-frames:v",
+ "1",
+ "-f",
+ "image2",
+ "-vcodec",
+ "png",
+ tmp_path,
+ ]
+ try:
+ await self._run(args)
+ os.replace(tmp_path, output_path)
+ finally:
+ for p in (tmp_path, layer_path):
+ if os.path.exists(p):
+ os.remove(p)
+ output_info = await self.probe(output_path)
+ return WatermarkProcessResult(
+ output_path=output_path,
+ width=output_info.width or source_info.width,
+ height=output_info.height or source_info.height,
+ duration_seconds=None,
+ file_size_bytes=os.path.getsize(output_path),
+ )
+
+ async def _apply_video_repeated_text_watermark(
+ self,
+ source_path: str,
+ output_path: str,
+ config: HomeMaterialWatermarkConfig | dict[str, Any],
+ cover_path: str | None = None,
+ ) -> WatermarkProcessResult:
+ source_info = await self.probe(source_path)
+ max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
+ if source_info.duration_seconds is not None and source_info.duration_seconds > Decimal(max_duration):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
+ width = int(source_info.width or 1080)
+ height = int(source_info.height or 1920)
+ tmp_path = output_path + ".part"
+ layer_path = output_path + ".text-layer.png"
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
+ self.generate_repeated_text_layer_file(width=width, height=height, text_config=self._text_config(config), output_path=layer_path)
+ args = [
+ self._ffmpeg_bin(),
+ "-y",
+ "-i",
+ source_path,
+ "-loop",
+ "1",
+ "-i",
+ layer_path,
+ "-filter_complex",
+ "[1:v]format=rgba[wm];[0:v][wm]overlay=0:0[v]",
+ "-map",
+ "[v]",
+ "-map",
+ "0:a?",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-crf",
+ "23",
+ "-c:a",
+ "copy",
+ "-movflags",
+ "+faststart",
+ "-shortest",
+ "-f",
+ "mp4",
+ tmp_path,
+ ]
+ try:
+ await self._run(args)
+ os.replace(tmp_path, output_path)
+ finally:
+ for p in (tmp_path, layer_path):
+ if os.path.exists(p):
+ os.remove(p)
+
+ output_info = await self.probe(output_path)
+ generated_cover = None
+ if cover_path:
+ generated_cover = await self.generate_cover(output_path, cover_path)
+ return WatermarkProcessResult(
+ output_path=output_path,
+ width=output_info.width or source_info.width,
+ height=output_info.height or source_info.height,
+ duration_seconds=output_info.duration_seconds or source_info.duration_seconds,
+ file_size_bytes=os.path.getsize(output_path),
+ cover_path=generated_cover,
+ )
+
+ async def generate_cover(self, video_path: str, cover_path: str) -> str:
+ tmp_path = cover_path + ".part"
+ Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
+ seek = getattr(settings, "VIDEO_COVER_SEEK_TIME", "00:00:01") or "00:00:01"
+ width = int(getattr(settings, "VIDEO_COVER_WIDTH", 720))
+ args = [
+ self._ffmpeg_bin(),
+ "-y",
+ "-ss",
+ seek,
+ "-i",
+ video_path,
+ "-frames:v",
+ "1",
+ "-vf",
+ f"scale={width}:-2",
+ "-f",
+ "image2",
+ "-vcodec",
+ "mjpeg",
+ tmp_path,
+ ]
+ try:
+ await self._run(args, timeout=int(getattr(settings, "VIDEO_COVER_TIMEOUT_SECONDS", 15)))
+ os.replace(tmp_path, cover_path)
+ return cover_path
+ except Exception:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+ fallback_seek = getattr(settings, "VIDEO_COVER_FALLBACK_SEEK_TIME", "00:00:00") or "00:00:00"
+ fallback_args = args.copy()
+ fallback_args[fallback_args.index(seek)] = fallback_seek
+ await self._run(fallback_args, timeout=int(getattr(settings, "VIDEO_COVER_TIMEOUT_SECONDS", 15)))
+ os.replace(tmp_path, cover_path)
+ return cover_path
+
+
+watermark_processor = HomeMaterialWatermarkProcessor()
diff --git a/video-gen-api/resource/font/NotoSansCJKsc-Regular.otf b/video-gen-api/resource/font/NotoSansCJKsc-Regular.otf
new file mode 100644
index 00000000..dc155624
Binary files /dev/null and b/video-gen-api/resource/font/NotoSansCJKsc-Regular.otf differ