diff --git a/video-gen-admin/src/App.tsx b/video-gen-admin/src/App.tsx index 704a990a..6aeb7fc6 100644 --- a/video-gen-admin/src/App.tsx +++ b/video-gen-admin/src/App.tsx @@ -33,6 +33,7 @@ import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail'; import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail'; import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig'; import AdminContactRequests from './pages/AdminContactRequests'; +import AdminHomeMaterials from './pages/AdminHomeMaterials'; import { useAdminStore } from './store'; @@ -107,6 +108,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/video-gen-admin/src/api/client.ts b/video-gen-admin/src/api/client.ts index 189ffe5f..ca2cccf4 100644 --- a/video-gen-admin/src/api/client.ts +++ b/video-gen-admin/src/api/client.ts @@ -1,6 +1,7 @@ /** * Real API client for connecting to the FastAPI backend. * Handles auth tokens, request/response encryption, snake_case→camelCase conversion. + * Supports both JSON bodies and multipart FormData bodies. */ import { encrypt, decrypt, isCryptoAvailable } from './crypto'; @@ -24,6 +25,7 @@ function toCamel(s: string): string { function keysToCamel(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(keysToCamel); if (obj !== null && typeof obj === 'object') { + if (obj instanceof File || obj instanceof Blob || obj instanceof FormData) return obj; return Object.fromEntries( Object.entries(obj as Record).map(([k, v]) => [toCamel(k), keysToCamel(v)]) ); @@ -54,31 +56,35 @@ async function tryDecrypt(data: string): Promise { export async function apiRequest(path: string, options: RequestOptions = {}): Promise { const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options; + const isFormData = typeof FormData !== 'undefined' && body instanceof FormData; - const headers: Record = { - 'Content-Type': 'application/json', - }; + const headers: Record = {}; + if (!isFormData) headers['Content-Type'] = 'application/json'; if (auth) { const token = getToken(); if (token) headers['Authorization'] = `Bearer ${token}`; } - let bodyStr: string | undefined; + let requestBody: BodyInit | undefined; if (body !== undefined) { - const json = JSON.stringify(body); - if (encryptBody) { - headers['X-Encrypted'] = 'true'; - bodyStr = JSON.stringify({ data: await encrypt(json) }); + if (isFormData) { + requestBody = body as FormData; } else { - bodyStr = json; + const json = JSON.stringify(body); + if (encryptBody) { + headers['X-Encrypted'] = 'true'; + requestBody = JSON.stringify({ data: await encrypt(json) }); + } else { + requestBody = json; + } } } const res = await fetch(`${BASE_URL}/api${path}`, { method, headers, - body: bodyStr, + body: requestBody, }); if (res.status === 204) return undefined as T; @@ -96,7 +102,7 @@ export async function apiRequest(path: string, options: RequestOptions = {}): // If the response is encrypted, decrypt it const hasEncryptedData = parsed && typeof parsed.data === 'string'; - if (hasEncryptedData && encryptBody) { + if (hasEncryptedData && encryptBody && !isFormData) { const decrypted = await tryDecrypt(parsed.data); if (decrypted !== null) { parsed = JSON.parse(decrypted); @@ -107,7 +113,8 @@ export async function apiRequest(path: string, options: RequestOptions = {}): // Handle error responses if (!res.ok) { - const msg = parsed?.detail || `请求失败 (${res.status})`; + const rawMsg = parsed?.detail || parsed?.message || `请求失败 (${res.status})`; + const msg = typeof rawMsg === 'string' ? rawMsg : JSON.stringify(rawMsg); if (res.status === 401) { clearToken(); // Only redirect to login if not already on login page @@ -125,7 +132,7 @@ export async function apiRequest(path: string, options: RequestOptions = {}): // Convenience methods export const api = { get: (path: string, auth = true) => apiRequest(path, { auth }), - post: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'POST', body, auth }), - put: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'PUT', body, auth }), + post: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'POST', body, auth, encryptBody: body instanceof FormData ? false : USE_ENCRYPTION }), + put: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'PUT', body, auth, encryptBody: body instanceof FormData ? false : USE_ENCRYPTION }), delete: (path: string, auth = true) => apiRequest(path, { method: 'DELETE', auth }), }; diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index c55b6191..874d6ceb 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -17,6 +17,28 @@ import type { AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams, } from '../types'; +import type { + HomeMaterialAsset, + HomeMaterialAssetQueryParams, + HomeMaterialAssetStatusOut, + HomeMaterialAssetUpdatePayload, + HomeMaterialCategory, + HomeMaterialCategoryPayload, + HomeMaterialCategoryQueryParams, + HomeMaterialConfig, + HomeMaterialListResponse, + HomeMaterialRegeneratePayload, + HomeMaterialTextWatermarkConfig, + HomeMaterialTextWatermarkPayload, + HomeMaterialTextWatermarkPreviewRequest, + HomeMaterialTextWatermarkPreviewResponse, + HomeMaterialUploadAssetParams, + HomeMaterialUploadResult, + HomeMaterialWatermark, + HomeMaterialWatermarkPayload, + HomeMaterialWatermarkQueryParams, +} from '../types'; + // ── Auth ────────────────────────────────────────────────── export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise { @@ -748,4 +770,162 @@ export async function getMaterialConsumpList(params: MaterialConsumpParams): Pro if (params.page !== undefined) query.set('page', String(params.page)); if (params.page_size !== undefined) query.set('page_size', String(params.page_size)); return api.get(`/material-consumption/admin/list?${query.toString()}`); -} \ No newline at end of file +} +// ── 首页素材行业装修 ────────────────────────────────────── + +export async function getHomeMaterialConfig(): Promise { + return api.get('/admin/home-material/config'); +} + +export async function saveHomeMaterialConfig(payload: HomeMaterialConfig): Promise { + return api.put('/admin/home-material/config', { + enabled: payload.enabled, + title: payload.title, + subtitle: payload.subtitle, + show_original_in_admin: payload.showOriginalInAdmin, + }); +} + +export async function getHomeMaterialCategories(filters?: HomeMaterialCategoryQueryParams): Promise> { + const params = new URLSearchParams(); + setMaybe(params, 'page', filters?.page); + setMaybe(params, 'page_size', filters?.pageSize); + setMaybe(params, 'keyword', filters?.keyword); + setMaybe(params, 'is_active', filters?.isActive); + const q = params.toString() ? `?${params}` : ''; + return api.get(`/admin/home-material/categories${q}`); +} + +export async function saveHomeMaterialCategory(payload: HomeMaterialCategoryPayload & { id?: string }): Promise { + const body = { + name: payload.name, + key: payload.key, + description: payload.description || null, + icon: payload.icon || null, + is_active: payload.is_active, + sort_order: payload.sort_order, + }; + if (payload.id) return api.put(`/admin/home-material/categories/${payload.id}`, body); + return api.post('/admin/home-material/categories', body); +} + +export async function deleteHomeMaterialCategory(id: string): Promise { + await api.delete(`/admin/home-material/categories/${id}`); +} + +export async function getHomeMaterialWatermarks(filters?: HomeMaterialWatermarkQueryParams): Promise> { + const params = new URLSearchParams(); + setMaybe(params, 'page', filters?.page); + setMaybe(params, 'page_size', filters?.pageSize); + setMaybe(params, 'is_active', filters?.isActive); + const q = params.toString() ? `?${params}` : ''; + return api.get(`/admin/home-material/watermarks${q}`); +} + +export async function uploadHomeMaterialWatermark(file: File, name?: string, isDefault = false): Promise { + const form = new FormData(); + form.append('file', file); + if (name) form.append('name', name); + form.append('is_default', String(isDefault)); + return api.post('/admin/home-material/watermarks', form); +} + +export async function updateHomeMaterialWatermark(id: string, payload: HomeMaterialWatermarkPayload): Promise { + return api.put(`/admin/home-material/watermarks/${id}`, payload); +} + +export async function deleteHomeMaterialWatermark(id: string): Promise { + await api.delete(`/admin/home-material/watermarks/${id}`); +} + +export async function getHomeMaterialAssets(filters?: HomeMaterialAssetQueryParams): Promise> { + const params = new URLSearchParams(); + setMaybe(params, 'page', filters?.page); + setMaybe(params, 'page_size', filters?.pageSize); + setMaybe(params, 'category_id', filters?.categoryId); + setMaybe(params, 'media_type', filters?.mediaType); + setMaybe(params, 'status', filters?.status); + setMaybe(params, 'is_active', filters?.isActive); + setMaybe(params, 'keyword', filters?.keyword); + const q = params.toString() ? `?${params}` : ''; + return api.get(`/admin/home-material/assets${q}`); +} + +export async function getHomeMaterialAsset(id: string): Promise { + return api.get(`/admin/home-material/assets/${id}`); +} + +export async function getHomeMaterialAssetStatus(id: string): Promise { + return api.get(`/admin/home-material/assets/${id}/status`); +} + +function toHomeMaterialTextWatermarkPayload(config?: HomeMaterialTextWatermarkConfig | null): HomeMaterialTextWatermarkPayload | null { + if (!config) return null; + return { + text: config.text, + opacity_level: config.opacityLevel, + font_size_px: config.fontSizePx, + color: config.color, + rotate_deg: config.rotateDeg, + gap_x: config.gapX, + gap_y: config.gapY, + staggered: config.staggered, + }; +} + +export async function uploadHomeMaterialAsset(payload: HomeMaterialUploadAssetParams): Promise { + const form = new FormData(); + form.append('category_id', payload.categoryId); + form.append('file', payload.file); + form.append('media_type', payload.mediaType); + if (payload.title && payload.title.trim()) form.append('title', payload.title.trim()); + form.append('watermark_type', payload.watermarkType); + + if (payload.watermarkType === 'image') { + if (payload.watermarkId) form.append('watermark_id', payload.watermarkId); + if (payload.watermarkFile) form.append('watermark_file', payload.watermarkFile); + form.append('opacity_level', String(payload.opacityLevel)); + form.append('position', payload.position); + if (payload.customXRatio !== undefined && payload.customXRatio !== null) form.append('custom_x_ratio', String(payload.customXRatio)); + if (payload.customYRatio !== undefined && payload.customYRatio !== null) form.append('custom_y_ratio', String(payload.customYRatio)); + form.append('size_mode', payload.sizeMode); + if (payload.widthRatio !== undefined && payload.widthRatio !== null) form.append('width_ratio', String(payload.widthRatio)); + if (payload.widthPx !== undefined && payload.widthPx !== null) form.append('width_px', String(payload.widthPx)); + form.append('margin_x', String(payload.marginX)); + form.append('margin_y', String(payload.marginY)); + } else { + const text = toHomeMaterialTextWatermarkPayload(payload.textWatermark); + if (text) { + form.append('text_watermark_text', text.text); + form.append('text_watermark_opacity_level', String(text.opacity_level)); + form.append('text_watermark_font_size_px', String(text.font_size_px)); + form.append('text_watermark_color', text.color); + form.append('text_watermark_rotate_deg', String(text.rotate_deg)); + form.append('text_watermark_gap_x', String(text.gap_x)); + form.append('text_watermark_gap_y', String(text.gap_y)); + form.append('text_watermark_staggered', String(text.staggered)); + } + } + + form.append('is_active', String(payload.isActive)); + form.append('sort_order', String(payload.sortOrder)); + form.append('wait', String(!!payload.wait)); + form.append('wait_timeout_seconds', String(payload.waitTimeoutSeconds ?? 30)); + return api.post('/admin/home-material/assets', form); +} + +export async function updateHomeMaterialAsset(id: string, payload: HomeMaterialAssetUpdatePayload): Promise { + return api.put(`/admin/home-material/assets/${id}`, payload); +} + +export async function previewHomeMaterialTextWatermark(payload: HomeMaterialTextWatermarkPreviewRequest): Promise { + return api.post('/admin/home-material/text-watermark-preview', payload); +} + +export async function regenerateHomeMaterialWatermark(id: string, payload: HomeMaterialRegeneratePayload): Promise { + return api.post(`/admin/home-material/assets/${id}/regenerate-watermark`, payload); +} + +export async function deleteHomeMaterialAsset(id: string): Promise { + await api.delete(`/admin/home-material/assets/${id}`); +} diff --git a/video-gen-admin/src/pages/AdminHomeMaterials.tsx b/video-gen-admin/src/pages/AdminHomeMaterials.tsx new file mode 100644 index 00000000..1e8dfde9 --- /dev/null +++ b/video-gen-admin/src/pages/AdminHomeMaterials.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Card, Form, Input, Space, Switch, Tabs, message } from 'antd'; +import { PlusOutlined, SaveOutlined } from '@ant-design/icons'; +import { + getHomeMaterialCategories, + getHomeMaterialConfig, + getHomeMaterialWatermarks, + saveHomeMaterialConfig, +} from '../api'; +import type { HomeMaterialCategory, HomeMaterialConfig, HomeMaterialWatermark } from '../types'; +import HomeMaterialAssetTable from './homeMaterials/HomeMaterialAssetTable'; +import HomeMaterialCategoryPanel from './homeMaterials/HomeMaterialCategoryPanel'; +import HomeMaterialUploadModal from './homeMaterials/HomeMaterialUploadModal'; +import WatermarkLibraryModal from './homeMaterials/WatermarkLibraryModal'; + +const AdminHomeMaterials: React.FC = () => { + const [form] = Form.useForm(); + const [configLoading, setConfigLoading] = useState(false); + const [categories, setCategories] = useState([]); + const [watermarks, setWatermarks] = useState([]); + 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 ( +
+ +
+ + + + + + + +
+
+ + + + + + )} + > + , + }, + { + 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, mediaType: v, page: 1 }))} + options={[{ label: '图片', value: 'image' }, { label: '视频', value: 'video' }]} + /> + + + + + ({ label: c.name, value: c.id }))} disabled={submitting} /> + + + + + + + + + ); +}; + +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({ position: v })} options={positionOptions} /> + + + + + + + + + + + ); + + 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